2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.energidataservice.internal.handler;
15 import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
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;
29 import java.util.Map.Entry;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
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;
72 import com.google.gson.Gson;
75 * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
76 * sent to one of the channels.
78 * @author Jacob Laursen - Initial contribution
81 public class EnergiDataServiceHandler extends BaseThingHandler {
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();
89 private EnergiDataServiceConfiguration config;
90 private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
91 private @Nullable ScheduledFuture<?> refreshFuture;
92 private @Nullable ScheduledFuture<?> priceUpdateFuture;
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) {
99 public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
101 this.timeZoneProvider = timeZoneProvider;
102 this.apiController = new ApiController(httpClient, timeZoneProvider);
103 this.cacheManager = new CacheManager();
105 // Default configuration
106 this.config = new EnergiDataServiceConfiguration();
110 public void handleCommand(ChannelUID channelUID, Command command) {
111 if (!(command instanceof RefreshType)) {
115 if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
116 refreshElectricityPrices();
121 public void initialize() {
122 config = getConfigAs(EnergiDataServiceConfiguration.class);
124 if (config.priceArea.isBlank()) {
125 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
126 "@text/offline.conf-error.no-price-area");
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");
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");
142 updateStatus(ThingStatus.UNKNOWN);
144 refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
148 public void dispose() {
149 ScheduledFuture<?> refreshFuture = this.refreshFuture;
150 if (refreshFuture != null) {
151 refreshFuture.cancel(true);
152 this.refreshFuture = null;
154 ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
155 if (priceUpdateFuture != null) {
156 priceUpdateFuture.cancel(true);
157 this.priceUpdateFuture = null;
160 cacheManager.clear();
164 public Collection<Class<? extends ThingHandlerService>> getServices() {
165 return Set.of(EnergiDataServiceActions.class);
168 private void refreshElectricityPrices() {
169 RetryStrategy retryPolicy;
171 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
172 downloadSpotPrices();
175 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
176 if (isLinked(datahubTariff.getChannelId()) || isLinked(CHANNEL_HOURLY_PRICES)) {
177 downloadTariffs(datahubTariff);
181 updateStatus(ThingStatus.ONLINE);
184 if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
185 if (cacheManager.getNumberOfFutureSpotPrices() < 13) {
186 retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing(DAILY_REFRESH_TIME_CET,
189 retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
192 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
194 } catch (DataServiceException e) {
195 if (e.getHttpStatus() != 0) {
196 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
197 HttpStatus.getCode(e.getHttpStatus()).getMessage());
199 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
201 if (e.getCause() != null) {
202 logger.debug("Error retrieving prices", e);
204 retryPolicy = RetryPolicyFactory.fromThrowable(e);
205 } catch (InterruptedException e) {
206 logger.debug("Refresh job interrupted");
207 Thread.currentThread().interrupt();
211 rescheduleRefreshJob(retryPolicy);
214 private void downloadSpotPrices() throws InterruptedException, DataServiceException {
215 if (cacheManager.areSpotPricesFullyCached()) {
216 logger.debug("Cached spot prices still valid, skipping download.");
219 DateQueryParameter start;
220 if (cacheManager.areHistoricSpotPricesCached()) {
221 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
223 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
224 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
226 Map<String, String> properties = editProperties();
227 ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
229 cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
230 updateProperties(properties);
233 private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
234 GlobalLocationNumber globalLocationNumber = switch (datahubTariff) {
235 case NET_TARIFF -> config.getGridCompanyGLN();
236 default -> config.getEnerginetGLN();
238 if (globalLocationNumber.isEmpty()) {
241 if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
242 logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
243 cacheManager.updateTariffs(datahubTariff);
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();
252 cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
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);
266 private DatahubTariffFilter getNetTariffFilter() {
267 Channel channel = getThing().getChannel(CHANNEL_NET_TARIFF);
268 if (channel == null) {
269 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
272 DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
273 .as(DatahubPriceConfiguration.class);
275 if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
276 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
279 DateQueryParameter start = datahubPriceConfiguration.getStart();
281 logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
282 datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
283 return DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN);
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);
293 // Only override start date in pre-configured filter.
294 filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getNetTariffByGLN(config.gridCompanyGLN),
298 return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
299 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
302 private void updatePrices() {
303 cacheManager.cleanup();
305 updateCurrentSpotPrice();
306 Arrays.stream(DatahubTariff.values())
307 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
308 updateHourlyPrices();
310 reschedulePriceUpdateJob();
313 private void updateCurrentSpotPrice() {
314 if (!isLinked(CHANNEL_SPOT_PRICE)) {
317 BigDecimal spotPrice = cacheManager.getSpotPrice();
318 updateState(CHANNEL_SPOT_PRICE, spotPrice != null ? new DecimalType(spotPrice) : UnDefType.UNDEF);
321 private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
322 if (!isLinked(channelId)) {
325 updateState(channelId, tariff != null ? new DecimalType(tariff) : UnDefType.UNDEF);
328 private void updateHourlyPrices() {
329 if (!isLinked(CHANNEL_HOURLY_PRICES)) {
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();
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);
348 updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
352 * Get the configured {@link Currency} for spot prices.
354 * @return Spot price currency
356 public Currency getCurrency() {
357 return config.getCurrency();
361 * Get cached spot prices or try once to download them if not cached
362 * (usually if no items are linked).
364 * @return Map of future spot prices
366 public Map<Instant, BigDecimal> getSpotPrices() {
368 downloadSpotPrices();
369 } catch (DataServiceException e) {
370 if (logger.isDebugEnabled()) {
371 logger.warn("Error retrieving spot prices", e);
373 logger.warn("Error retrieving spot prices: {}", e.getMessage());
375 } catch (InterruptedException e) {
376 Thread.currentThread().interrupt();
379 return cacheManager.getSpotPrices();
383 * Return cached tariffs or try once to download them if not cached
384 * (usually if no items are linked).
386 * @return Map of future tariffs
388 public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
390 downloadTariffs(datahubTariff);
391 } catch (DataServiceException e) {
392 if (logger.isDebugEnabled()) {
393 logger.warn("Error retrieving tariffs", e);
395 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
397 } catch (InterruptedException e) {
398 Thread.currentThread().interrupt();
401 return cacheManager.getTariffs(datahubTariff);
405 * Return whether reduced electricity tax is set in configuration.
407 * @return true if reduced electricity tax applies
409 public boolean isReducedElectricityTax() {
410 return config.reducedElectricityTax;
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;
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);
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;
435 ScheduledFuture<?> refreshJob = this.refreshFuture;
437 long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
438 Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
439 this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
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));
446 if (refreshJob != null) {
447 refreshJob.cancel(true);