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