]> git.basschouten.com Git - openhab-addons.git/blob
ca686821fb6b0c55bd6e042f280841fcab5b3b0f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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 org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.http.HttpStatus;
40 import org.openhab.binding.energidataservice.internal.ApiController;
41 import org.openhab.binding.energidataservice.internal.CacheManager;
42 import org.openhab.binding.energidataservice.internal.DatahubTariff;
43 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
44 import org.openhab.binding.energidataservice.internal.api.ChargeType;
45 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
46 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
47 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
48 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
49 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
50 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
51 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
52 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
53 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
54 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
55 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
56 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
57 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
58 import org.openhab.core.i18n.TimeZoneProvider;
59 import org.openhab.core.library.types.DecimalType;
60 import org.openhab.core.library.types.StringType;
61 import org.openhab.core.thing.Channel;
62 import org.openhab.core.thing.ChannelUID;
63 import org.openhab.core.thing.Thing;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.binding.BaseThingHandler;
67 import org.openhab.core.thing.binding.ThingHandlerService;
68 import org.openhab.core.types.Command;
69 import org.openhab.core.types.RefreshType;
70 import org.openhab.core.types.TimeSeries;
71 import org.openhab.core.types.UnDefType;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
74
75 import com.google.gson.Gson;
76
77 /**
78  * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
79  * sent to one of the channels.
80  *
81  * @author Jacob Laursen - Initial contribution
82  */
83 @NonNullByDefault
84 public class EnergiDataServiceHandler extends BaseThingHandler {
85
86     private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
87     private final TimeZoneProvider timeZoneProvider;
88     private final ApiController apiController;
89     private final CacheManager cacheManager;
90     private final Gson gson = new Gson();
91
92     private EnergiDataServiceConfiguration config;
93     private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
94     private @Nullable ScheduledFuture<?> refreshFuture;
95     private @Nullable ScheduledFuture<?> priceUpdateFuture;
96
97     private record Price(String hourStart, BigDecimal spotPrice, String spotPriceCurrency,
98             @Nullable BigDecimal gridTariff, @Nullable BigDecimal systemTariff,
99             @Nullable BigDecimal transmissionGridTariff, @Nullable BigDecimal electricityTax,
100             @Nullable BigDecimal reducedElectricityTax) {
101     }
102
103     public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
104         super(thing);
105         this.timeZoneProvider = timeZoneProvider;
106         this.apiController = new ApiController(httpClient, timeZoneProvider);
107         this.cacheManager = new CacheManager();
108
109         // Default configuration
110         this.config = new EnergiDataServiceConfiguration();
111     }
112
113     @Override
114     public void handleCommand(ChannelUID channelUID, Command command) {
115         if (!(command instanceof RefreshType)) {
116             return;
117         }
118
119         if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
120             refreshElectricityPrices();
121         }
122     }
123
124     @Override
125     public void initialize() {
126         config = getConfigAs(EnergiDataServiceConfiguration.class);
127
128         if (config.priceArea.isBlank()) {
129             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
130                     "@text/offline.conf-error.no-price-area");
131             return;
132         }
133         GlobalLocationNumber gln = config.getGridCompanyGLN();
134         if (!gln.isEmpty() && !gln.isValid()) {
135             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
136                     "@text/offline.conf-error.invalid-grid-company-gln");
137             return;
138         }
139         gln = config.getEnerginetGLN();
140         if (!gln.isEmpty() && !gln.isValid()) {
141             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
142                     "@text/offline.conf-error.invalid-energinet-gln");
143             return;
144         }
145
146         updateStatus(ThingStatus.UNKNOWN);
147
148         refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
149     }
150
151     @Override
152     public void dispose() {
153         ScheduledFuture<?> refreshFuture = this.refreshFuture;
154         if (refreshFuture != null) {
155             refreshFuture.cancel(true);
156             this.refreshFuture = null;
157         }
158         ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
159         if (priceUpdateFuture != null) {
160             priceUpdateFuture.cancel(true);
161             this.priceUpdateFuture = null;
162         }
163
164         cacheManager.clear();
165     }
166
167     @Override
168     public Collection<Class<? extends ThingHandlerService>> getServices() {
169         return Set.of(EnergiDataServiceActions.class);
170     }
171
172     private void refreshElectricityPrices() {
173         RetryStrategy retryPolicy;
174         try {
175             if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
176                 downloadSpotPrices();
177             }
178
179             for (DatahubTariff datahubTariff : DatahubTariff.values()) {
180                 if (isLinked(datahubTariff.getChannelId()) || isLinked(CHANNEL_HOURLY_PRICES)) {
181                     downloadTariffs(datahubTariff);
182                 }
183             }
184
185             updateStatus(ThingStatus.ONLINE);
186             updatePrices();
187             updateTimeSeries();
188
189             if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
190                 if (cacheManager.getNumberOfFutureSpotPrices() < 13) {
191                     retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing(DAILY_REFRESH_TIME_CET,
192                             NORD_POOL_TIMEZONE);
193                 } else {
194                     retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
195                 }
196             } else {
197                 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
198             }
199         } catch (DataServiceException e) {
200             if (e.getHttpStatus() != 0) {
201                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
202                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
203             } else {
204                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
205             }
206             if (e.getCause() != null) {
207                 logger.debug("Error retrieving prices", e);
208             }
209             retryPolicy = RetryPolicyFactory.fromThrowable(e);
210         } catch (InterruptedException e) {
211             logger.debug("Refresh job interrupted");
212             Thread.currentThread().interrupt();
213             return;
214         }
215
216         rescheduleRefreshJob(retryPolicy);
217     }
218
219     private void downloadSpotPrices() throws InterruptedException, DataServiceException {
220         if (cacheManager.areSpotPricesFullyCached()) {
221             logger.debug("Cached spot prices still valid, skipping download.");
222             return;
223         }
224         DateQueryParameter start;
225         if (cacheManager.areHistoricSpotPricesCached()) {
226             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
227         } else {
228             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
229                     Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
230         }
231         Map<String, String> properties = editProperties();
232         ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
233                 start, properties);
234         cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
235         updateProperties(properties);
236     }
237
238     private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
239         GlobalLocationNumber globalLocationNumber = switch (datahubTariff) {
240             case GRID_TARIFF -> config.getGridCompanyGLN();
241             default -> config.getEnerginetGLN();
242         };
243         if (globalLocationNumber.isEmpty()) {
244             return;
245         }
246         if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
247             logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
248             cacheManager.updateTariffs(datahubTariff);
249         } else {
250             DatahubTariffFilter filter = switch (datahubTariff) {
251                 case GRID_TARIFF -> getGridTariffFilter();
252                 case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
253                 case TRANSMISSION_GRID_TARIFF -> DatahubTariffFilterFactory.getTransmissionGridTariff();
254                 case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
255                 case REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
256             };
257             cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
258         }
259     }
260
261     private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
262             DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
263         Map<String, String> properties = editProperties();
264         Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
265                 ChargeType.Tariff, filter, properties);
266         updateProperties(properties);
267
268         return records;
269     }
270
271     private DatahubTariffFilter getGridTariffFilter() {
272         Channel channel = getThing().getChannel(CHANNEL_GRID_TARIFF);
273         if (channel == null) {
274             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
275         }
276
277         DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
278                 .as(DatahubPriceConfiguration.class);
279
280         if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
281             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
282         }
283
284         DateQueryParameter start = datahubPriceConfiguration.getStart();
285         if (start == null) {
286             logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
287                     datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
288             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
289         }
290
291         Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
292         Set<String> notes = datahubPriceConfiguration.getNotes();
293         DatahubTariffFilter filter;
294         if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
295             // Completely override filter.
296             filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
297         } else {
298             // Only override start date in pre-configured filter.
299             filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN),
300                     start);
301         }
302
303         return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
304                 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
305     }
306
307     private void updatePrices() {
308         cacheManager.cleanup();
309
310         updateCurrentSpotPrice();
311         Arrays.stream(DatahubTariff.values())
312                 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
313         updateHourlyPrices();
314
315         reschedulePriceUpdateJob();
316     }
317
318     private void updateCurrentSpotPrice() {
319         if (!isLinked(CHANNEL_SPOT_PRICE)) {
320             return;
321         }
322         BigDecimal spotPrice = cacheManager.getSpotPrice();
323         updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
324     }
325
326     private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
327         if (!isLinked(channelId)) {
328             return;
329         }
330         updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
331     }
332
333     private void updateHourlyPrices() {
334         if (!isLinked(CHANNEL_HOURLY_PRICES)) {
335             return;
336         }
337         Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
338         Price[] targetPrices = new Price[spotPriceMap.size()];
339         List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
340                 .sorted(Map.Entry.comparingByKey()).toList();
341
342         int i = 0;
343         for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
344             Instant hourStart = sourcePrice.getKey();
345             BigDecimal gridTariff = cacheManager.getTariff(DatahubTariff.GRID_TARIFF, hourStart);
346             BigDecimal systemTariff = cacheManager.getTariff(DatahubTariff.SYSTEM_TARIFF, hourStart);
347             BigDecimal transmissionGridTariff = cacheManager.getTariff(DatahubTariff.TRANSMISSION_GRID_TARIFF,
348                     hourStart);
349             BigDecimal electricityTax = cacheManager.getTariff(DatahubTariff.ELECTRICITY_TAX, hourStart);
350             BigDecimal reducedElectricityTax = cacheManager.getTariff(DatahubTariff.REDUCED_ELECTRICITY_TAX, hourStart);
351             targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, gridTariff,
352                     systemTariff, electricityTax, reducedElectricityTax, transmissionGridTariff);
353         }
354         updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
355     }
356
357     private void updateTimeSeries() {
358         TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
359         Map<DatahubTariff, TimeSeries> datahubTimeSeriesMap = new HashMap<>();
360         for (DatahubTariff datahubTariff : DatahubTariff.values()) {
361             datahubTimeSeriesMap.put(datahubTariff, new TimeSeries(REPLACE));
362         }
363
364         Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
365         List<Entry<Instant, BigDecimal>> spotPrices = spotPriceMap.entrySet().stream()
366                 .sorted(Map.Entry.comparingByKey()).toList();
367         for (Entry<Instant, BigDecimal> spotPrice : spotPrices) {
368             Instant hourStart = spotPrice.getKey();
369             if (isLinked(CHANNEL_SPOT_PRICE)) {
370                 spotPriceTimeSeries.add(hourStart, new DecimalType(spotPrice.getValue()));
371             }
372             for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
373                 DatahubTariff datahubTariff = entry.getKey();
374                 String channelId = datahubTariff.getChannelId();
375                 if (!isLinked(channelId)) {
376                     continue;
377                 }
378                 BigDecimal tariff = cacheManager.getTariff(datahubTariff, hourStart);
379                 if (tariff != null) {
380                     TimeSeries timeSeries = entry.getValue();
381                     timeSeries.add(hourStart, new DecimalType(tariff));
382                 }
383             }
384         }
385         if (spotPriceTimeSeries.size() > 0) {
386             sendTimeSeries(CHANNEL_SPOT_PRICE, spotPriceTimeSeries);
387         }
388         for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
389             DatahubTariff datahubTariff = entry.getKey();
390             String channelId = datahubTariff.getChannelId();
391             if (!isLinked(channelId)) {
392                 continue;
393             }
394             TimeSeries timeSeries = entry.getValue();
395             if (timeSeries.size() > 0) {
396                 sendTimeSeries(channelId, timeSeries);
397             }
398         }
399     }
400
401     /**
402      * Get the configured {@link Currency} for spot prices.
403      * 
404      * @return Spot price currency
405      */
406     public Currency getCurrency() {
407         return config.getCurrency();
408     }
409
410     /**
411      * Get cached spot prices or try once to download them if not cached
412      * (usually if no items are linked).
413      *
414      * @return Map of future spot prices
415      */
416     public Map<Instant, BigDecimal> getSpotPrices() {
417         try {
418             downloadSpotPrices();
419         } catch (DataServiceException e) {
420             if (logger.isDebugEnabled()) {
421                 logger.warn("Error retrieving spot prices", e);
422             } else {
423                 logger.warn("Error retrieving spot prices: {}", e.getMessage());
424             }
425         } catch (InterruptedException e) {
426             Thread.currentThread().interrupt();
427         }
428
429         return cacheManager.getSpotPrices();
430     }
431
432     /**
433      * Return cached tariffs or try once to download them if not cached
434      * (usually if no items are linked).
435      *
436      * @return Map of future tariffs
437      */
438     public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
439         try {
440             downloadTariffs(datahubTariff);
441         } catch (DataServiceException e) {
442             if (logger.isDebugEnabled()) {
443                 logger.warn("Error retrieving tariffs", e);
444             } else {
445                 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
446             }
447         } catch (InterruptedException e) {
448             Thread.currentThread().interrupt();
449         }
450
451         return cacheManager.getTariffs(datahubTariff);
452     }
453
454     /**
455      * Return whether reduced electricity tax is set in configuration.
456      *
457      * @return true if reduced electricity tax applies
458      */
459     public boolean isReducedElectricityTax() {
460         return config.reducedElectricityTax;
461     }
462
463     private void reschedulePriceUpdateJob() {
464         ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
465         if (priceUpdateJob != null) {
466             // Do not interrupt ourselves.
467             priceUpdateJob.cancel(false);
468             this.priceUpdateFuture = null;
469         }
470
471         Instant now = Instant.now();
472         long millisUntilNextClockHour = Duration
473                 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
474         this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
475                 TimeUnit.MILLISECONDS);
476         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
477     }
478
479     private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
480         // Preserve state of previous retry policy when configuration is the same.
481         if (!retryPolicy.equals(this.retryPolicy)) {
482             this.retryPolicy = retryPolicy;
483         }
484
485         ScheduledFuture<?> refreshJob = this.refreshFuture;
486
487         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
488         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
489         this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
490                 TimeUnit.SECONDS);
491         logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
492         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
493         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
494                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
495
496         if (refreshJob != null) {
497             refreshJob.cancel(true);
498         }
499     }
500 }