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 cacheManager.putNetTariffs(downloadPriceLists(config.getGridCompanyGLN(), getNetTariffFilter()));
253 private void downloadSystemTariffs() throws InterruptedException, DataServiceException {
254 GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
255 if (globalLocationNumber.isEmpty()) {
258 if (cacheManager.areSystemTariffsValidTomorrow()) {
259 logger.debug("Cached system tariffs still valid, skipping download.");
260 cacheManager.updateSystemTariffs();
262 cacheManager.putSystemTariffs(
263 downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getSystemTariff()));
267 private void downloadElectricityTaxes() throws InterruptedException, DataServiceException {
268 GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
269 if (globalLocationNumber.isEmpty()) {
272 if (cacheManager.areElectricityTaxesValidTomorrow()) {
273 logger.debug("Cached electricity taxes still valid, skipping download.");
274 cacheManager.updateElectricityTaxes();
276 cacheManager.putElectricityTaxes(
277 downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getElectricityTax()));
281 private void downloadTransmissionNetTariffs() throws InterruptedException, DataServiceException {
282 GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
283 if (globalLocationNumber.isEmpty()) {
286 if (cacheManager.areTransmissionNetTariffsValidTomorrow()) {
287 logger.debug("Cached transmission net tariffs still valid, skipping download.");
288 cacheManager.updateTransmissionNetTariffs();
290 cacheManager.putTransmissionNetTariffs(
291 downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getTransmissionNetTariff()));
295 private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
296 DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
297 Map<String, String> properties = editProperties();
298 Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
299 ChargeType.Tariff, filter, properties);
300 updateProperties(properties);
305 private DatahubTariffFilter getNetTariffFilter() {
306 Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
307 if (channel == null) {
308 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
311 DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
312 .as(DatahubPriceConfiguration.class);
314 if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
315 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
318 DateQueryParameter start = datahubPriceConfiguration.getStart();
320 logger.warn("Invalid channel configuration parameter 'start': {}", datahubPriceConfiguration.start);
321 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
324 Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
325 Set<String> notes = datahubPriceConfiguration.getNotes();
326 if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
327 // Completely override filter.
328 return new DatahubTariffFilter(chargeTypeCodes, notes, start);
330 // Only override start date in pre-configured filter.
331 return new DatahubTariffFilter(DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN), start);
335 private void updatePrices() {
336 cacheManager.cleanup();
338 updateCurrentSpotPrice();
339 updateCurrentTariff(CHANNEL_NET_TARIFF, cacheManager.getNetTariff());
340 updateCurrentTariff(CHANNEL_SYSTEM_TARIFF, cacheManager.getSystemTariff());
341 updateCurrentTariff(CHANNEL_ELECTRICITY_TAX, cacheManager.getElectricityTax());
342 updateCurrentTariff(CHANNEL_TRANSMISSION_NET_TARIFF, cacheManager.getTransmissionNetTariff());
343 updateHourlyPrices();
345 reschedulePriceUpdateJob();
348 private void updateCurrentSpotPrice() {
349 if (!isLinked(CHANNEL_SPOT_PRICE)) {
352 BigDecimal spotPrice = cacheManager.getSpotPrice();
353 updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
356 private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
357 if (!isLinked(channelId)) {
360 updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
363 private void updateHourlyPrices() {
364 if (!isLinked(CHANNEL_HOURLY_PRICES)) {
367 Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
368 Price[] targetPrices = new Price[spotPriceMap.size()];
369 List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
370 .sorted(Map.Entry.comparingByKey()).toList();
373 for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
374 Instant hourStart = sourcePrice.getKey();
375 BigDecimal netTariff = cacheManager.getNetTariff(hourStart);
376 BigDecimal systemTariff = cacheManager.getSystemTariff(hourStart);
377 BigDecimal electricityTax = cacheManager.getElectricityTax(hourStart);
378 BigDecimal transmissionNetTariff = cacheManager.getTransmissionNetTariff(hourStart);
379 targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, netTariff,
380 systemTariff, electricityTax, transmissionNetTariff);
382 updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
386 * Get the configured {@link Currency} for spot prices.
388 * @return Spot price currency
390 public Currency getCurrency() {
391 return config.getCurrency();
395 * Get cached spot prices or try once to download them if not cached
396 * (usually if no items are linked).
398 * @return Map of future spot prices
400 public Map<Instant, BigDecimal> getSpotPrices() {
402 downloadSpotPrices();
403 } catch (DataServiceException e) {
404 if (logger.isDebugEnabled()) {
405 logger.warn("Error retrieving spot prices", e);
407 logger.warn("Error retrieving spot prices: {}", e.getMessage());
409 } catch (InterruptedException e) {
410 Thread.currentThread().interrupt();
413 return cacheManager.getSpotPrices();
417 * Get cached net tariffs or try once to download them if not cached
418 * (usually if no items are linked).
420 * @return Map of future net tariffs
422 public Map<Instant, BigDecimal> getNetTariffs() {
424 downloadNetTariffs();
425 } catch (DataServiceException e) {
426 if (logger.isDebugEnabled()) {
427 logger.warn("Error retrieving net tariffs", e);
429 logger.warn("Error retrieving net tariffs: {}", e.getMessage());
431 } catch (InterruptedException e) {
432 Thread.currentThread().interrupt();
435 return cacheManager.getNetTariffs();
439 * Get cached system tariffs or try once to download them if not cached
440 * (usually if no items are linked).
442 * @return Map of future system tariffs
444 public Map<Instant, BigDecimal> getSystemTariffs() {
446 downloadSystemTariffs();
447 } catch (DataServiceException e) {
448 if (logger.isDebugEnabled()) {
449 logger.warn("Error retrieving system tariffs", e);
451 logger.warn("Error retrieving system tariffs: {}", e.getMessage());
453 } catch (InterruptedException e) {
454 Thread.currentThread().interrupt();
457 return cacheManager.getSystemTariffs();
461 * Get cached electricity taxes or try once to download them if not cached
462 * (usually if no items are linked).
464 * @return Map of future electricity taxes
466 public Map<Instant, BigDecimal> getElectricityTaxes() {
468 downloadElectricityTaxes();
469 } catch (DataServiceException e) {
470 if (logger.isDebugEnabled()) {
471 logger.warn("Error retrieving electricity taxes", e);
473 logger.warn("Error retrieving electricity taxes: {}", e.getMessage());
475 } catch (InterruptedException e) {
476 Thread.currentThread().interrupt();
479 return cacheManager.getElectricityTaxes();
483 * Return cached transmission net tariffs or try once to download them if not cached
484 * (usually if no items are linked).
486 * @return Map of future transmissions net tariffs
488 public Map<Instant, BigDecimal> getTransmissionNetTariffs() {
490 downloadTransmissionNetTariffs();
491 } catch (DataServiceException e) {
492 if (logger.isDebugEnabled()) {
493 logger.warn("Error retrieving transmission net tariffs", e);
495 logger.warn("Error retrieving transmission net tariffs: {}", e.getMessage());
497 } catch (InterruptedException e) {
498 Thread.currentThread().interrupt();
501 return cacheManager.getTransmissionNetTariffs();
504 private void reschedulePriceUpdateJob() {
505 ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
506 if (priceUpdateJob != null) {
507 // Do not interrupt ourselves.
508 priceUpdateJob.cancel(false);
509 this.priceUpdateFuture = null;
512 Instant now = Instant.now();
513 long millisUntilNextClockHour = Duration
514 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
515 this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
516 TimeUnit.MILLISECONDS);
517 logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
520 private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
521 // Preserve state of previous retry policy when configuration is the same.
522 if (!retryPolicy.equals(this.retryPolicy)) {
523 this.retryPolicy = retryPolicy;
526 ScheduledFuture<?> refreshJob = this.refreshFuture;
528 long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
529 Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
530 this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
532 logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
533 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
534 updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
535 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
537 if (refreshJob != null) {
538 refreshJob.cancel(true);