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