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