]> git.basschouten.com Git - openhab-addons.git/blob
16bffcf8da388d8ab0db5a6e84c696ceee13b24e
[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             cacheManager.putNetTariffs(downloadPriceLists(config.getGridCompanyGLN(), getNetTariffFilter()));
250         }
251     }
252
253     private void downloadSystemTariffs() throws InterruptedException, DataServiceException {
254         GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
255         if (globalLocationNumber.isEmpty()) {
256             return;
257         }
258         if (cacheManager.areSystemTariffsValidTomorrow()) {
259             logger.debug("Cached system tariffs still valid, skipping download.");
260             cacheManager.updateSystemTariffs();
261         } else {
262             cacheManager.putSystemTariffs(
263                     downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getSystemTariff()));
264         }
265     }
266
267     private void downloadElectricityTaxes() throws InterruptedException, DataServiceException {
268         GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
269         if (globalLocationNumber.isEmpty()) {
270             return;
271         }
272         if (cacheManager.areElectricityTaxesValidTomorrow()) {
273             logger.debug("Cached electricity taxes still valid, skipping download.");
274             cacheManager.updateElectricityTaxes();
275         } else {
276             cacheManager.putElectricityTaxes(
277                     downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getElectricityTax()));
278         }
279     }
280
281     private void downloadTransmissionNetTariffs() throws InterruptedException, DataServiceException {
282         GlobalLocationNumber globalLocationNumber = config.getEnerginetGLN();
283         if (globalLocationNumber.isEmpty()) {
284             return;
285         }
286         if (cacheManager.areTransmissionNetTariffsValidTomorrow()) {
287             logger.debug("Cached transmission net tariffs still valid, skipping download.");
288             cacheManager.updateTransmissionNetTariffs();
289         } else {
290             cacheManager.putTransmissionNetTariffs(
291                     downloadPriceLists(globalLocationNumber, DatahubTariffFilterFactory.getTransmissionNetTariff()));
292         }
293     }
294
295     private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
296             DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
297         Map<String, String> properties = editProperties();
298         Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
299                 ChargeType.Tariff, filter, properties);
300         updateProperties(properties);
301
302         return records;
303     }
304
305     private DatahubTariffFilter getNetTariffFilter() {
306         Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
307         if (channel == null) {
308             return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
309         }
310
311         DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
312                 .as(DatahubPriceConfiguration.class);
313
314         if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
315             return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
316         }
317
318         DateQueryParameter start = datahubPriceConfiguration.getStart();
319         if (start == null) {
320             logger.warn("Invalid channel configuration parameter 'start': {}", datahubPriceConfiguration.start);
321             return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
322         }
323
324         Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
325         Set<String> notes = datahubPriceConfiguration.getNotes();
326         if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
327             // Completely override filter.
328             return new DatahubTariffFilter(chargeTypeCodes, notes, start);
329         } else {
330             // Only override start date in pre-configured filter.
331             return new DatahubTariffFilter(DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN), start);
332         }
333     }
334
335     private void updatePrices() {
336         cacheManager.cleanup();
337
338         updateCurrentSpotPrice();
339         updateCurrentTariff(CHANNEL_NET_TARIFF, cacheManager.getNetTariff());
340         updateCurrentTariff(CHANNEL_SYSTEM_TARIFF, cacheManager.getSystemTariff());
341         updateCurrentTariff(CHANNEL_ELECTRICITY_TAX, cacheManager.getElectricityTax());
342         updateCurrentTariff(CHANNEL_TRANSMISSION_NET_TARIFF, cacheManager.getTransmissionNetTariff());
343         updateHourlyPrices();
344
345         reschedulePriceUpdateJob();
346     }
347
348     private void updateCurrentSpotPrice() {
349         if (!isLinked(CHANNEL_SPOT_PRICE)) {
350             return;
351         }
352         BigDecimal spotPrice = cacheManager.getSpotPrice();
353         updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
354     }
355
356     private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
357         if (!isLinked(channelId)) {
358             return;
359         }
360         updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
361     }
362
363     private void updateHourlyPrices() {
364         if (!isLinked(CHANNEL_HOURLY_PRICES)) {
365             return;
366         }
367         Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
368         Price[] targetPrices = new Price[spotPriceMap.size()];
369         List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
370                 .sorted(Map.Entry.comparingByKey()).toList();
371
372         int i = 0;
373         for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
374             Instant hourStart = sourcePrice.getKey();
375             BigDecimal netTariff = cacheManager.getNetTariff(hourStart);
376             BigDecimal systemTariff = cacheManager.getSystemTariff(hourStart);
377             BigDecimal electricityTax = cacheManager.getElectricityTax(hourStart);
378             BigDecimal transmissionNetTariff = cacheManager.getTransmissionNetTariff(hourStart);
379             targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, netTariff,
380                     systemTariff, electricityTax, transmissionNetTariff);
381         }
382         updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
383     }
384
385     /**
386      * Get the configured {@link Currency} for spot prices.
387      * 
388      * @return Spot price currency
389      */
390     public Currency getCurrency() {
391         return config.getCurrency();
392     }
393
394     /**
395      * Get cached spot prices or try once to download them if not cached
396      * (usually if no items are linked).
397      *
398      * @return Map of future spot prices
399      */
400     public Map<Instant, BigDecimal> getSpotPrices() {
401         try {
402             downloadSpotPrices();
403         } catch (DataServiceException e) {
404             if (logger.isDebugEnabled()) {
405                 logger.warn("Error retrieving spot prices", e);
406             } else {
407                 logger.warn("Error retrieving spot prices: {}", e.getMessage());
408             }
409         } catch (InterruptedException e) {
410             Thread.currentThread().interrupt();
411         }
412
413         return cacheManager.getSpotPrices();
414     }
415
416     /**
417      * Get cached net tariffs or try once to download them if not cached
418      * (usually if no items are linked).
419      *
420      * @return Map of future net tariffs
421      */
422     public Map<Instant, BigDecimal> getNetTariffs() {
423         try {
424             downloadNetTariffs();
425         } catch (DataServiceException e) {
426             if (logger.isDebugEnabled()) {
427                 logger.warn("Error retrieving net tariffs", e);
428             } else {
429                 logger.warn("Error retrieving net tariffs: {}", e.getMessage());
430             }
431         } catch (InterruptedException e) {
432             Thread.currentThread().interrupt();
433         }
434
435         return cacheManager.getNetTariffs();
436     }
437
438     /**
439      * Get cached system tariffs or try once to download them if not cached
440      * (usually if no items are linked).
441      *
442      * @return Map of future system tariffs
443      */
444     public Map<Instant, BigDecimal> getSystemTariffs() {
445         try {
446             downloadSystemTariffs();
447         } catch (DataServiceException e) {
448             if (logger.isDebugEnabled()) {
449                 logger.warn("Error retrieving system tariffs", e);
450             } else {
451                 logger.warn("Error retrieving system tariffs: {}", e.getMessage());
452             }
453         } catch (InterruptedException e) {
454             Thread.currentThread().interrupt();
455         }
456
457         return cacheManager.getSystemTariffs();
458     }
459
460     /**
461      * Get cached electricity taxes or try once to download them if not cached
462      * (usually if no items are linked).
463      *
464      * @return Map of future electricity taxes
465      */
466     public Map<Instant, BigDecimal> getElectricityTaxes() {
467         try {
468             downloadElectricityTaxes();
469         } catch (DataServiceException e) {
470             if (logger.isDebugEnabled()) {
471                 logger.warn("Error retrieving electricity taxes", e);
472             } else {
473                 logger.warn("Error retrieving electricity taxes: {}", e.getMessage());
474             }
475         } catch (InterruptedException e) {
476             Thread.currentThread().interrupt();
477         }
478
479         return cacheManager.getElectricityTaxes();
480     }
481
482     /**
483      * Return cached transmission net tariffs or try once to download them if not cached
484      * (usually if no items are linked).
485      *
486      * @return Map of future transmissions net tariffs
487      */
488     public Map<Instant, BigDecimal> getTransmissionNetTariffs() {
489         try {
490             downloadTransmissionNetTariffs();
491         } catch (DataServiceException e) {
492             if (logger.isDebugEnabled()) {
493                 logger.warn("Error retrieving transmission net tariffs", e);
494             } else {
495                 logger.warn("Error retrieving transmission net tariffs: {}", e.getMessage());
496             }
497         } catch (InterruptedException e) {
498             Thread.currentThread().interrupt();
499         }
500
501         return cacheManager.getTransmissionNetTariffs();
502     }
503
504     private void reschedulePriceUpdateJob() {
505         ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
506         if (priceUpdateJob != null) {
507             // Do not interrupt ourselves.
508             priceUpdateJob.cancel(false);
509             this.priceUpdateFuture = null;
510         }
511
512         Instant now = Instant.now();
513         long millisUntilNextClockHour = Duration
514                 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
515         this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
516                 TimeUnit.MILLISECONDS);
517         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
518     }
519
520     private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
521         // Preserve state of previous retry policy when configuration is the same.
522         if (!retryPolicy.equals(this.retryPolicy)) {
523             this.retryPolicy = retryPolicy;
524         }
525
526         ScheduledFuture<?> refreshJob = this.refreshFuture;
527
528         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
529         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
530         this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
531                 TimeUnit.SECONDS);
532         logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
533         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
534         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
535                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
536
537         if (refreshJob != null) {
538             refreshJob.cancel(true);
539         }
540     }
541 }