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.*;
16 import static org.openhab.core.types.TimeSeries.Policy.REPLACE;
18 import java.math.BigDecimal;
19 import java.time.Duration;
20 import java.time.Instant;
21 import java.time.LocalDateTime;
22 import java.time.LocalTime;
23 import java.time.format.DateTimeFormatter;
24 import java.time.temporal.ChronoUnit;
25 import java.util.Arrays;
26 import java.util.Collection;
27 import java.util.Currency;
28 import java.util.HashMap;
29 import java.util.List;
31 import java.util.Map.Entry;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.http.HttpStatus;
40 import org.openhab.binding.energidataservice.internal.ApiController;
41 import org.openhab.binding.energidataservice.internal.CacheManager;
42 import org.openhab.binding.energidataservice.internal.DatahubTariff;
43 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
44 import org.openhab.binding.energidataservice.internal.api.ChargeType;
45 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
46 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
47 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
48 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
49 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
50 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
51 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
52 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
53 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
54 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
55 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
56 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
57 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
58 import org.openhab.core.i18n.TimeZoneProvider;
59 import org.openhab.core.library.types.DecimalType;
60 import org.openhab.core.library.types.StringType;
61 import org.openhab.core.thing.Channel;
62 import org.openhab.core.thing.ChannelUID;
63 import org.openhab.core.thing.Thing;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.binding.BaseThingHandler;
67 import org.openhab.core.thing.binding.ThingHandlerService;
68 import org.openhab.core.types.Command;
69 import org.openhab.core.types.RefreshType;
70 import org.openhab.core.types.TimeSeries;
71 import org.openhab.core.types.UnDefType;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
75 import com.google.gson.Gson;
78 * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
79 * sent to one of the channels.
81 * @author Jacob Laursen - Initial contribution
84 public class EnergiDataServiceHandler extends BaseThingHandler {
86 private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
87 private final TimeZoneProvider timeZoneProvider;
88 private final ApiController apiController;
89 private final CacheManager cacheManager;
90 private final Gson gson = new Gson();
92 private EnergiDataServiceConfiguration config;
93 private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
94 private @Nullable ScheduledFuture<?> refreshFuture;
95 private @Nullable ScheduledFuture<?> priceUpdateFuture;
97 private record Price(String hourStart, BigDecimal spotPrice, String spotPriceCurrency,
98 @Nullable BigDecimal netTariff, @Nullable BigDecimal systemTariff, @Nullable BigDecimal electricityTax,
99 @Nullable BigDecimal reducedElectricityTax, @Nullable BigDecimal transmissionNetTariff) {
102 public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
104 this.timeZoneProvider = timeZoneProvider;
105 this.apiController = new ApiController(httpClient, timeZoneProvider);
106 this.cacheManager = new CacheManager();
108 // Default configuration
109 this.config = new EnergiDataServiceConfiguration();
113 public void handleCommand(ChannelUID channelUID, Command command) {
114 if (!(command instanceof RefreshType)) {
118 if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
119 refreshElectricityPrices();
124 public void initialize() {
125 config = getConfigAs(EnergiDataServiceConfiguration.class);
127 if (config.priceArea.isBlank()) {
128 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
129 "@text/offline.conf-error.no-price-area");
132 GlobalLocationNumber gln = config.getGridCompanyGLN();
133 if (!gln.isEmpty() && !gln.isValid()) {
134 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
135 "@text/offline.conf-error.invalid-grid-company-gln");
138 gln = config.getEnerginetGLN();
139 if (!gln.isEmpty() && !gln.isValid()) {
140 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
141 "@text/offline.conf-error.invalid-energinet-gln");
145 updateStatus(ThingStatus.UNKNOWN);
147 refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
151 public void dispose() {
152 ScheduledFuture<?> refreshFuture = this.refreshFuture;
153 if (refreshFuture != null) {
154 refreshFuture.cancel(true);
155 this.refreshFuture = null;
157 ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
158 if (priceUpdateFuture != null) {
159 priceUpdateFuture.cancel(true);
160 this.priceUpdateFuture = null;
163 cacheManager.clear();
167 public Collection<Class<? extends ThingHandlerService>> getServices() {
168 return Set.of(EnergiDataServiceActions.class);
171 private void refreshElectricityPrices() {
172 RetryStrategy retryPolicy;
174 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
175 downloadSpotPrices();
178 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
179 if (isLinked(datahubTariff.getChannelId()) || isLinked(CHANNEL_HOURLY_PRICES)) {
180 downloadTariffs(datahubTariff);
184 updateStatus(ThingStatus.ONLINE);
188 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
189 if (cacheManager.getNumberOfFutureSpotPrices() < 13) {
190 retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing(DAILY_REFRESH_TIME_CET,
193 retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
196 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
198 } catch (DataServiceException e) {
199 if (e.getHttpStatus() != 0) {
200 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
201 HttpStatus.getCode(e.getHttpStatus()).getMessage());
203 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
205 if (e.getCause() != null) {
206 logger.debug("Error retrieving prices", e);
208 retryPolicy = RetryPolicyFactory.fromThrowable(e);
209 } catch (InterruptedException e) {
210 logger.debug("Refresh job interrupted");
211 Thread.currentThread().interrupt();
215 rescheduleRefreshJob(retryPolicy);
218 private void downloadSpotPrices() throws InterruptedException, DataServiceException {
219 if (cacheManager.areSpotPricesFullyCached()) {
220 logger.debug("Cached spot prices still valid, skipping download.");
223 DateQueryParameter start;
224 if (cacheManager.areHistoricSpotPricesCached()) {
225 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
227 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
228 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
230 Map<String, String> properties = editProperties();
231 ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
233 cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
234 updateProperties(properties);
237 private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
238 GlobalLocationNumber globalLocationNumber = switch (datahubTariff) {
239 case NET_TARIFF -> config.getGridCompanyGLN();
240 default -> config.getEnerginetGLN();
242 if (globalLocationNumber.isEmpty()) {
245 if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
246 logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
247 cacheManager.updateTariffs(datahubTariff);
249 DatahubTariffFilter filter = switch (datahubTariff) {
250 case NET_TARIFF -> getNetTariffFilter();
251 case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
252 case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
253 case REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
254 case TRANSMISSION_NET_TARIFF -> DatahubTariffFilterFactory.getTransmissionNetTariff();
256 cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
260 private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
261 DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
262 Map<String, String> properties = editProperties();
263 Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
264 ChargeType.Tariff, filter, properties);
265 updateProperties(properties);
270 private DatahubTariffFilter getNetTariffFilter() {
271 Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
272 if (channel == null) {
273 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
276 DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
277 .as(DatahubPriceConfiguration.class);
279 if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
280 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
283 DateQueryParameter start = datahubPriceConfiguration.getStart();
285 logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
286 datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
287 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
290 Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
291 Set<String> notes = datahubPriceConfiguration.getNotes();
292 DatahubTariffFilter filter;
293 if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
294 // Completely override filter.
295 filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
297 // Only override start date in pre-configured filter.
298 filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN),
302 return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
303 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
306 private void updatePrices() {
307 cacheManager.cleanup();
309 updateCurrentSpotPrice();
310 Arrays.stream(DatahubTariff.values())
311 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
312 updateHourlyPrices();
314 reschedulePriceUpdateJob();
317 private void updateCurrentSpotPrice() {
318 if (!isLinked(CHANNEL_SPOT_PRICE)) {
321 BigDecimal spotPrice = cacheManager.getSpotPrice();
322 updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
325 private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
326 if (!isLinked(channelId)) {
329 updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
332 private void updateHourlyPrices() {
333 if (!isLinked(CHANNEL_HOURLY_PRICES)) {
336 Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
337 Price[] targetPrices = new Price[spotPriceMap.size()];
338 List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
339 .sorted(Map.Entry.comparingByKey()).toList();
342 for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
343 Instant hourStart = sourcePrice.getKey();
344 BigDecimal netTariff = cacheManager.getTariff(DatahubTariff.NET_TARIFF, hourStart);
345 BigDecimal systemTariff = cacheManager.getTariff(DatahubTariff.SYSTEM_TARIFF, hourStart);
346 BigDecimal electricityTax = cacheManager.getTariff(DatahubTariff.ELECTRICITY_TAX, hourStart);
347 BigDecimal reducedElectricityTax = cacheManager.getTariff(DatahubTariff.REDUCED_ELECTRICITY_TAX, hourStart);
348 BigDecimal transmissionNetTariff = cacheManager.getTariff(DatahubTariff.TRANSMISSION_NET_TARIFF, hourStart);
349 targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, netTariff,
350 systemTariff, electricityTax, reducedElectricityTax, transmissionNetTariff);
352 updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
355 private void updateTimeSeries() {
356 TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
357 Map<DatahubTariff, TimeSeries> datahubTimeSeriesMap = new HashMap<>();
358 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
359 datahubTimeSeriesMap.put(datahubTariff, new TimeSeries(REPLACE));
362 Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
363 List<Entry<Instant, BigDecimal>> spotPrices = spotPriceMap.entrySet().stream()
364 .sorted(Map.Entry.comparingByKey()).toList();
365 for (Entry<Instant, BigDecimal> spotPrice : spotPrices) {
366 Instant hourStart = spotPrice.getKey();
367 if (isLinked(CHANNEL_SPOT_PRICE)) {
368 spotPriceTimeSeries.add(hourStart, new DecimalType(spotPrice.getValue()));
370 for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
371 DatahubTariff datahubTariff = entry.getKey();
372 String channelId = datahubTariff.getChannelId();
373 if (!isLinked(channelId)) {
376 BigDecimal tariff = cacheManager.getTariff(datahubTariff, hourStart);
377 if (tariff != null) {
378 TimeSeries timeSeries = entry.getValue();
379 timeSeries.add(hourStart, new DecimalType(tariff));
383 if (spotPriceTimeSeries.size() > 0) {
384 sendTimeSeries(CHANNEL_SPOT_PRICE, spotPriceTimeSeries);
386 for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
387 DatahubTariff datahubTariff = entry.getKey();
388 String channelId = datahubTariff.getChannelId();
389 if (!isLinked(channelId)) {
392 TimeSeries timeSeries = entry.getValue();
393 if (timeSeries.size() > 0) {
394 sendTimeSeries(channelId, timeSeries);
400 * Get the configured {@link Currency} for spot prices.
402 * @return Spot price currency
404 public Currency getCurrency() {
405 return config.getCurrency();
409 * Get cached spot prices or try once to download them if not cached
410 * (usually if no items are linked).
412 * @return Map of future spot prices
414 public Map<Instant, BigDecimal> getSpotPrices() {
416 downloadSpotPrices();
417 } catch (DataServiceException e) {
418 if (logger.isDebugEnabled()) {
419 logger.warn("Error retrieving spot prices", e);
421 logger.warn("Error retrieving spot prices: {}", e.getMessage());
423 } catch (InterruptedException e) {
424 Thread.currentThread().interrupt();
427 return cacheManager.getSpotPrices();
431 * Return cached tariffs or try once to download them if not cached
432 * (usually if no items are linked).
434 * @return Map of future tariffs
436 public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
438 downloadTariffs(datahubTariff);
439 } catch (DataServiceException e) {
440 if (logger.isDebugEnabled()) {
441 logger.warn("Error retrieving tariffs", e);
443 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
445 } catch (InterruptedException e) {
446 Thread.currentThread().interrupt();
449 return cacheManager.getTariffs(datahubTariff);
453 * Return whether reduced electricity tax is set in configuration.
455 * @return true if reduced electricity tax applies
457 public boolean isReducedElectricityTax() {
458 return config.reducedElectricityTax;
461 private void reschedulePriceUpdateJob() {
462 ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
463 if (priceUpdateJob != null) {
464 // Do not interrupt ourselves.
465 priceUpdateJob.cancel(false);
466 this.priceUpdateFuture = null;
469 Instant now = Instant.now();
470 long millisUntilNextClockHour = Duration
471 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
472 this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
473 TimeUnit.MILLISECONDS);
474 logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
477 private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
478 // Preserve state of previous retry policy when configuration is the same.
479 if (!retryPolicy.equals(this.retryPolicy)) {
480 this.retryPolicy = retryPolicy;
483 ScheduledFuture<?> refreshJob = this.refreshFuture;
485 long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
486 Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
487 this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
489 logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
490 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
491 updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
492 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
494 if (refreshJob != null) {
495 refreshJob.cancel(true);