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.eclipse.jetty.http.HttpMethod.GET;
16 import static org.eclipse.jetty.http.HttpStatus.OK_200;
17 import static org.openhab.binding.awattar.internal.AwattarBindingConstants.*;
19 import java.time.Instant;
20 import java.time.LocalDate;
21 import java.time.ZoneId;
22 import java.time.ZonedDateTime;
23 import java.util.Comparator;
24 import java.util.SortedSet;
25 import java.util.TreeSet;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.TimeoutException;
30 import java.util.function.Function;
32 import javax.measure.Unit;
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.client.api.ContentResponse;
38 import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
39 import org.openhab.binding.awattar.internal.AwattarPrice;
40 import org.openhab.binding.awattar.internal.dto.AwattarApiData;
41 import org.openhab.binding.awattar.internal.dto.Datum;
42 import org.openhab.core.i18n.TimeZoneProvider;
43 import org.openhab.core.library.types.QuantityType;
44 import org.openhab.core.library.unit.CurrencyUnits;
45 import org.openhab.core.thing.Bridge;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.binding.BaseBridgeHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.TimeSeries;
53 import org.openhab.core.types.util.UnitUtils;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
57 import com.google.gson.Gson;
58 import com.google.gson.JsonSyntaxException;
61 * The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API.
63 * The API provides hourly prices for the current day and, starting from 14:00, hourly prices for the next day.
64 * Check the documentation at <a href="https://www.awattar.de/services/api" />
68 * @author Wolfgang Klimt - Initial contribution
71 public class AwattarBridgeHandler extends BaseBridgeHandler {
72 private static final int DATA_REFRESH_INTERVAL = 60;
74 private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class);
75 private final HttpClient httpClient;
76 private @Nullable ScheduledFuture<?> dataRefresher;
77 private Instant lastRefresh = Instant.EPOCH;
79 private static final String URLDE = "https://api.awattar.de/v1/marketdata";
80 private static final String URLAT = "https://api.awattar.at/v1/marketdata";
83 // This cache stores price data for up to two days
84 private @Nullable SortedSet<AwattarPrice> prices;
85 private double vatFactor = 0;
86 private double basePrice = 0;
88 private final TimeZoneProvider timeZoneProvider;
90 public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
92 this.httpClient = httpClient;
94 this.timeZoneProvider = timeZoneProvider;
95 zone = timeZoneProvider.getTimeZone();
99 public void initialize() {
100 updateStatus(ThingStatus.UNKNOWN);
101 AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class);
102 vatFactor = 1 + (config.vatPercent / 100);
103 basePrice = config.basePrice;
104 zone = timeZoneProvider.getTimeZone();
105 switch (config.country) {
113 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
114 "@text/error.unsupported.country");
118 dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L,
119 TimeUnit.MILLISECONDS);
123 public void dispose() {
124 ScheduledFuture<?> localRefresher = dataRefresher;
125 if (localRefresher != null) {
126 localRefresher.cancel(true);
128 dataRefresher = null;
132 void refreshIfNeeded() {
138 private void refresh() {
140 // we start one day in the past to cover ranges that already started yesterday
141 ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1);
142 long start = zdt.toInstant().toEpochMilli();
143 // Starting from midnight yesterday we add three days so that the range covers the whole next day.
144 zdt = zdt.plusDays(3);
145 long end = zdt.toInstant().toEpochMilli();
147 StringBuilder request = new StringBuilder(url);
148 request.append("?start=").append(start).append("&end=").append(end);
150 logger.trace("aWATTar API request: = '{}'", request);
151 ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET)
152 .timeout(10, TimeUnit.SECONDS).send();
153 int httpStatus = contentResponse.getStatus();
154 String content = contentResponse.getContentAsString();
155 logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content);
157 if (httpStatus == OK_200) {
158 Gson gson = new Gson();
159 SortedSet<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));
160 AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
161 if (apiData != null) {
162 TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
163 TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
165 Unit<?> priceUnit = getPriceUnit();
167 for (Datum d : apiData.data) {
168 double netMarket = d.marketprice / 10.0;
169 double grossMarket = netMarket * vatFactor;
170 double netTotal = netMarket + basePrice;
171 double grossTotal = netTotal * vatFactor;
172 Instant timestamp = Instant.ofEpochMilli(d.startTimestamp);
174 netMarketSeries.add(timestamp, new QuantityType<>(netMarket / 100.0, priceUnit));
175 netTotalSeries.add(timestamp, new QuantityType<>(netTotal / 100.0, priceUnit));
177 result.add(new AwattarPrice(netMarket, grossMarket, netTotal, grossTotal,
178 new TimeRange(d.startTimestamp, d.endTimestamp)));
183 sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries);
184 sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries);
186 updateStatus(ThingStatus.ONLINE);
188 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
189 "@text/error.invalid.data");
192 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
193 "@text/warn.awattar.statuscode");
195 } catch (JsonSyntaxException e) {
196 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.json");
197 } catch (InterruptedException e) {
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.interrupted");
199 } catch (ExecutionException e) {
200 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.execution");
201 } catch (TimeoutException e) {
202 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.timeout");
206 private Unit<?> getPriceUnit() {
207 Unit<?> priceUnit = UnitUtils.parseUnit("EUR/kWh");
208 if (priceUnit == null) {
209 priceUnit = CurrencyUnits.BASE_ENERGY_PRICE;
210 logger.info("Using {} instead of EUR/kWh, because it is not available", priceUnit);
215 private void createAndSendTimeSeries(String channelId, Function<AwattarPrice, Double> valueFunction) {
216 SortedSet<AwattarPrice> prices = getPrices();
217 Unit<?> priceUnit = getPriceUnit();
218 if (prices == null) {
221 TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
222 prices.forEach(p -> {
223 timeSeries.add(Instant.ofEpochMilli(p.timerange().start()),
224 new QuantityType<>(valueFunction.apply(p) / 100.0, priceUnit));
226 sendTimeSeries(channelId, timeSeries);
230 * Check if the data needs to be refreshed.
232 * The data is refreshed if:
233 * - the thing is offline
234 * - the local cache is empty
235 * - the current time is after 15:00 and the last refresh was more than an hour ago
236 * - the current time is after 18:00 and the last refresh was more than an hour ago
237 * - the current time is after 21:00 and the last refresh was more than an hour ago
239 * @return true if the data needs to be refreshed
241 private boolean needRefresh() {
242 // if the thing is offline, we need to refresh
243 if (getThing().getStatus() != ThingStatus.ONLINE) {
247 // if the local cache is empty, we need to refresh
248 if (prices == null) {
252 // Note: all this magic is made to avoid refreshing the data too often, since the API is rate-limited
253 // to 100 requests per day.
255 // do not refresh before 15:00, since the prices for the next day are available only after 14:00
256 ZonedDateTime now = ZonedDateTime.now(zone);
257 if (now.getHour() < 15) {
261 // refresh then every 3 hours, if the last refresh was more than an hour ago
262 if (now.getHour() % 3 == 0 && lastRefresh.getEpochSecond() < now.minusHours(1).toEpochSecond()) {
264 // update the last refresh time
265 lastRefresh = Instant.now();
267 // return true to indicate an update is needed
274 public ZoneId getTimeZone() {
279 public synchronized SortedSet<AwattarPrice> getPrices() {
280 if (prices == null) {
286 public @Nullable AwattarPrice getPriceFor(long timestamp) {
287 SortedSet<AwattarPrice> localPrices = getPrices();
288 if (localPrices == null || !containsPriceFor(timestamp)) {
291 return localPrices.stream().filter(e -> e.timerange().contains(timestamp)).findAny().orElse(null);
294 public boolean containsPriceFor(long timestamp) {
295 SortedSet<AwattarPrice> localPrices = getPrices();
296 return localPrices != null && localPrices.first().timerange().start() <= timestamp
297 && localPrices.last().timerange().end() > timestamp;
301 public void handleCommand(ChannelUID channelUID, Command command) {
302 if (command instanceof RefreshType) {
303 switch (channelUID.getId()) {
304 case CHANNEL_MARKET_NET -> createAndSendTimeSeries(CHANNEL_MARKET_NET, AwattarPrice::netPrice);
305 case CHANNEL_TOTAL_NET -> createAndSendTimeSeries(CHANNEL_TOTAL_NET, AwattarPrice::netTotal);