]> git.basschouten.com Git - openhab-addons.git/blob
785b655364651a5b72b9b8b3ee73f5049978cddc
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.energidataservice.internal.handler;
14
15 import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
16 import static org.openhab.core.types.TimeSeries.Policy.REPLACE;
17
18 import java.math.BigDecimal;
19 import java.time.Duration;
20 import java.time.Instant;
21 import java.time.LocalDate;
22 import java.time.LocalDateTime;
23 import java.time.LocalTime;
24 import java.time.ZoneId;
25 import java.time.format.DateTimeFormatter;
26 import java.time.temporal.ChronoUnit;
27 import java.util.Arrays;
28 import java.util.Collection;
29 import java.util.Comparator;
30 import java.util.Currency;
31 import java.util.List;
32 import java.util.Map;
33 import java.util.Map.Entry;
34 import java.util.Set;
35 import java.util.concurrent.ScheduledFuture;
36 import java.util.concurrent.TimeUnit;
37
38 import javax.measure.Unit;
39
40 import org.eclipse.jdt.annotation.NonNullByDefault;
41 import org.eclipse.jdt.annotation.Nullable;
42 import org.eclipse.jetty.client.HttpClient;
43 import org.eclipse.jetty.http.HttpStatus;
44 import org.openhab.binding.energidataservice.internal.ApiController;
45 import org.openhab.binding.energidataservice.internal.CacheManager;
46 import org.openhab.binding.energidataservice.internal.DatahubTariff;
47 import org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants;
48 import org.openhab.binding.energidataservice.internal.PriceListParser;
49 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
50 import org.openhab.binding.energidataservice.internal.api.ChargeType;
51 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
52 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
53 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
54 import org.openhab.binding.energidataservice.internal.api.Dataset;
55 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
56 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
57 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
58 import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecord;
59 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
60 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
61 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
62 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
63 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
64 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
65 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
66 import org.openhab.core.i18n.TimeZoneProvider;
67 import org.openhab.core.library.types.DecimalType;
68 import org.openhab.core.library.types.QuantityType;
69 import org.openhab.core.library.unit.CurrencyUnits;
70 import org.openhab.core.library.unit.Units;
71 import org.openhab.core.thing.Channel;
72 import org.openhab.core.thing.ChannelUID;
73 import org.openhab.core.thing.Thing;
74 import org.openhab.core.thing.ThingStatus;
75 import org.openhab.core.thing.ThingStatusDetail;
76 import org.openhab.core.thing.binding.BaseThingHandler;
77 import org.openhab.core.thing.binding.ThingHandlerService;
78 import org.openhab.core.types.Command;
79 import org.openhab.core.types.RefreshType;
80 import org.openhab.core.types.State;
81 import org.openhab.core.types.TimeSeries;
82 import org.openhab.core.types.UnDefType;
83 import org.slf4j.Logger;
84 import org.slf4j.LoggerFactory;
85
86 /**
87  * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
88  * sent to one of the channels.
89  *
90  * @author Jacob Laursen - Initial contribution
91  */
92 @NonNullByDefault
93 public class EnergiDataServiceHandler extends BaseThingHandler {
94
95     private static final Duration emissionPrognosisJobInterval = Duration.ofMinutes(15);
96     private static final Duration emissionRealtimeJobInterval = Duration.ofMinutes(5);
97
98     private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
99     private final TimeZoneProvider timeZoneProvider;
100     private final ApiController apiController;
101     private final CacheManager cacheManager;
102
103     private EnergiDataServiceConfiguration config;
104     private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
105     private boolean realtimeEmissionsFetchedFirstTime = false;
106     private @Nullable ScheduledFuture<?> refreshPriceFuture;
107     private @Nullable ScheduledFuture<?> refreshEmissionPrognosisFuture;
108     private @Nullable ScheduledFuture<?> refreshEmissionRealtimeFuture;
109     private @Nullable ScheduledFuture<?> priceUpdateFuture;
110
111     public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
112         super(thing);
113         this.timeZoneProvider = timeZoneProvider;
114         this.apiController = new ApiController(httpClient, timeZoneProvider);
115         this.cacheManager = new CacheManager();
116
117         // Default configuration
118         this.config = new EnergiDataServiceConfiguration();
119     }
120
121     @Override
122     public void handleCommand(ChannelUID channelUID, Command command) {
123         if (!(command instanceof RefreshType)) {
124             return;
125         }
126
127         String channelId = channelUID.getId();
128         if (ELECTRICITY_CHANNELS.contains(channelId)) {
129             refreshElectricityPrices();
130         } else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) {
131             rescheduleEmissionPrognosisJob();
132         } else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) {
133             realtimeEmissionsFetchedFirstTime = false;
134             rescheduleEmissionRealtimeJob();
135         }
136     }
137
138     @Override
139     public void initialize() {
140         config = getConfigAs(EnergiDataServiceConfiguration.class);
141
142         if (config.priceArea.isBlank()) {
143             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
144                     "@text/offline.conf-error.no-price-area");
145             return;
146         }
147         GlobalLocationNumber gln = config.getGridCompanyGLN();
148         if (!gln.isEmpty() && !gln.isValid()) {
149             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
150                     "@text/offline.conf-error.invalid-grid-company-gln");
151             return;
152         }
153         gln = config.getEnerginetGLN();
154         if (!gln.isEmpty() && !gln.isValid()) {
155             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
156                     "@text/offline.conf-error.invalid-energinet-gln");
157             return;
158         }
159
160         updateStatus(ThingStatus.UNKNOWN);
161
162         refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
163
164         if (isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
165             rescheduleEmissionPrognosisJob();
166         }
167         if (isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
168             rescheduleEmissionRealtimeJob();
169         }
170     }
171
172     @Override
173     public void dispose() {
174         ScheduledFuture<?> refreshPriceFuture = this.refreshPriceFuture;
175         if (refreshPriceFuture != null) {
176             refreshPriceFuture.cancel(true);
177             this.refreshPriceFuture = null;
178         }
179         ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
180         if (refreshEmissionPrognosisFuture != null) {
181             refreshEmissionPrognosisFuture.cancel(true);
182             this.refreshEmissionPrognosisFuture = null;
183         }
184         ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
185         if (refreshEmissionRealtimeFuture != null) {
186             refreshEmissionRealtimeFuture.cancel(true);
187             this.refreshEmissionRealtimeFuture = null;
188         }
189         ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
190         if (priceUpdateFuture != null) {
191             priceUpdateFuture.cancel(true);
192             this.priceUpdateFuture = null;
193         }
194
195         cacheManager.clear();
196     }
197
198     @Override
199     public Collection<Class<? extends ThingHandlerService>> getServices() {
200         return Set.of(EnergiDataServiceActions.class);
201     }
202
203     @Override
204     public void channelLinked(ChannelUID channelUID) {
205         super.channelLinked(channelUID);
206
207         if (!"DK1".equals(config.priceArea) && !"DK2".equals(config.priceArea)
208                 && (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelUID.getId())
209                         || CHANNEL_CO2_EMISSION_REALTIME.contains(channelUID.getId()))) {
210             logger.warn("Item linked to channel '{}', but price area {} is not supported for this channel",
211                     channelUID.getId(), config.priceArea);
212         }
213     }
214
215     @Override
216     public void channelUnlinked(ChannelUID channelUID) {
217         super.channelUnlinked(channelUID);
218
219         if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelUID.getId()) && !isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
220             logger.debug("No more items linked to channel '{}', stopping emission prognosis refresh job",
221                     channelUID.getId());
222             ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
223             if (refreshEmissionPrognosisFuture != null) {
224                 refreshEmissionPrognosisFuture.cancel(true);
225                 this.refreshEmissionPrognosisFuture = null;
226             }
227         } else if (CHANNEL_CO2_EMISSION_REALTIME.contains(channelUID.getId())
228                 && !isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
229             logger.debug("No more items linked to channel '{}', stopping realtime emission refresh job",
230                     channelUID.getId());
231             ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
232             if (refreshEmissionRealtimeFuture != null) {
233                 refreshEmissionRealtimeFuture.cancel(true);
234                 this.refreshEmissionRealtimeFuture = null;
235             }
236         }
237     }
238
239     private void refreshElectricityPrices() {
240         RetryStrategy retryPolicy;
241         try {
242             boolean spotPricesDownloaded = false;
243             if (isLinked(CHANNEL_SPOT_PRICE)) {
244                 spotPricesDownloaded = downloadSpotPrices();
245             }
246
247             for (DatahubTariff datahubTariff : DatahubTariff.values()) {
248                 if (isLinked(datahubTariff.getChannelId())) {
249                     downloadTariffs(datahubTariff);
250                 }
251             }
252
253             updateStatus(ThingStatus.ONLINE);
254             updatePrices();
255             updateElectricityTimeSeriesFromCache();
256
257             if (isLinked(CHANNEL_SPOT_PRICE)) {
258                 long numberOfFutureSpotPrices = cacheManager.getNumberOfFutureSpotPrices();
259                 LocalTime now = LocalTime.now(NORD_POOL_TIMEZONE);
260
261                 if (numberOfFutureSpotPrices >= 13 || (numberOfFutureSpotPrices == 12
262                         && now.isAfter(DAILY_REFRESH_TIME_CET.minusHours(1)) && now.isBefore(DAILY_REFRESH_TIME_CET))) {
263                     if (spotPricesDownloaded) {
264                         triggerChannel(CHANNEL_EVENT, EVENT_DAY_AHEAD_AVAILABLE);
265                     }
266                     retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
267                 } else {
268                     logger.warn("Spot prices are not available, retry scheduled (see details in Thing properties)");
269                     retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing();
270                 }
271             } else {
272                 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
273             }
274         } catch (DataServiceException e) {
275             if (e.getHttpStatus() != 0) {
276                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
277                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
278             } else {
279                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
280             }
281             if (e.getCause() != null) {
282                 logger.debug("Error retrieving prices", e);
283             }
284             retryPolicy = RetryPolicyFactory.fromThrowable(e);
285         } catch (InterruptedException e) {
286             logger.debug("Refresh job interrupted");
287             Thread.currentThread().interrupt();
288             return;
289         }
290
291         reschedulePriceRefreshJob(retryPolicy);
292     }
293
294     private boolean downloadSpotPrices() throws InterruptedException, DataServiceException {
295         if (cacheManager.areSpotPricesFullyCached()) {
296             logger.debug("Cached spot prices still valid, skipping download.");
297             return false;
298         }
299         DateQueryParameter start;
300         if (cacheManager.areHistoricSpotPricesCached()) {
301             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
302         } else {
303             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
304                     Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
305         }
306         Map<String, String> properties = editProperties();
307         try {
308             ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
309                     start, DateQueryParameter.EMPTY, properties);
310             cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
311         } finally {
312             updateProperties(properties);
313         }
314         return true;
315     }
316
317     private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
318         GlobalLocationNumber globalLocationNumber = getGlobalLocationNumber(datahubTariff);
319         if (globalLocationNumber.isEmpty()) {
320             return;
321         }
322         if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
323             logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
324             cacheManager.updateTariffs(datahubTariff);
325         } else {
326             DatahubTariffFilter filter = getDatahubTariffFilter(datahubTariff);
327             cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
328         }
329     }
330
331     private DatahubTariffFilter getDatahubTariffFilter(DatahubTariff datahubTariff) {
332         return switch (datahubTariff) {
333             case GRID_TARIFF -> getGridTariffFilter();
334             case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
335             case TRANSMISSION_GRID_TARIFF -> DatahubTariffFilterFactory.getTransmissionGridTariff();
336             case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
337             case REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
338         };
339     }
340
341     private GlobalLocationNumber getGlobalLocationNumber(DatahubTariff datahubTariff) {
342         return switch (datahubTariff) {
343             case GRID_TARIFF -> config.getGridCompanyGLN();
344             default -> config.getEnerginetGLN();
345         };
346     }
347
348     private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
349             DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
350         Map<String, String> properties = editProperties();
351         try {
352             return apiController.getDatahubPriceLists(globalLocationNumber, ChargeType.Tariff, filter, properties);
353         } finally {
354             updateProperties(properties);
355         }
356     }
357
358     private DatahubTariffFilter getGridTariffFilter() {
359         Channel channel = getThing().getChannel(CHANNEL_GRID_TARIFF);
360         if (channel == null) {
361             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
362         }
363
364         DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
365                 .as(DatahubPriceConfiguration.class);
366
367         if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
368             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
369         }
370
371         DateQueryParameter start = datahubPriceConfiguration.getStart();
372         if (start == null) {
373             logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
374                     datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
375             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
376         }
377
378         Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
379         Set<String> notes = datahubPriceConfiguration.getNotes();
380         DatahubTariffFilter filter;
381         if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
382             // Completely override filter.
383             filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
384         } else {
385             // Only override start date in pre-configured filter.
386             filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN),
387                     start);
388         }
389
390         return new DatahubTariffFilter(filter,
391                 DateQueryParameter.of(filter.getStart(), Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
392     }
393
394     private void refreshCo2EmissionPrognosis() {
395         try {
396             updateCo2Emissions(Dataset.CO2EmissionPrognosis, CHANNEL_CO2_EMISSION_PROGNOSIS,
397                     DateQueryParameter.of(DateQueryParameterType.UTC_NOW, Duration.ofMinutes(-5)));
398             updateStatus(ThingStatus.ONLINE);
399         } catch (DataServiceException e) {
400             if (e.getHttpStatus() != 0) {
401                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
402                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
403             } else {
404                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
405             }
406             if (e.getCause() != null) {
407                 logger.debug("Error retrieving CO2 emission prognosis", e);
408             }
409         } catch (InterruptedException e) {
410             logger.debug("Emission prognosis refresh job interrupted");
411             Thread.currentThread().interrupt();
412             return;
413         }
414     }
415
416     private void refreshCo2EmissionRealtime() {
417         try {
418             updateCo2Emissions(Dataset.CO2Emission, CHANNEL_CO2_EMISSION_REALTIME,
419                     DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
420                             realtimeEmissionsFetchedFirstTime ? Duration.ofMinutes(-5) : Duration.ofHours(-24)));
421             realtimeEmissionsFetchedFirstTime = true;
422             updateStatus(ThingStatus.ONLINE);
423         } catch (DataServiceException e) {
424             if (e.getHttpStatus() != 0) {
425                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
426                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
427             } else {
428                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
429             }
430             if (e.getCause() != null) {
431                 logger.debug("Error retrieving CO2 realtime emissions", e);
432             }
433         } catch (InterruptedException e) {
434             logger.debug("Emission realtime refresh job interrupted");
435             Thread.currentThread().interrupt();
436             return;
437         }
438     }
439
440     private void updateCo2Emissions(Dataset dataset, String channelId, DateQueryParameter dateQueryParameter)
441             throws InterruptedException, DataServiceException {
442         if (!"DK1".equals(config.priceArea) && !"DK2".equals(config.priceArea)) {
443             // Dataset is only for Denmark.
444             return;
445         }
446         Map<String, String> properties = editProperties();
447         CO2EmissionRecord[] emissionRecords = apiController.getCo2Emissions(dataset, config.priceArea,
448                 dateQueryParameter, properties);
449         updateProperties(properties);
450
451         TimeSeries timeSeries = new TimeSeries(REPLACE);
452         Instant now = Instant.now();
453
454         if (dataset == Dataset.CO2Emission && emissionRecords.length > 0) {
455             // Records are sorted descending, first record is current.
456             updateState(channelId, new QuantityType<>(emissionRecords[0].emission(), Units.GRAM_PER_KILOWATT_HOUR));
457         }
458
459         for (CO2EmissionRecord emissionRecord : emissionRecords) {
460             State state = new QuantityType<>(emissionRecord.emission(), Units.GRAM_PER_KILOWATT_HOUR);
461             timeSeries.add(emissionRecord.start(), state);
462
463             if (dataset == Dataset.CO2EmissionPrognosis && now.compareTo(emissionRecord.start()) >= 0
464                     && now.compareTo(emissionRecord.end()) < 0) {
465                 updateState(channelId, state);
466             }
467         }
468         sendTimeSeries(channelId, timeSeries);
469     }
470
471     private void updatePrices() {
472         cacheManager.cleanup();
473
474         updateCurrentSpotPrice();
475         Arrays.stream(DatahubTariff.values())
476                 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
477
478         reschedulePriceUpdateJob();
479     }
480
481     private void updateCurrentSpotPrice() {
482         if (!isLinked(CHANNEL_SPOT_PRICE)) {
483             return;
484         }
485         BigDecimal spotPrice = cacheManager.getSpotPrice();
486         updatePriceState(CHANNEL_SPOT_PRICE, spotPrice, config.getCurrency());
487     }
488
489     private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
490         if (!isLinked(channelId)) {
491             return;
492         }
493         updatePriceState(channelId, tariff, CURRENCY_DKK);
494     }
495
496     private void updatePriceState(String channelID, @Nullable BigDecimal price, Currency currency) {
497         updateState(channelID, price != null ? getEnergyPrice(price, currency) : UnDefType.UNDEF);
498     }
499
500     private State getEnergyPrice(BigDecimal price, Currency currency) {
501         String currencyCode = currency.getCurrencyCode();
502         Unit<?> unit = CurrencyUnits.getInstance().getUnit(currencyCode);
503         if (unit == null) {
504             logger.trace("Currency {} is unknown, falling back to DecimalType", currency.getCurrencyCode());
505             return new DecimalType(price);
506         }
507         try {
508             return new QuantityType<>(price + " " + currencyCode + "/kWh");
509         } catch (IllegalArgumentException e) {
510             logger.debug("Unable to create QuantityType, falling back to DecimalType", e);
511             return new DecimalType(price);
512         }
513     }
514
515     /**
516      * Download spot prices in requested period and update corresponding channel with time series.
517      * 
518      * @param startDate Start date of period
519      * @param endDate End date of period
520      * @return number of published states
521      */
522     public int updateSpotPriceTimeSeries(LocalDate startDate, LocalDate endDate)
523             throws InterruptedException, DataServiceException {
524         if (!isLinked(CHANNEL_SPOT_PRICE)) {
525             return 0;
526         }
527         Map<String, String> properties = editProperties();
528         try {
529             Currency currency = config.getCurrency();
530             ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, currency,
531                     DateQueryParameter.of(startDate), DateQueryParameter.of(endDate.plusDays(1)), properties);
532             boolean isDKK = EnergiDataServiceBindingConstants.CURRENCY_DKK.equals(currency);
533             TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
534             if (spotPriceRecords.length == 0) {
535                 return 0;
536             }
537             for (ElspotpriceRecord record : Arrays.stream(spotPriceRecords)
538                     .sorted(Comparator.comparing(ElspotpriceRecord::hour)).toList()) {
539                 spotPriceTimeSeries.add(record.hour(), getEnergyPrice(
540                         (isDKK ? record.spotPriceDKK() : record.spotPriceEUR()).divide(BigDecimal.valueOf(1000)),
541                         currency));
542             }
543             sendTimeSeries(CHANNEL_SPOT_PRICE, spotPriceTimeSeries);
544             return spotPriceRecords.length;
545         } finally {
546             updateProperties(properties);
547         }
548     }
549
550     /**
551      * Download tariffs in requested period and update corresponding channel with time series.
552      * 
553      * @param datahubTariff Tariff to update
554      * @param startDate Start date of period
555      * @param endDate End date of period
556      * @return number of published states
557      */
558     public int updateTariffTimeSeries(DatahubTariff datahubTariff, LocalDate startDate, LocalDate endDate)
559             throws InterruptedException, DataServiceException {
560         if (!isLinked(datahubTariff.getChannelId())) {
561             return 0;
562         }
563         GlobalLocationNumber globalLocationNumber = getGlobalLocationNumber(datahubTariff);
564         if (globalLocationNumber.isEmpty()) {
565             return 0;
566         }
567         DatahubTariffFilter filter = getDatahubTariffFilter(datahubTariff);
568         DateQueryParameter start = filter.getStart();
569         DateQueryParameterType filterStartDateType = start.getDateType();
570         LocalDate filterStartDate = start.getDate();
571         if (filterStartDateType != null) {
572             // For filters with date relative to current date, override with provided parameters.
573             filter = new DatahubTariffFilter(filter, DateQueryParameter.of(startDate), DateQueryParameter.of(endDate));
574         } else if (filterStartDate != null && startDate.isBefore(filterStartDate)) {
575             throw new IllegalArgumentException("Start date before " + start.getDate() + " is not supported");
576         }
577         Collection<DatahubPricelistRecord> datahubRecords = downloadPriceLists(globalLocationNumber, filter);
578         ZoneId zoneId = timeZoneProvider.getTimeZone();
579         Instant firstHourStart = startDate.atStartOfDay(zoneId).toInstant();
580         Instant lastHourStart = endDate.plusDays(1).atStartOfDay(zoneId).toInstant();
581         Map<Instant, BigDecimal> tariffMap = new PriceListParser().toHourly(datahubRecords, firstHourStart,
582                 lastHourStart);
583
584         return updatePriceTimeSeries(datahubTariff.getChannelId(), tariffMap, CURRENCY_DKK, true);
585     }
586
587     private void updateElectricityTimeSeriesFromCache() {
588         updatePriceTimeSeries(CHANNEL_SPOT_PRICE, cacheManager.getSpotPrices(), config.getCurrency(), false);
589
590         for (DatahubTariff datahubTariff : DatahubTariff.values()) {
591             String channelId = datahubTariff.getChannelId();
592             updatePriceTimeSeries(channelId, cacheManager.getTariffs(datahubTariff), CURRENCY_DKK, true);
593         }
594     }
595
596     private int updatePriceTimeSeries(String channelId, Map<Instant, BigDecimal> priceMap, Currency currency,
597             boolean deduplicate) {
598         if (!isLinked(channelId)) {
599             return 0;
600         }
601         List<Entry<Instant, BigDecimal>> prices = priceMap.entrySet().stream().sorted(Map.Entry.comparingByKey())
602                 .toList();
603         TimeSeries timeSeries = new TimeSeries(REPLACE);
604         BigDecimal previousTariff = null;
605         for (Entry<Instant, BigDecimal> price : prices) {
606             Instant hourStart = price.getKey();
607             BigDecimal priceValue = price.getValue();
608             if (deduplicate && priceValue.equals(previousTariff)) {
609                 // Skip redundant states.
610                 continue;
611             }
612             timeSeries.add(hourStart, getEnergyPrice(priceValue, currency));
613             previousTariff = priceValue;
614         }
615         if (timeSeries.size() > 0) {
616             sendTimeSeries(channelId, timeSeries);
617         }
618         return timeSeries.size();
619     }
620
621     /**
622      * Get the configured {@link Currency} for spot prices.
623      * 
624      * @return Spot price currency
625      */
626     public Currency getCurrency() {
627         return config.getCurrency();
628     }
629
630     /**
631      * Get cached spot prices or try once to download them if not cached
632      * (usually if no items are linked).
633      *
634      * @return Map of future spot prices
635      */
636     public Map<Instant, BigDecimal> getSpotPrices() {
637         try {
638             downloadSpotPrices();
639         } catch (DataServiceException e) {
640             if (logger.isDebugEnabled()) {
641                 logger.warn("Error retrieving spot prices", e);
642             } else {
643                 logger.warn("Error retrieving spot prices: {}", e.getMessage());
644             }
645         } catch (InterruptedException e) {
646             Thread.currentThread().interrupt();
647         }
648
649         return cacheManager.getSpotPrices();
650     }
651
652     /**
653      * Return cached tariffs or try once to download them if not cached
654      * (usually if no items are linked).
655      *
656      * @return Map of future tariffs
657      */
658     public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
659         try {
660             downloadTariffs(datahubTariff);
661         } catch (DataServiceException e) {
662             if (logger.isDebugEnabled()) {
663                 logger.warn("Error retrieving tariffs", e);
664             } else {
665                 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
666             }
667         } catch (InterruptedException e) {
668             Thread.currentThread().interrupt();
669         }
670
671         return cacheManager.getTariffs(datahubTariff);
672     }
673
674     /**
675      * Return whether reduced electricity tax is set in configuration.
676      *
677      * @return true if reduced electricity tax applies
678      */
679     public boolean isReducedElectricityTax() {
680         return config.reducedElectricityTax;
681     }
682
683     private void reschedulePriceUpdateJob() {
684         ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
685         if (priceUpdateJob != null) {
686             // Do not interrupt ourselves.
687             priceUpdateJob.cancel(false);
688             this.priceUpdateFuture = null;
689         }
690
691         Instant now = Instant.now();
692         long millisUntilNextClockHour = Duration
693                 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
694         this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
695                 TimeUnit.MILLISECONDS);
696         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
697     }
698
699     private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
700         // Preserve state of previous retry policy when configuration is the same.
701         if (!retryPolicy.equals(this.retryPolicy)) {
702             this.retryPolicy = retryPolicy;
703         }
704
705         ScheduledFuture<?> refreshJob = this.refreshPriceFuture;
706
707         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
708         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
709         this.refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
710                 TimeUnit.SECONDS);
711         logger.debug("Price refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
712         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
713         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
714                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
715
716         if (refreshJob != null) {
717             refreshJob.cancel(true);
718         }
719     }
720
721     private void rescheduleEmissionPrognosisJob() {
722         logger.debug("Scheduling emission prognosis refresh job now and every {}", emissionPrognosisJobInterval);
723
724         ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
725         if (refreshEmissionPrognosisFuture != null) {
726             refreshEmissionPrognosisFuture.cancel(true);
727         }
728
729         this.refreshEmissionPrognosisFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionPrognosis, 0,
730                 emissionPrognosisJobInterval.toSeconds(), TimeUnit.SECONDS);
731     }
732
733     private void rescheduleEmissionRealtimeJob() {
734         logger.debug("Scheduling emission realtime refresh job now and every {}", emissionRealtimeJobInterval);
735
736         ScheduledFuture<?> refreshEmissionFuture = this.refreshEmissionRealtimeFuture;
737         if (refreshEmissionFuture != null) {
738             refreshEmissionFuture.cancel(true);
739         }
740
741         this.refreshEmissionRealtimeFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionRealtime, 0,
742                 emissionRealtimeJobInterval.toSeconds(), TimeUnit.SECONDS);
743     }
744 }