2 * Copyright (c) 2010-2024 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.awattar.internal.handler;
15 import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_MARKET_NET;
16 import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_TOTAL_NET;
18 import java.time.Instant;
19 import java.time.ZoneId;
20 import java.time.ZonedDateTime;
21 import java.util.SortedSet;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.function.Function;
26 import javax.measure.Unit;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
32 import org.openhab.binding.awattar.internal.AwattarPrice;
33 import org.openhab.binding.awattar.internal.api.AwattarApi;
34 import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException;
35 import org.openhab.core.i18n.TimeZoneProvider;
36 import org.openhab.core.library.types.QuantityType;
37 import org.openhab.core.library.unit.CurrencyUnits;
38 import org.openhab.core.thing.Bridge;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.BaseBridgeHandler;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.RefreshType;
45 import org.openhab.core.types.TimeSeries;
46 import org.openhab.core.types.util.UnitUtils;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
51 * The {@link AwattarBridgeHandler} is responsible for retrieving data from the
52 * aWATTar API via the {@link AwattarApi}.
54 * The API provides hourly prices for the current day and, starting from 14:00,
55 * hourly prices for the next day.
56 * Check the documentation at <a href="https://www.awattar.de/services/api" />
60 * @author Wolfgang Klimt - Initial contribution
63 public class AwattarBridgeHandler extends BaseBridgeHandler {
64 private static final int DATA_REFRESH_INTERVAL = 60;
66 private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class);
67 private final HttpClient httpClient;
69 private @Nullable ScheduledFuture<?> dataRefresher;
70 private Instant lastRefresh = Instant.EPOCH;
72 // This cache stores price data for up to two days
73 private @Nullable SortedSet<AwattarPrice> prices;
76 private @Nullable AwattarApi awattarApi;
78 public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
80 this.httpClient = httpClient;
81 zone = timeZoneProvider.getTimeZone();
85 public void initialize() {
86 updateStatus(ThingStatus.UNKNOWN);
87 AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class);
90 awattarApi = new AwattarApi(httpClient, zone, config);
92 dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L,
93 TimeUnit.MILLISECONDS);
94 } catch (IllegalArgumentException e) {
95 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.unsupported.country");
100 public void dispose() {
101 ScheduledFuture<?> localRefresher = dataRefresher;
102 if (localRefresher != null) {
103 localRefresher.cancel(true);
105 dataRefresher = null;
109 void refreshIfNeeded() {
116 * Refresh the data from the API.
120 private void refresh() {
122 // Method is private and only called when dataRefresher is initialized.
123 // DataRefresher is initialized after successful creation of AwattarApi.
124 prices = awattarApi.getData();
126 TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
127 TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
129 Unit<?> priceUnit = getPriceUnit();
131 for (AwattarPrice price : prices) {
132 Instant timestamp = Instant.ofEpochMilli(price.timerange().start());
134 netMarketSeries.add(timestamp, new QuantityType<>(price.netPrice() / 100.0, priceUnit));
135 netTotalSeries.add(timestamp, new QuantityType<>(price.netTotal() / 100.0, priceUnit));
139 sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries);
140 sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries);
142 updateStatus(ThingStatus.ONLINE);
143 } catch (AwattarApiException e) {
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
148 private Unit<?> getPriceUnit() {
149 Unit<?> priceUnit = UnitUtils.parseUnit("EUR/kWh");
150 if (priceUnit == null) {
151 priceUnit = CurrencyUnits.BASE_ENERGY_PRICE;
152 logger.info("Using {} instead of EUR/kWh, because it is not available", priceUnit);
157 private void createAndSendTimeSeries(String channelId, Function<AwattarPrice, Double> valueFunction) {
158 SortedSet<AwattarPrice> locPrices = getPrices();
159 Unit<?> priceUnit = getPriceUnit();
160 if (locPrices == null) {
163 TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
164 locPrices.forEach(p -> {
165 timeSeries.add(Instant.ofEpochMilli(p.timerange().start()),
166 new QuantityType<>(valueFunction.apply(p) / 100.0, priceUnit));
168 sendTimeSeries(channelId, timeSeries);
172 * Check if the data needs to be refreshed.
174 * The data is refreshed if:
175 * - the thing is offline
176 * - the local cache is empty
177 * - the current time is after 15:00 and the last refresh was more than an hour
179 * - the current time is after 18:00 and the last refresh was more than an hour
181 * - the current time is after 21:00 and the last refresh was more than an hour
184 * @return true if the data needs to be refreshed
186 private boolean needRefresh() {
187 // if the thing is offline, we need to refresh
188 if (getThing().getStatus() != ThingStatus.ONLINE) {
192 // if the local cache is empty, we need to refresh
193 if (prices == null) {
197 // Note: all this magic is made to avoid refreshing the data too often, since
198 // the API is rate-limited
199 // to 100 requests per day.
201 // do not refresh before 15:00, since the prices for the next day are available
203 ZonedDateTime now = ZonedDateTime.now(zone);
204 if (now.getHour() < 15) {
208 // refresh then every 3 hours, if the last refresh was more than an hour ago
209 if (now.getHour() % 3 == 0 && lastRefresh.getEpochSecond() < now.minusHours(1).toEpochSecond()) {
211 // update the last refresh time
212 lastRefresh = Instant.now();
214 // return true to indicate an update is needed
221 public ZoneId getTimeZone() {
226 public synchronized SortedSet<AwattarPrice> getPrices() {
227 if (prices == null) {
233 public @Nullable AwattarPrice getPriceFor(long timestamp) {
234 SortedSet<AwattarPrice> localPrices = getPrices();
235 if (localPrices == null || !containsPriceFor(timestamp)) {
238 return localPrices.stream().filter(e -> e.timerange().contains(timestamp)).findAny().orElse(null);
241 public boolean containsPriceFor(long timestamp) {
242 SortedSet<AwattarPrice> localPrices = getPrices();
243 return localPrices != null && localPrices.first().timerange().start() <= timestamp
244 && localPrices.last().timerange().end() > timestamp;
248 public void handleCommand(ChannelUID channelUID, Command command) {
249 if (command instanceof RefreshType) {
250 switch (channelUID.getId()) {
251 case CHANNEL_MARKET_NET -> createAndSendTimeSeries(CHANNEL_MARKET_NET, AwattarPrice::netPrice);
252 case CHANNEL_TOTAL_NET -> createAndSendTimeSeries(CHANNEL_TOTAL_NET, AwattarPrice::netTotal);