]> git.basschouten.com Git - openhab-addons.git/blob
94a4f430eba6df7b1f058bedeea39112e7eb33ea
[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.Arrays;
25 import java.util.Collection;
26 import java.util.Currency;
27 import java.util.List;
28 import java.util.Map;
29 import java.util.Map.Entry;
30 import java.util.Set;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
33
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.eclipse.jetty.http.HttpStatus;
38 import org.openhab.binding.energidataservice.internal.ApiController;
39 import org.openhab.binding.energidataservice.internal.CacheManager;
40 import org.openhab.binding.energidataservice.internal.DatahubTariff;
41 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
42 import org.openhab.binding.energidataservice.internal.api.ChargeType;
43 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
44 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
45 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
46 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
47 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
48 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
49 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
50 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
51 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
52 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
53 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
54 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
55 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
56 import org.openhab.core.i18n.TimeZoneProvider;
57 import org.openhab.core.library.types.DecimalType;
58 import org.openhab.core.library.types.StringType;
59 import org.openhab.core.thing.Channel;
60 import org.openhab.core.thing.ChannelUID;
61 import org.openhab.core.thing.Thing;
62 import org.openhab.core.thing.ThingStatus;
63 import org.openhab.core.thing.ThingStatusDetail;
64 import org.openhab.core.thing.binding.BaseThingHandler;
65 import org.openhab.core.thing.binding.ThingHandlerService;
66 import org.openhab.core.types.Command;
67 import org.openhab.core.types.RefreshType;
68 import org.openhab.core.types.UnDefType;
69 import org.slf4j.Logger;
70 import org.slf4j.LoggerFactory;
71
72 import com.google.gson.Gson;
73
74 /**
75  * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
76  * sent to one of the channels.
77  *
78  * @author Jacob Laursen - Initial contribution
79  */
80 @NonNullByDefault
81 public class EnergiDataServiceHandler extends BaseThingHandler {
82
83     private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
84     private final TimeZoneProvider timeZoneProvider;
85     private final ApiController apiController;
86     private final CacheManager cacheManager;
87     private final Gson gson = new Gson();
88
89     private EnergiDataServiceConfiguration config;
90     private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
91     private @Nullable ScheduledFuture<?> refreshFuture;
92     private @Nullable ScheduledFuture<?> priceUpdateFuture;
93
94     private record Price(String hourStart, BigDecimal spotPrice, String spotPriceCurrency,
95             @Nullable BigDecimal netTariff, @Nullable BigDecimal systemTariff, @Nullable BigDecimal electricityTax,
96             @Nullable BigDecimal transmissionNetTariff) {
97     }
98
99     public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
100         super(thing);
101         this.timeZoneProvider = timeZoneProvider;
102         this.apiController = new ApiController(httpClient, timeZoneProvider);
103         this.cacheManager = new CacheManager();
104
105         // Default configuration
106         this.config = new EnergiDataServiceConfiguration();
107     }
108
109     @Override
110     public void handleCommand(ChannelUID channelUID, Command command) {
111         if (!(command instanceof RefreshType)) {
112             return;
113         }
114
115         if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
116             refreshElectricityPrices();
117         }
118     }
119
120     @Override
121     public void initialize() {
122         config = getConfigAs(EnergiDataServiceConfiguration.class);
123
124         if (config.priceArea.isBlank()) {
125             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
126                     "@text/offline.conf-error.no-price-area");
127             return;
128         }
129         GlobalLocationNumber gln = config.getGridCompanyGLN();
130         if (!gln.isEmpty() && !gln.isValid()) {
131             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
132                     "@text/offline.conf-error.invalid-grid-company-gln");
133             return;
134         }
135         gln = config.getEnerginetGLN();
136         if (!gln.isEmpty() && !gln.isValid()) {
137             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
138                     "@text/offline.conf-error.invalid-energinet-gln");
139             return;
140         }
141
142         updateStatus(ThingStatus.UNKNOWN);
143
144         refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
145     }
146
147     @Override
148     public void dispose() {
149         ScheduledFuture<?> refreshFuture = this.refreshFuture;
150         if (refreshFuture != null) {
151             refreshFuture.cancel(true);
152             this.refreshFuture = null;
153         }
154         ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
155         if (priceUpdateFuture != null) {
156             priceUpdateFuture.cancel(true);
157             this.priceUpdateFuture = null;
158         }
159
160         cacheManager.clear();
161     }
162
163     @Override
164     public Collection<Class<? extends ThingHandlerService>> getServices() {
165         return Set.of(EnergiDataServiceActions.class);
166     }
167
168     private void refreshElectricityPrices() {
169         RetryStrategy retryPolicy;
170         try {
171             if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
172                 downloadSpotPrices();
173             }
174
175             for (DatahubTariff datahubTariff : DatahubTariff.values()) {
176                 if (isLinked(datahubTariff.getChannelId()) || isLinked(CHANNEL_HOURLY_PRICES)) {
177                     downloadTariffs(datahubTariff);
178                 }
179             }
180
181             updateStatus(ThingStatus.ONLINE);
182             updatePrices();
183
184             if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
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 NET_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 NET_TARIFF -> getNetTariffFilter();
247                 case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
248                 case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
249                 case TRANSMISSION_NET_TARIFF -> DatahubTariffFilterFactory.getTransmissionNetTariff();
250             };
251             cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
252         }
253     }
254
255     private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
256             DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
257         Map<String, String> properties = editProperties();
258         Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
259                 ChargeType.Tariff, filter, properties);
260         updateProperties(properties);
261
262         return records;
263     }
264
265     private DatahubTariffFilter getNetTariffFilter() {
266         Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
267         if (channel == null) {
268             return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
269         }
270
271         DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
272                 .as(DatahubPriceConfiguration.class);
273
274         if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
275             return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
276         }
277
278         DateQueryParameter start = datahubPriceConfiguration.getStart();
279         if (start == null) {
280             logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
281                     datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
282             return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
283         }
284
285         Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
286         Set<String> notes = datahubPriceConfiguration.getNotes();
287         DatahubTariffFilter filter;
288         if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
289             // Completely override filter.
290             filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
291         } else {
292             // Only override start date in pre-configured filter.
293             filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN),
294                     start);
295         }
296
297         return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
298                 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
299     }
300
301     private void updatePrices() {
302         cacheManager.cleanup();
303
304         updateCurrentSpotPrice();
305         Arrays.stream(DatahubTariff.values())
306                 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
307         updateHourlyPrices();
308
309         reschedulePriceUpdateJob();
310     }
311
312     private void updateCurrentSpotPrice() {
313         if (!isLinked(CHANNEL_SPOT_PRICE)) {
314             return;
315         }
316         BigDecimal spotPrice = cacheManager.getSpotPrice();
317         updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
318     }
319
320     private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
321         if (!isLinked(channelId)) {
322             return;
323         }
324         updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
325     }
326
327     private void updateHourlyPrices() {
328         if (!isLinked(CHANNEL_HOURLY_PRICES)) {
329             return;
330         }
331         Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
332         Price[] targetPrices = new Price[spotPriceMap.size()];
333         List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
334                 .sorted(Map.Entry.comparingByKey()).toList();
335
336         int i = 0;
337         for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
338             Instant hourStart = sourcePrice.getKey();
339             BigDecimal netTariff = cacheManager.getTariff(DatahubTariff.NET_TARIFF, hourStart);
340             BigDecimal systemTariff = cacheManager.getTariff(DatahubTariff.SYSTEM_TARIFF, hourStart);
341             BigDecimal electricityTax = cacheManager.getTariff(DatahubTariff.ELECTRICITY_TAX, hourStart);
342             BigDecimal transmissionNetTariff = cacheManager.getTariff(DatahubTariff.TRANSMISSION_NET_TARIFF, hourStart);
343             targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, netTariff,
344                     systemTariff, electricityTax, transmissionNetTariff);
345         }
346         updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
347     }
348
349     /**
350      * Get the configured {@link Currency} for spot prices.
351      * 
352      * @return Spot price currency
353      */
354     public Currency getCurrency() {
355         return config.getCurrency();
356     }
357
358     /**
359      * Get cached spot prices or try once to download them if not cached
360      * (usually if no items are linked).
361      *
362      * @return Map of future spot prices
363      */
364     public Map<Instant, BigDecimal> getSpotPrices() {
365         try {
366             downloadSpotPrices();
367         } catch (DataServiceException e) {
368             if (logger.isDebugEnabled()) {
369                 logger.warn("Error retrieving spot prices", e);
370             } else {
371                 logger.warn("Error retrieving spot prices: {}", e.getMessage());
372             }
373         } catch (InterruptedException e) {
374             Thread.currentThread().interrupt();
375         }
376
377         return cacheManager.getSpotPrices();
378     }
379
380     /**
381      * Return cached tariffs or try once to download them if not cached
382      * (usually if no items are linked).
383      *
384      * @return Map of future tariffs
385      */
386     public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
387         try {
388             downloadTariffs(datahubTariff);
389         } catch (DataServiceException e) {
390             if (logger.isDebugEnabled()) {
391                 logger.warn("Error retrieving tariffs", e);
392             } else {
393                 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
394             }
395         } catch (InterruptedException e) {
396             Thread.currentThread().interrupt();
397         }
398
399         return cacheManager.getTariffs(datahubTariff);
400     }
401
402     private void reschedulePriceUpdateJob() {
403         ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
404         if (priceUpdateJob != null) {
405             // Do not interrupt ourselves.
406             priceUpdateJob.cancel(false);
407             this.priceUpdateFuture = null;
408         }
409
410         Instant now = Instant.now();
411         long millisUntilNextClockHour = Duration
412                 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
413         this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
414                 TimeUnit.MILLISECONDS);
415         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
416     }
417
418     private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
419         // Preserve state of previous retry policy when configuration is the same.
420         if (!retryPolicy.equals(this.retryPolicy)) {
421             this.retryPolicy = retryPolicy;
422         }
423
424         ScheduledFuture<?> refreshJob = this.refreshFuture;
425
426         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
427         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
428         this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
429                 TimeUnit.SECONDS);
430         logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
431         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
432         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
433                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
434
435         if (refreshJob != null) {
436             refreshJob.cancel(true);
437         }
438     }
439 }