2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.energidataservice.internal;
15 import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
17 import java.time.Instant;
18 import java.time.LocalDateTime;
19 import java.time.format.DateTimeFormatter;
20 import java.util.Arrays;
21 import java.util.Collection;
22 import java.util.Currency;
23 import java.util.HashMap;
24 import java.util.List;
26 import java.util.Objects;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.TimeoutException;
29 import java.util.stream.Collectors;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.eclipse.jetty.client.api.Request;
35 import org.eclipse.jetty.http.HttpFields;
36 import org.eclipse.jetty.http.HttpMethod;
37 import org.eclipse.jetty.http.HttpStatus;
38 import org.openhab.binding.energidataservice.internal.api.ChargeType;
39 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
40 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
41 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
42 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
43 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecords;
44 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
45 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecords;
46 import org.openhab.binding.energidataservice.internal.api.serialization.InstantDeserializer;
47 import org.openhab.binding.energidataservice.internal.api.serialization.LocalDateTimeDeserializer;
48 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
49 import org.openhab.core.i18n.TimeZoneProvider;
50 import org.osgi.framework.FrameworkUtil;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
54 import com.google.gson.Gson;
55 import com.google.gson.GsonBuilder;
56 import com.google.gson.JsonSyntaxException;
59 * The {@link ApiController} is responsible for interacting with Energi Data Service.
61 * @author Jacob Laursen - Initial contribution
64 public class ApiController {
65 private static final String ENDPOINT = "https://api.energidataservice.dk/";
66 private static final String DATASET_PATH = "dataset/";
68 private static final String DATASET_NAME_SPOT_PRICES = "Elspotprices";
69 private static final String DATASET_NAME_DATAHUB_PRICELIST = "DatahubPricelist";
71 private static final String FILTER_KEY_PRICE_AREA = "PriceArea";
72 private static final String FILTER_KEY_CHARGE_TYPE = "ChargeType";
73 private static final String FILTER_KEY_CHARGE_TYPE_CODE = "ChargeTypeCode";
74 private static final String FILTER_KEY_GLN_NUMBER = "GLN_Number";
75 private static final String FILTER_KEY_NOTE = "Note";
77 private static final String HEADER_REMAINING_CALLS = "RemainingCalls";
78 private static final String HEADER_TOTAL_CALLS = "TotalCalls";
80 private final Logger logger = LoggerFactory.getLogger(ApiController.class);
81 private final Gson gson = new GsonBuilder() //
82 .registerTypeAdapter(Instant.class, new InstantDeserializer()) //
83 .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeDeserializer()) //
85 private final HttpClient httpClient;
86 private final TimeZoneProvider timeZoneProvider;
87 private final String userAgent;
89 public ApiController(HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
90 this.httpClient = httpClient;
91 this.timeZoneProvider = timeZoneProvider;
92 userAgent = "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
96 * Retrieve spot prices for requested area and in requested {@link Currency}.
98 * @param priceArea Usually DK1 or DK2
99 * @param currency DKK or EUR
100 * @param start Specifies the start point of the period for the data request
101 * @param properties Map of properties which will be updated with metadata from headers
102 * @return Records with pairs of hour start and price in requested currency.
103 * @throws InterruptedException
104 * @throws DataServiceException
106 public ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, DateQueryParameter start,
107 Map<String, String> properties) throws InterruptedException, DataServiceException {
108 if (!SUPPORTED_CURRENCIES.contains(currency)) {
109 throw new IllegalArgumentException("Invalid currency " + currency.getCurrencyCode());
112 Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_SPOT_PRICES)
113 .param("start", start.toString()) //
114 .param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
115 .param("columns", "HourUTC,SpotPrice" + currency) //
117 .method(HttpMethod.GET);
119 logger.trace("GET request for {}", request.getURI());
122 ContentResponse response = request.send();
124 updatePropertiesFromResponse(response, properties);
126 int status = response.getStatus();
127 if (!HttpStatus.isSuccess(status)) {
128 throw new DataServiceException("The request failed with HTTP error " + status, status);
130 String responseContent = response.getContentAsString();
131 if (responseContent.isEmpty()) {
132 throw new DataServiceException("Empty response");
134 logger.trace("Response content: '{}'", responseContent);
136 ElspotpriceRecords records = gson.fromJson(responseContent, ElspotpriceRecords.class);
137 if (records == null) {
138 throw new DataServiceException("Error parsing response");
141 if (records.total() == 0 || Objects.isNull(records.records()) || records.records().length == 0) {
142 throw new DataServiceException("No records");
145 return Arrays.stream(records.records()).filter(Objects::nonNull).toArray(ElspotpriceRecord[]::new);
146 } catch (JsonSyntaxException e) {
147 throw new DataServiceException("Error parsing response", e);
148 } catch (TimeoutException | ExecutionException e) {
149 throw new DataServiceException(e);
153 private void updatePropertiesFromResponse(ContentResponse response, Map<String, String> properties) {
154 HttpFields headers = response.getHeaders();
155 String remainingCalls = headers.get(HEADER_REMAINING_CALLS);
156 if (remainingCalls != null) {
157 properties.put(PROPERTY_REMAINING_CALLS, remainingCalls);
159 String totalCalls = headers.get(HEADER_TOTAL_CALLS);
160 if (totalCalls != null) {
161 properties.put(PROPERTY_TOTAL_CALLS, totalCalls);
163 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
164 properties.put(PROPERTY_LAST_CALL, LocalDateTime.now(timeZoneProvider.getTimeZone()).format(formatter));
168 * Retrieve datahub pricelists for requested GLN and charge type/charge type code.
170 * @param globalLocationNumber Global Location Number of the Charge Owner
171 * @param chargeType Charge type (Subscription, Fee or Tariff).
172 * @param tariffFilter Tariff filter (charge type codes and notes).
173 * @param properties Map of properties which will be updated with metadata from headers
174 * @return Price list for requested GLN and note.
175 * @throws InterruptedException
176 * @throws DataServiceException
178 public Collection<DatahubPricelistRecord> getDatahubPriceLists(GlobalLocationNumber globalLocationNumber,
179 ChargeType chargeType, DatahubTariffFilter tariffFilter, Map<String, String> properties)
180 throws InterruptedException, DataServiceException {
181 String columns = "ValidFrom,ValidTo,ChargeTypeCode";
182 for (int i = 1; i < 25; i++) {
183 columns += ",Price" + i;
186 Map<String, Collection<String>> filterMap = new HashMap<>(Map.of( //
187 FILTER_KEY_GLN_NUMBER, List.of(globalLocationNumber.toString()), //
188 FILTER_KEY_CHARGE_TYPE, List.of(chargeType.toString())));
190 Collection<String> chargeTypeCodes = tariffFilter.getChargeTypeCodesAsStrings();
191 if (!chargeTypeCodes.isEmpty()) {
192 filterMap.put(FILTER_KEY_CHARGE_TYPE_CODE, chargeTypeCodes);
195 Collection<String> notes = tariffFilter.getNotes();
196 if (!notes.isEmpty()) {
197 filterMap.put(FILTER_KEY_NOTE, notes);
200 Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_DATAHUB_PRICELIST)
201 .param("filter", mapToFilter(filterMap)) //
202 .param("columns", columns) //
204 .method(HttpMethod.GET);
206 DateQueryParameter dateQueryParameter = tariffFilter.getDateQueryParameter();
207 if (!dateQueryParameter.isEmpty()) {
208 request = request.param("start", dateQueryParameter.toString());
211 logger.trace("GET request for {}", request.getURI());
214 ContentResponse response = request.send();
216 updatePropertiesFromResponse(response, properties);
218 int status = response.getStatus();
219 if (!HttpStatus.isSuccess(status)) {
220 throw new DataServiceException("The request failed with HTTP error " + status, status);
222 String responseContent = response.getContentAsString();
223 if (responseContent.isEmpty()) {
224 throw new DataServiceException("Empty response");
226 logger.trace("Response content: '{}'", responseContent);
228 DatahubPricelistRecords records = gson.fromJson(responseContent, DatahubPricelistRecords.class);
229 if (records == null) {
230 throw new DataServiceException("Error parsing response");
233 if (records.limit() > 0 && records.limit() < records.total()) {
234 logger.warn("{} price list records available, but only {} returned.", records.total(), records.limit());
237 if (Objects.isNull(records.records())) {
241 return Arrays.stream(records.records()).filter(Objects::nonNull).toList();
242 } catch (JsonSyntaxException e) {
243 throw new DataServiceException("Error parsing response", e);
244 } catch (TimeoutException | ExecutionException e) {
245 throw new DataServiceException(e);
249 private String mapToFilter(Map<String, Collection<String>> map) {
250 return "{" + map.entrySet().stream().map(
251 e -> "\"" + e.getKey() + "\":[\"" + e.getValue().stream().collect(Collectors.joining("\",\"")) + "\"]")
252 .collect(Collectors.joining(",")) + "}";