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