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.Collection;
25 import java.util.Currency;
26 import java.util.List;
28 import java.util.Map.Entry;
30 import java.util.concurrent.ScheduledFuture;
31 import java.util.concurrent.TimeUnit;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.http.HttpStatus;
37 import org.openhab.binding.energidataservice.internal.ApiController;
38 import org.openhab.binding.energidataservice.internal.CacheManager;
39 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
40 import org.openhab.binding.energidataservice.internal.api.ChargeType;
41 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
42 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
43 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
44 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
45 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
46 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
47 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
48 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
49 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
50 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
51 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
52 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
53 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
54 import org.openhab.core.i18n.TimeZoneProvider;
55 import org.openhab.core.library.types.DecimalType;
56 import org.openhab.core.library.types.StringType;
57 import org.openhab.core.thing.Channel;
58 import org.openhab.core.thing.ChannelUID;
59 import org.openhab.core.thing.Thing;
60 import org.openhab.core.thing.ThingStatus;
61 import org.openhab.core.thing.ThingStatusDetail;
62 import org.openhab.core.thing.binding.BaseThingHandler;
63 import org.openhab.core.thing.binding.ThingHandlerService;
64 import org.openhab.core.types.Command;
65 import org.openhab.core.types.RefreshType;
66 import org.openhab.core.types.UnDefType;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
70 import com.google.gson.Gson;
73 * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
74 * sent to one of the channels.
76 * @author Jacob Laursen - Initial contribution
79 public class EnergiDataServiceHandler extends BaseThingHandler {
81 private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
82 private final TimeZoneProvider timeZoneProvider;
83 private final ApiController apiController;
84 private final CacheManager cacheManager;
85 private final Gson gson = new Gson();
87 private EnergiDataServiceConfiguration config;
88 private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
89 private @Nullable ScheduledFuture<?> refreshFuture;
90 private @Nullable ScheduledFuture<?> priceUpdateFuture;
92 private record Price(String hourStart, BigDecimal spotPrice, String spotPriceCurrency,
93 @Nullable BigDecimal netTariff, @Nullable BigDecimal systemTariff, @Nullable BigDecimal electricityTax,
94 @Nullable BigDecimal transmissionNetTariff) {
97 public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
99 this.timeZoneProvider = timeZoneProvider;
100 this.apiController = new ApiController(httpClient, timeZoneProvider);
101 this.cacheManager = new CacheManager();
103 // Default configuration
104 this.config = new EnergiDataServiceConfiguration();
108 public void handleCommand(ChannelUID channelUID, Command command) {
109 if (!(command instanceof RefreshType)) {
113 if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
114 refreshElectricityPrices();
119 public void initialize() {
120 config = getConfigAs(EnergiDataServiceConfiguration.class);
122 if (config.priceArea.isBlank()) {
123 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
124 "@text/offline.conf-error.no-price-area");
127 GlobalLocationNumber gln = config.getGridCompanyGLN();
128 if (!gln.isEmpty() && !gln.isValid()) {
129 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
130 "@text/offline.conf-error.invalid-grid-company-gln");
133 gln = config.getEnerginetGLN();
134 if (!gln.isEmpty() && !gln.isValid()) {
135 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
136 "@text/offline.conf-error.invalid-energinet-gln");
140 updateStatus(ThingStatus.UNKNOWN);
142 refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
146 public void dispose() {
147 ScheduledFuture<?> refreshFuture = this.refreshFuture;
148 if (refreshFuture != null) {
149 refreshFuture.cancel(true);
150 this.refreshFuture = null;
152 ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
153 if (priceUpdateFuture != null) {
154 priceUpdateFuture.cancel(true);
155 this.priceUpdateFuture = null;
158 cacheManager.clear();
162 public Collection<Class<? extends ThingHandlerService>> getServices() {
163 return Set.of(EnergiDataServiceActions.class);
166 private void refreshElectricityPrices() {
167 RetryStrategy retryPolicy;
169 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
170 downloadSpotPrices();
173 if (isLinked(CHANNEL_NET_TARIFF) || isLinked(CHANNEL_HOURLY_PRICES)) {
174 downloadNetTariffs();
177 if (isLinked(CHANNEL_SYSTEM_TARIFF) || isLinked(CHANNEL_HOURLY_PRICES)) {
178 downloadSystemTariffs();
181 if (isLinked(CHANNEL_ELECTRICITY_TAX) || isLinked(CHANNEL_HOURLY_PRICES)) {
182 downloadElectricityTaxes();
185 if (isLinked(CHANNEL_TRANSMISSION_NET_TARIFF) || isLinked(CHANNEL_HOURLY_PRICES)) {
186 downloadTransmissionNetTariffs();
189 updateStatus(ThingStatus.ONLINE);
192 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
193 if (cacheManager.getNumberOfFutureSpotPrices() < 13) {
194 retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing(DAILY_REFRESH_TIME_CET,
197 retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
200 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
202 } catch (DataServiceException e) {
203 if (e.getHttpStatus() != 0) {
204 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
205 HttpStatus.getCode(e.getHttpStatus()).getMessage());
207 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
209 if (e.getCause() != null) {
210 logger.debug("Error retrieving prices", e);
212 retryPolicy = RetryPolicyFactory.fromThrowable(e);
213 } catch (InterruptedException e) {
214 logger.debug("Refresh job interrupted");
215 Thread.currentThread().interrupt();
219 rescheduleRefreshJob(retryPolicy);
222 private void downloadSpotPrices() throws InterruptedException, DataServiceException {
223 if (cacheManager.areSpotPricesFullyCached()) {
224 logger.debug("Cached spot prices still valid, skipping download.");
227 DateQueryParameter start;
228 if (cacheManager.areHistoricSpotPricesCached()) {
229 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
231 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
232 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
234 Map<String, String> properties = editProperties();
235 ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
237 cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
238 updateProperties(properties);
241 private void downloadNetTariffs() throws InterruptedException, DataServiceException {
242 if (config.getGridCompanyGLN().isEmpty()) {
245 if (cacheManager.areNetTariffsValidTomorrow()) {
246 logger.debug("Cached net tariffs still valid, skipping download.");
247 cacheManager.updateNetTariffs();
249 DatahubTariffFilter filter = getNetTariffFilter();
250 cacheManager.putNetTariffs(downloadPriceLists(config.getGridCompanyGLN(),
251 new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
252 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)))));
256 private void downloadSystemTariffs() throws InterruptedException, DataServiceException {
257 GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
258 if (globalLocationNumber.isEmpty()) {
261 if (cacheManager.areSystemTariffsValidTomorrow()) {
262 logger.debug("Cached system tariffs still valid, skipping download.");
263 cacheManager.updateSystemTariffs();
265 cacheManager.putSystemTariffs(
266 downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getSystemTariff()));
270 private void downloadElectricityTaxes() throws InterruptedException, DataServiceException {
271 GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
272 if (globalLocationNumber.isEmpty()) {
275 if (cacheManager.areElectricityTaxesValidTomorrow()) {
276 logger.debug("Cached electricity taxes still valid, skipping download.");
277 cacheManager.updateElectricityTaxes();
279 cacheManager.putElectricityTaxes(
280 downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getElectricityTax()));
284 private void downloadTransmissionNetTariffs() throws InterruptedException, DataServiceException {
285 GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
286 if (globalLocationNumber.isEmpty()) {
289 if (cacheManager.areTransmissionNetTariffsValidTomorrow()) {
290 logger.debug("Cached transmission net tariffs still valid, skipping download.");
291 cacheManager.updateTransmissionNetTariffs();
293 cacheManager.putTransmissionNetTariffs(
294 downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getTransmissionNetTariff()));
298 private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
299 DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
300 Map<String, String> properties = editProperties();
301 Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
302 ChargeType.Tariff, filter, properties);
303 updateProperties(properties);
308 private DatahubTariffFilter getNetTariffFilter() {
309 Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
310 if (channel == null) {
311 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
314 DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
315 .as(DatahubPriceConfiguration.class);
317 if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
318 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
321 DateQueryParameter start = datahubPriceConfiguration.getStart();
323 logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
324 datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
325 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
328 Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
329 Set<String> notes = datahubPriceConfiguration.getNotes();
330 if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
331 // Completely override filter.
332 return new DatahubTariffFilter(chargeTypeCodes, notes, start);
334 // Only override start date in pre-configured filter.
335 return new DatahubTariffFilter(DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN), start);
339 private void updatePrices() {
340 cacheManager.cleanup();
342 updateCurrentSpotPrice();
343 updateCurrentTariff(CHANNEL_NET_TARIFF, cacheManager.getNetTariff());
344 updateCurrentTariff(CHANNEL_SYSTEM_TARIFF, cacheManager.getSystemTariff());
345 updateCurrentTariff(CHANNEL_ELECTRICITY_TAX, cacheManager.getElectricityTax());
346 updateCurrentTariff(CHANNEL_TRANSMISSION_NET_TARIFF, cacheManager.getTransmissionNetTariff());
347 updateHourlyPrices();
349 reschedulePriceUpdateJob();
352 private void updateCurrentSpotPrice() {
353 if (!isLinked(CHANNEL_SPOT_PRICE)) {
356 BigDecimal spotPrice = cacheManager.getSpotPrice();
357 updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
360 private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
361 if (!isLinked(channelId)) {
364 updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
367 private void updateHourlyPrices() {
368 if (!isLinked(CHANNEL_HOURLY_PRICES)) {
371 Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
372 Price[] targetPrices = new Price[spotPriceMap.size()];
373 List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
374 .sorted(Map.Entry.comparingByKey()).toList();
377 for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
378 Instant hourStart = sourcePrice.getKey();
379 BigDecimal netTariff = cacheManager.getNetTariff(hourStart);
380 BigDecimal systemTariff = cacheManager.getSystemTariff(hourStart);
381 BigDecimal electricityTax = cacheManager.getElectricityTax(hourStart);
382 BigDecimal transmissionNetTariff = cacheManager.getTransmissionNetTariff(hourStart);
383 targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, netTariff,
384 systemTariff, electricityTax, transmissionNetTariff);
386 updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
390 * Get the configured {@link Currency} for spot prices.
392 * @return Spot price currency
394 public Currency getCurrency() {
395 return config.getCurrency();
399 * Get cached spot prices or try once to download them if not cached
400 * (usually if no items are linked).
402 * @return Map of future spot prices
404 public Map<Instant, BigDecimal> getSpotPrices() {
406 downloadSpotPrices();
407 } catch (DataServiceException e) {
408 if (logger.isDebugEnabled()) {
409 logger.warn("Error retrieving spot prices", e);
411 logger.warn("Error retrieving spot prices: {}", e.getMessage());
413 } catch (InterruptedException e) {
414 Thread.currentThread().interrupt();
417 return cacheManager.getSpotPrices();
421 * Get cached net tariffs or try once to download them if not cached
422 * (usually if no items are linked).
424 * @return Map of future net tariffs
426 public Map<Instant, BigDecimal> getNetTariffs() {
428 downloadNetTariffs();
429 } catch (DataServiceException e) {
430 if (logger.isDebugEnabled()) {
431 logger.warn("Error retrieving net tariffs", e);
433 logger.warn("Error retrieving net tariffs: {}", e.getMessage());
435 } catch (InterruptedException e) {
436 Thread.currentThread().interrupt();
439 return cacheManager.getNetTariffs();
443 * Get cached system tariffs or try once to download them if not cached
444 * (usually if no items are linked).
446 * @return Map of future system tariffs
448 public Map<Instant, BigDecimal> getSystemTariffs() {
450 downloadSystemTariffs();
451 } catch (DataServiceException e) {
452 if (logger.isDebugEnabled()) {
453 logger.warn("Error retrieving system tariffs", e);
455 logger.warn("Error retrieving system tariffs: {}", e.getMessage());
457 } catch (InterruptedException e) {
458 Thread.currentThread().interrupt();
461 return cacheManager.getSystemTariffs();
465 * Get cached electricity taxes or try once to download them if not cached
466 * (usually if no items are linked).
468 * @return Map of future electricity taxes
470 public Map<Instant, BigDecimal> getElectricityTaxes() {
472 downloadElectricityTaxes();
473 } catch (DataServiceException e) {
474 if (logger.isDebugEnabled()) {
475 logger.warn("Error retrieving electricity taxes", e);
477 logger.warn("Error retrieving electricity taxes: {}", e.getMessage());
479 } catch (InterruptedException e) {
480 Thread.currentThread().interrupt();
483 return cacheManager.getElectricityTaxes();
487 * Return cached transmission net tariffs or try once to download them if not cached
488 * (usually if no items are linked).
490 * @return Map of future transmissions net tariffs
492 public Map<Instant, BigDecimal> getTransmissionNetTariffs() {
494 downloadTransmissionNetTariffs();
495 } catch (DataServiceException e) {
496 if (logger.isDebugEnabled()) {
497 logger.warn("Error retrieving transmission net tariffs", e);
499 logger.warn("Error retrieving transmission net tariffs: {}", e.getMessage());
501 } catch (InterruptedException e) {
502 Thread.currentThread().interrupt();
505 return cacheManager.getTransmissionNetTariffs();
508 private void reschedulePriceUpdateJob() {
509 ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
510 if (priceUpdateJob != null) {
511 // Do not interrupt ourselves.
512 priceUpdateJob.cancel(false);
513 this.priceUpdateFuture = null;
516 Instant now = Instant.now();
517 long millisUntilNextClockHour = Duration
518 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
519 this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
520 TimeUnit.MILLISECONDS);
521 logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
524 private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
525 // Preserve state of previous retry policy when configuration is the same.
526 if (!retryPolicy.equals(this.retryPolicy)) {
527 this.retryPolicy = retryPolicy;
530 ScheduledFuture<?> refreshJob = this.refreshFuture;
532 long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
533 Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
534 this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
536 logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
537 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
538 updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
539 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
541 if (refreshJob != null) {
542 refreshJob.cancel(true);