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