2 * Copyright (c) 2010-2024 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 javax.measure.Unit;
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.eclipse.jetty.client.HttpClient;
41 import org.eclipse.jetty.http.HttpStatus;
42 import org.openhab.binding.energidataservice.internal.ApiController;
43 import org.openhab.binding.energidataservice.internal.CacheManager;
44 import org.openhab.binding.energidataservice.internal.DatahubTariff;
45 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
46 import org.openhab.binding.energidataservice.internal.api.ChargeType;
47 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
48 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
49 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
50 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
51 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
52 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
53 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
54 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
55 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
56 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
57 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
58 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
59 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
60 import org.openhab.core.i18n.TimeZoneProvider;
61 import org.openhab.core.library.types.DecimalType;
62 import org.openhab.core.library.types.QuantityType;
63 import org.openhab.core.library.unit.CurrencyUnits;
64 import org.openhab.core.thing.Channel;
65 import org.openhab.core.thing.ChannelUID;
66 import org.openhab.core.thing.Thing;
67 import org.openhab.core.thing.ThingStatus;
68 import org.openhab.core.thing.ThingStatusDetail;
69 import org.openhab.core.thing.binding.BaseThingHandler;
70 import org.openhab.core.thing.binding.ThingHandlerService;
71 import org.openhab.core.types.Command;
72 import org.openhab.core.types.RefreshType;
73 import org.openhab.core.types.State;
74 import org.openhab.core.types.TimeSeries;
75 import org.openhab.core.types.UnDefType;
76 import org.slf4j.Logger;
77 import org.slf4j.LoggerFactory;
80 * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
81 * sent to one of the channels.
83 * @author Jacob Laursen - Initial contribution
86 public class EnergiDataServiceHandler extends BaseThingHandler {
88 private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
89 private final TimeZoneProvider timeZoneProvider;
90 private final ApiController apiController;
91 private final CacheManager cacheManager;
93 private EnergiDataServiceConfiguration config;
94 private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
95 private @Nullable ScheduledFuture<?> refreshFuture;
96 private @Nullable ScheduledFuture<?> priceUpdateFuture;
98 public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
100 this.timeZoneProvider = timeZoneProvider;
101 this.apiController = new ApiController(httpClient, timeZoneProvider);
102 this.cacheManager = new CacheManager();
104 // Default configuration
105 this.config = new EnergiDataServiceConfiguration();
109 public void handleCommand(ChannelUID channelUID, Command command) {
110 if (!(command instanceof RefreshType)) {
114 if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
115 refreshElectricityPrices();
120 public void initialize() {
121 config = getConfigAs(EnergiDataServiceConfiguration.class);
123 if (config.priceArea.isBlank()) {
124 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
125 "@text/offline.conf-error.no-price-area");
128 GlobalLocationNumber gln = config.getGridCompanyGLN();
129 if (!gln.isEmpty() && !gln.isValid()) {
130 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
131 "@text/offline.conf-error.invalid-grid-company-gln");
134 gln = config.getEnerginetGLN();
135 if (!gln.isEmpty() && !gln.isValid()) {
136 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
137 "@text/offline.conf-error.invalid-energinet-gln");
141 updateStatus(ThingStatus.UNKNOWN);
143 refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
147 public void dispose() {
148 ScheduledFuture<?> refreshFuture = this.refreshFuture;
149 if (refreshFuture != null) {
150 refreshFuture.cancel(true);
151 this.refreshFuture = null;
153 ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
154 if (priceUpdateFuture != null) {
155 priceUpdateFuture.cancel(true);
156 this.priceUpdateFuture = null;
159 cacheManager.clear();
163 public Collection<Class<? extends ThingHandlerService>> getServices() {
164 return Set.of(EnergiDataServiceActions.class);
167 private void refreshElectricityPrices() {
168 RetryStrategy retryPolicy;
170 if (isLinked(CHANNEL_SPOT_PRICE)) {
171 downloadSpotPrices();
174 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
175 if (isLinked(datahubTariff.getChannelId())) {
176 downloadTariffs(datahubTariff);
180 updateStatus(ThingStatus.ONLINE);
184 if (isLinked(CHANNEL_SPOT_PRICE)) {
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 GRID_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 GRID_TARIFF -> getGridTariffFilter();
247 case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
248 case TRANSMISSION_GRID_TARIFF -> DatahubTariffFilterFactory.getTransmissionGridTariff();
249 case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
250 case REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
252 cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
256 private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
257 DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
258 Map<String, String> properties = editProperties();
259 Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
260 ChargeType.Tariff, filter, properties);
261 updateProperties(properties);
266 private DatahubTariffFilter getGridTariffFilter() {
267 Channel channel = getThing().getChannel(CHANNEL_GRID_TARIFF);
268 if (channel == null) {
269 return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
272 DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
273 .as(DatahubPriceConfiguration.class);
275 if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
276 return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
279 DateQueryParameter start = datahubPriceConfiguration.getStart();
281 logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
282 datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
283 return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
286 Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
287 Set<String> notes = datahubPriceConfiguration.getNotes();
288 DatahubTariffFilter filter;
289 if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
290 // Completely override filter.
291 filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
293 // Only override start date in pre-configured filter.
294 filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN),
298 return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
299 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
302 private void updatePrices() {
303 cacheManager.cleanup();
305 updateCurrentSpotPrice();
306 Arrays.stream(DatahubTariff.values())
307 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
309 reschedulePriceUpdateJob();
312 private void updateCurrentSpotPrice() {
313 if (!isLinked(CHANNEL_SPOT_PRICE)) {
316 BigDecimal spotPrice = cacheManager.getSpotPrice();
317 updatePriceState(CHANNEL_SPOT_PRICE, spotPrice, config.getCurrency());
320 private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
321 if (!isLinked(channelId)) {
324 updatePriceState(channelId, tariff, CURRENCY_DKK);
327 private void updatePriceState(String channelID, @Nullable BigDecimal price, Currency currency) {
328 updateState(channelID, price != null ? getEnergyPrice(price, currency) : UnDefType.UNDEF);
331 private State getEnergyPrice(BigDecimal price, Currency currency) {
332 String currencyCode = currency.getCurrencyCode();
333 Unit<?> unit = CurrencyUnits.getInstance().getUnit(currencyCode);
335 logger.trace("Currency {} is unknown, falling back to DecimalType", currency.getCurrencyCode());
336 return new DecimalType(price);
339 return new QuantityType<>(price + " " + currencyCode + "/kWh");
340 } catch (IllegalArgumentException e) {
341 logger.debug("Unable to create QuantityType, falling back to DecimalType", e);
342 return new DecimalType(price);
346 private void updateTimeSeries() {
347 TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
348 Map<DatahubTariff, TimeSeries> datahubTimeSeriesMap = new HashMap<>();
349 Map<DatahubTariff, BigDecimal> datahubPreviousTariff = new HashMap<>();
350 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
351 datahubTimeSeriesMap.put(datahubTariff, new TimeSeries(REPLACE));
354 Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
355 List<Entry<Instant, BigDecimal>> spotPrices = spotPriceMap.entrySet().stream()
356 .sorted(Map.Entry.comparingByKey()).toList();
357 for (Entry<Instant, BigDecimal> spotPrice : spotPrices) {
358 Instant hourStart = spotPrice.getKey();
359 if (isLinked(CHANNEL_SPOT_PRICE)) {
360 spotPriceTimeSeries.add(hourStart, getEnergyPrice(spotPrice.getValue(), config.getCurrency()));
362 for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
363 DatahubTariff datahubTariff = entry.getKey();
364 String channelId = datahubTariff.getChannelId();
365 if (!isLinked(channelId)) {
368 BigDecimal tariff = cacheManager.getTariff(datahubTariff, hourStart);
369 if (tariff != null) {
370 BigDecimal previousTariff = datahubPreviousTariff.get(datahubTariff);
371 if (previousTariff != null && tariff.equals(previousTariff)) {
372 // Skip redundant states.
375 TimeSeries timeSeries = entry.getValue();
376 timeSeries.add(hourStart, getEnergyPrice(tariff, CURRENCY_DKK));
377 datahubPreviousTariff.put(datahubTariff, tariff);
381 if (spotPriceTimeSeries.size() > 0) {
382 sendTimeSeries(CHANNEL_SPOT_PRICE, spotPriceTimeSeries);
384 for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
385 DatahubTariff datahubTariff = entry.getKey();
386 String channelId = datahubTariff.getChannelId();
387 if (!isLinked(channelId)) {
390 TimeSeries timeSeries = entry.getValue();
391 if (timeSeries.size() > 0) {
392 sendTimeSeries(channelId, timeSeries);
398 * Get the configured {@link Currency} for spot prices.
400 * @return Spot price currency
402 public Currency getCurrency() {
403 return config.getCurrency();
407 * Get cached spot prices or try once to download them if not cached
408 * (usually if no items are linked).
410 * @return Map of future spot prices
412 public Map<Instant, BigDecimal> getSpotPrices() {
414 downloadSpotPrices();
415 } catch (DataServiceException e) {
416 if (logger.isDebugEnabled()) {
417 logger.warn("Error retrieving spot prices", e);
419 logger.warn("Error retrieving spot prices: {}", e.getMessage());
421 } catch (InterruptedException e) {
422 Thread.currentThread().interrupt();
425 return cacheManager.getSpotPrices();
429 * Return cached tariffs or try once to download them if not cached
430 * (usually if no items are linked).
432 * @return Map of future tariffs
434 public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
436 downloadTariffs(datahubTariff);
437 } catch (DataServiceException e) {
438 if (logger.isDebugEnabled()) {
439 logger.warn("Error retrieving tariffs", e);
441 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
443 } catch (InterruptedException e) {
444 Thread.currentThread().interrupt();
447 return cacheManager.getTariffs(datahubTariff);
451 * Return whether reduced electricity tax is set in configuration.
453 * @return true if reduced electricity tax applies
455 public boolean isReducedElectricityTax() {
456 return config.reducedElectricityTax;
459 private void reschedulePriceUpdateJob() {
460 ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
461 if (priceUpdateJob != null) {
462 // Do not interrupt ourselves.
463 priceUpdateJob.cancel(false);
464 this.priceUpdateFuture = null;
467 Instant now = Instant.now();
468 long millisUntilNextClockHour = Duration
469 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
470 this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
471 TimeUnit.MILLISECONDS);
472 logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
475 private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
476 // Preserve state of previous retry policy when configuration is the same.
477 if (!retryPolicy.equals(this.retryPolicy)) {
478 this.retryPolicy = retryPolicy;
481 ScheduledFuture<?> refreshJob = this.refreshFuture;
483 long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
484 Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
485 this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
487 logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
488 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
489 updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
490 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
492 if (refreshJob != null) {
493 refreshJob.cancel(true);