]> git.basschouten.com Git - openhab-addons.git/blob
3241685f3d9c3d8b141c247a06e340c9476a80f0
[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 reducedElectricityTax, @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 REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
250                 case TRANSMISSION_NET_TARIFF -> DatahubTariffFilterFactory.getTransmissionNetTariff();
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 getNetTariffFilter() {
267         Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
268         if (channel == null) {
269             return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
270         }
271
272         DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
273                 .as(DatahubPriceConfiguration.class);
274
275         if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
276             return DatahubTariffFilterFactory.getNetTariffByGLN(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.getNetTariffByGLN(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.getNetTariffByGLN(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         updateHourlyPrices();
309
310         reschedulePriceUpdateJob();
311     }
312
313     private void updateCurrentSpotPrice() {
314         if (!isLinked(CHANNEL_SPOT_PRICE)) {
315             return;
316         }
317         BigDecimal spotPrice = cacheManager.getSpotPrice();
318         updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
319     }
320
321     private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
322         if (!isLinked(channelId)) {
323             return;
324         }
325         updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
326     }
327
328     private void updateHourlyPrices() {
329         if (!isLinked(CHANNEL_HOURLY_PRICES)) {
330             return;
331         }
332         Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
333         Price[] targetPrices = new Price[spotPriceMap.size()];
334         List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
335                 .sorted(Map.Entry.comparingByKey()).toList();
336
337         int i = 0;
338         for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
339             Instant hourStart = sourcePrice.getKey();
340             BigDecimal netTariff = cacheManager.getTariff(DatahubTariff.NET_TARIFF, hourStart);
341             BigDecimal systemTariff = cacheManager.getTariff(DatahubTariff.SYSTEM_TARIFF, hourStart);
342             BigDecimal electricityTax = cacheManager.getTariff(DatahubTariff.ELECTRICITY_TAX, hourStart);
343             BigDecimal reducedElectricityTax = cacheManager.getTariff(DatahubTariff.REDUCED_ELECTRICITY_TAX, hourStart);
344             BigDecimal transmissionNetTariff = cacheManager.getTariff(DatahubTariff.TRANSMISSION_NET_TARIFF, hourStart);
345             targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, netTariff,
346                     systemTariff, electricityTax, reducedElectricityTax, transmissionNetTariff);
347         }
348         updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
349     }
350
351     /**
352      * Get the configured {@link Currency} for spot prices.
353      * 
354      * @return Spot price currency
355      */
356     public Currency getCurrency() {
357         return config.getCurrency();
358     }
359
360     /**
361      * Get cached spot prices or try once to download them if not cached
362      * (usually if no items are linked).
363      *
364      * @return Map of future spot prices
365      */
366     public Map<Instant, BigDecimal> getSpotPrices() {
367         try {
368             downloadSpotPrices();
369         } catch (DataServiceException e) {
370             if (logger.isDebugEnabled()) {
371                 logger.warn("Error retrieving spot prices", e);
372             } else {
373                 logger.warn("Error retrieving spot prices: {}", e.getMessage());
374             }
375         } catch (InterruptedException e) {
376             Thread.currentThread().interrupt();
377         }
378
379         return cacheManager.getSpotPrices();
380     }
381
382     /**
383      * Return cached tariffs or try once to download them if not cached
384      * (usually if no items are linked).
385      *
386      * @return Map of future tariffs
387      */
388     public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
389         try {
390             downloadTariffs(datahubTariff);
391         } catch (DataServiceException e) {
392             if (logger.isDebugEnabled()) {
393                 logger.warn("Error retrieving tariffs", e);
394             } else {
395                 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
396             }
397         } catch (InterruptedException e) {
398             Thread.currentThread().interrupt();
399         }
400
401         return cacheManager.getTariffs(datahubTariff);
402     }
403
404     /**
405      * Return whether reduced electricity tax is set in configuration.
406      *
407      * @return true if reduced electricity tax applies
408      */
409     public boolean isReducedElectricityTax() {
410         return config.reducedElectricityTax;
411     }
412
413     private void reschedulePriceUpdateJob() {
414         ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
415         if (priceUpdateJob != null) {
416             // Do not interrupt ourselves.
417             priceUpdateJob.cancel(false);
418             this.priceUpdateFuture = null;
419         }
420
421         Instant now = Instant.now();
422         long millisUntilNextClockHour = Duration
423                 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
424         this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
425                 TimeUnit.MILLISECONDS);
426         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
427     }
428
429     private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
430         // Preserve state of previous retry policy when configuration is the same.
431         if (!retryPolicy.equals(this.retryPolicy)) {
432             this.retryPolicy = retryPolicy;
433         }
434
435         ScheduledFuture<?> refreshJob = this.refreshFuture;
436
437         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
438         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
439         this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
440                 TimeUnit.SECONDS);
441         logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
442         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
443         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
444                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
445
446         if (refreshJob != null) {
447             refreshJob.cancel(true);
448         }
449     }
450 }