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