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.handler;
15 import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
17 import java.math.BigDecimal;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.time.LocalDateTime;
21 import java.time.LocalTime;
22 import java.time.format.DateTimeFormatter;
23 import java.time.temporal.ChronoUnit;
24 import java.util.Arrays;
25 import java.util.Collection;
26 import java.util.Currency;
27 import java.util.List;
29 import java.util.Map.Entry;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.eclipse.jetty.http.HttpStatus;
38 import org.openhab.binding.energidataservice.internal.ApiController;
39 import org.openhab.binding.energidataservice.internal.CacheManager;
40 import org.openhab.binding.energidataservice.internal.DatahubTariff;
41 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
42 import org.openhab.binding.energidataservice.internal.api.ChargeType;
43 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
44 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
45 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
46 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
47 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
48 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
49 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
50 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
51 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
52 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
53 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
54 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
55 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
56 import org.openhab.core.i18n.TimeZoneProvider;
57 import org.openhab.core.library.types.DecimalType;
58 import org.openhab.core.library.types.StringType;
59 import org.openhab.core.thing.Channel;
60 import org.openhab.core.thing.ChannelUID;
61 import org.openhab.core.thing.Thing;
62 import org.openhab.core.thing.ThingStatus;
63 import org.openhab.core.thing.ThingStatusDetail;
64 import org.openhab.core.thing.binding.BaseThingHandler;
65 import org.openhab.core.thing.binding.ThingHandlerService;
66 import org.openhab.core.types.Command;
67 import org.openhab.core.types.RefreshType;
68 import org.openhab.core.types.UnDefType;
69 import org.slf4j.Logger;
70 import org.slf4j.LoggerFactory;
72 import com.google.gson.Gson;
75 * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
76 * sent to one of the channels.
78 * @author Jacob Laursen - Initial contribution
81 public class EnergiDataServiceHandler extends BaseThingHandler {
83 private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
84 private final TimeZoneProvider timeZoneProvider;
85 private final ApiController apiController;
86 private final CacheManager cacheManager;
87 private final Gson gson = new Gson();
89 private EnergiDataServiceConfiguration config;
90 private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
91 private @Nullable ScheduledFuture<?> refreshFuture;
92 private @Nullable ScheduledFuture<?> priceUpdateFuture;
94 private record Price(String hourStart, BigDecimal spotPrice, String spotPriceCurrency,
95 @Nullable BigDecimal netTariff, @Nullable BigDecimal systemTariff, @Nullable BigDecimal electricityTax,
96 @Nullable BigDecimal transmissionNetTariff) {
99 public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
101 this.timeZoneProvider = timeZoneProvider;
102 this.apiController = new ApiController(httpClient, timeZoneProvider);
103 this.cacheManager = new CacheManager();
105 // Default configuration
106 this.config = new EnergiDataServiceConfiguration();
110 public void handleCommand(ChannelUID channelUID, Command command) {
111 if (!(command instanceof RefreshType)) {
115 if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
116 refreshElectricityPrices();
121 public void initialize() {
122 config = getConfigAs(EnergiDataServiceConfiguration.class);
124 if (config.priceArea.isBlank()) {
125 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
126 "@text/offline.conf-error.no-price-area");
129 GlobalLocationNumber gln = config.getGridCompanyGLN();
130 if (!gln.isEmpty() && !gln.isValid()) {
131 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
132 "@text/offline.conf-error.invalid-grid-company-gln");
135 gln = config.getEnerginetGLN();
136 if (!gln.isEmpty() && !gln.isValid()) {
137 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
138 "@text/offline.conf-error.invalid-energinet-gln");
142 updateStatus(ThingStatus.UNKNOWN);
144 refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
148 public void dispose() {
149 ScheduledFuture<?> refreshFuture = this.refreshFuture;
150 if (refreshFuture != null) {
151 refreshFuture.cancel(true);
152 this.refreshFuture = null;
154 ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
155 if (priceUpdateFuture != null) {
156 priceUpdateFuture.cancel(true);
157 this.priceUpdateFuture = null;
160 cacheManager.clear();
164 public Collection<Class<? extends ThingHandlerService>> getServices() {
165 return Set.of(EnergiDataServiceActions.class);
168 private void refreshElectricityPrices() {
169 RetryStrategy retryPolicy;
171 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
172 downloadSpotPrices();
175 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
176 if (isLinked(datahubTariff.getChannelId()) || isLinked(CHANNEL_HOURLY_PRICES)) {
177 downloadTariffs(datahubTariff);
181 updateStatus(ThingStatus.ONLINE);
184 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
185 if (cacheManager.getNumberOfFutureSpotPrices() < 13) {
186 retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing(DAILY_REFRESH_TIME_CET,
189 retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
192 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
194 } catch (DataServiceException e) {
195 if (e.getHttpStatus() != 0) {
196 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
197 HttpStatus.getCode(e.getHttpStatus()).getMessage());
199 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
201 if (e.getCause() != null) {
202 logger.debug("Error retrieving prices", e);
204 retryPolicy = RetryPolicyFactory.fromThrowable(e);
205 } catch (InterruptedException e) {
206 logger.debug("Refresh job interrupted");
207 Thread.currentThread().interrupt();
211 rescheduleRefreshJob(retryPolicy);
214 private void downloadSpotPrices() throws InterruptedException, DataServiceException {
215 if (cacheManager.areSpotPricesFullyCached()) {
216 logger.debug("Cached spot prices still valid, skipping download.");
219 DateQueryParameter start;
220 if (cacheManager.areHistoricSpotPricesCached()) {
221 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
223 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
224 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
226 Map<String, String> properties = editProperties();
227 ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
229 cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
230 updateProperties(properties);
233 private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
234 GlobalLocationNumber globalLocationNumber = switch (datahubTariff) {
235 case NET_TARIFF -> config.getGridCompanyGLN();
236 default -> config.getEnerginetGLN();
238 if (globalLocationNumber.isEmpty()) {
241 if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
242 logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
243 cacheManager.updateTariffs(datahubTariff);
245 DatahubTariffFilter filter = switch (datahubTariff) {
246 case NET_TARIFF -> getNetTariffFilter();
247 case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
248 case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
249 case TRANSMISSION_NET_TARIFF -> DatahubTariffFilterFactory.getTransmissionNetTariff();
251 cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
255 private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
256 DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
257 Map<String, String> properties = editProperties();
258 Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
259 ChargeType.Tariff, filter, properties);
260 updateProperties(properties);
265 private DatahubTariffFilter getNetTariffFilter() {
266 Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
267 if (channel == null) {
268 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
271 DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
272 .as(DatahubPriceConfiguration.class);
274 if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
275 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
278 DateQueryParameter start = datahubPriceConfiguration.getStart();
280 logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
281 datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
282 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
285 Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
286 Set<String> notes = datahubPriceConfiguration.getNotes();
287 DatahubTariffFilter filter;
288 if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
289 // Completely override filter.
290 filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
292 // Only override start date in pre-configured filter.
293 filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN),
297 return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
298 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
301 private void updatePrices() {
302 cacheManager.cleanup();
304 updateCurrentSpotPrice();
305 Arrays.stream(DatahubTariff.values())
306 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
307 updateHourlyPrices();
309 reschedulePriceUpdateJob();
312 private void updateCurrentSpotPrice() {
313 if (!isLinked(CHANNEL_SPOT_PRICE)) {
316 BigDecimal spotPrice = cacheManager.getSpotPrice();
317 updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
320 private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
321 if (!isLinked(channelId)) {
324 updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
327 private void updateHourlyPrices() {
328 if (!isLinked(CHANNEL_HOURLY_PRICES)) {
331 Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
332 Price[] targetPrices = new Price[spotPriceMap.size()];
333 List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
334 .sorted(Map.Entry.comparingByKey()).toList();
337 for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
338 Instant hourStart = sourcePrice.getKey();
339 BigDecimal netTariff = cacheManager.getTariff(DatahubTariff.NET_TARIFF, hourStart);
340 BigDecimal systemTariff = cacheManager.getTariff(DatahubTariff.SYSTEM_TARIFF, hourStart);
341 BigDecimal electricityTax = cacheManager.getTariff(DatahubTariff.ELECTRICITY_TAX, hourStart);
342 BigDecimal transmissionNetTariff = cacheManager.getTariff(DatahubTariff.TRANSMISSION_NET_TARIFF, hourStart);
343 targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, netTariff,
344 systemTariff, electricityTax, transmissionNetTariff);
346 updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
350 * Get the configured {@link Currency} for spot prices.
352 * @return Spot price currency
354 public Currency getCurrency() {
355 return config.getCurrency();
359 * Get cached spot prices or try once to download them if not cached
360 * (usually if no items are linked).
362 * @return Map of future spot prices
364 public Map<Instant, BigDecimal> getSpotPrices() {
366 downloadSpotPrices();
367 } catch (DataServiceException e) {
368 if (logger.isDebugEnabled()) {
369 logger.warn("Error retrieving spot prices", e);
371 logger.warn("Error retrieving spot prices: {}", e.getMessage());
373 } catch (InterruptedException e) {
374 Thread.currentThread().interrupt();
377 return cacheManager.getSpotPrices();
381 * Return cached tariffs or try once to download them if not cached
382 * (usually if no items are linked).
384 * @return Map of future tariffs
386 public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
388 downloadTariffs(datahubTariff);
389 } catch (DataServiceException e) {
390 if (logger.isDebugEnabled()) {
391 logger.warn("Error retrieving tariffs", e);
393 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
395 } catch (InterruptedException e) {
396 Thread.currentThread().interrupt();
399 return cacheManager.getTariffs(datahubTariff);
402 private void reschedulePriceUpdateJob() {
403 ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
404 if (priceUpdateJob != null) {
405 // Do not interrupt ourselves.
406 priceUpdateJob.cancel(false);
407 this.priceUpdateFuture = null;
410 Instant now = Instant.now();
411 long millisUntilNextClockHour = Duration
412 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
413 this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
414 TimeUnit.MILLISECONDS);
415 logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
418 private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
419 // Preserve state of previous retry policy when configuration is the same.
420 if (!retryPolicy.equals(this.retryPolicy)) {
421 this.retryPolicy = retryPolicy;
424 ScheduledFuture<?> refreshJob = this.refreshFuture;
426 long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
427 Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
428 this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
430 logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
431 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
432 updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
433 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
435 if (refreshJob != null) {
436 refreshJob.cancel(true);