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.BINDING_ID;
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;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.eclipse.jetty.client.api.ContentResponse;
35 import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
36 import org.openhab.binding.awattar.internal.AwattarPrice;
37 import org.openhab.binding.awattar.internal.dto.AwattarApiData;
38 import org.openhab.binding.awattar.internal.dto.Datum;
39 import org.openhab.core.i18n.TimeZoneProvider;
40 import org.openhab.core.thing.Bridge;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.binding.BaseBridgeHandler;
45 import org.openhab.core.types.Command;
46 import org.openhab.core.types.RefreshType;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
50 import com.google.gson.Gson;
51 import com.google.gson.JsonSyntaxException;
54 * The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API.
56 * The API provides hourly prices for the current day and, starting from 14:00, hourly prices for the next day.
57 * Check the documentation at <a href="https://www.awattar.de/services/api" />
61 * @author Wolfgang Klimt - Initial contribution
64 public class AwattarBridgeHandler extends BaseBridgeHandler {
65 private static final int DATA_REFRESH_INTERVAL = 60;
67 private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class);
68 private final HttpClient httpClient;
69 private @Nullable ScheduledFuture<?> dataRefresher;
70 private Instant lastRefresh = Instant.EPOCH;
72 private static final String URLDE = "https://api.awattar.de/v1/marketdata";
73 private static final String URLAT = "https://api.awattar.at/v1/marketdata";
76 // This cache stores price data for up to two days
77 private @Nullable SortedSet<AwattarPrice> prices;
78 private double vatFactor = 0;
79 private double basePrice = 0;
81 private final TimeZoneProvider timeZoneProvider;
83 public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
85 this.httpClient = httpClient;
87 this.timeZoneProvider = timeZoneProvider;
88 zone = timeZoneProvider.getTimeZone();
92 public void initialize() {
93 updateStatus(ThingStatus.UNKNOWN);
94 AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class);
95 vatFactor = 1 + (config.vatPercent / 100);
96 basePrice = config.basePrice;
97 zone = timeZoneProvider.getTimeZone();
98 switch (config.country) {
106 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
107 "@text/error.unsupported.country");
111 dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000,
112 TimeUnit.MILLISECONDS);
116 public void dispose() {
117 ScheduledFuture<?> localRefresher = dataRefresher;
118 if (localRefresher != null) {
119 localRefresher.cancel(true);
121 dataRefresher = null;
125 void refreshIfNeeded() {
129 updateStatus(ThingStatus.ONLINE);
132 private void refresh() {
134 // we start one day in the past to cover ranges that already started yesterday
135 ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1);
136 long start = zdt.toInstant().toEpochMilli();
137 // Starting from midnight yesterday we add three days so that the range covers the whole next day.
138 zdt = zdt.plusDays(3);
139 long end = zdt.toInstant().toEpochMilli();
141 StringBuilder request = new StringBuilder(url);
142 request.append("?start=").append(start).append("&end=").append(end);
144 logger.trace("aWATTar API request: = '{}'", request);
145 ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET)
146 .timeout(10, TimeUnit.SECONDS).send();
147 int httpStatus = contentResponse.getStatus();
148 String content = contentResponse.getContentAsString();
149 logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content);
151 if (httpStatus == OK_200) {
152 Gson gson = new Gson();
153 SortedSet<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));
154 AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
155 if (apiData != null) {
156 for (Datum d : apiData.data) {
157 double netPrice = d.marketprice / 10.0;
158 TimeRange timerange = new TimeRange(d.startTimestamp, d.endTimestamp);
159 result.add(new AwattarPrice(netPrice, netPrice * vatFactor, netPrice + basePrice,
160 (netPrice + basePrice) * vatFactor, timerange));
163 updateStatus(ThingStatus.ONLINE);
165 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
166 "@text/error.invalid.data");
169 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
170 "@text/warn.awattar.statuscode");
172 } catch (JsonSyntaxException e) {
173 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.json");
174 } catch (InterruptedException e) {
175 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.interrupted");
176 } catch (ExecutionException e) {
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.execution");
178 } catch (TimeoutException e) {
179 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.timeout");
184 * Check if the data needs to be refreshed.
186 * The data is refreshed if:
187 * - the thing is offline
188 * - the local cache is empty
189 * - the current time is after 15:00 and the last refresh was more than an hour ago
190 * - the current time is after 18:00 and the last refresh was more than an hour ago
191 * - the current time is after 21:00 and the last refresh was more than an hour ago
193 * @return true if the data needs to be refreshed
195 private boolean needRefresh() {
196 // if the thing is offline, we need to refresh
197 if (getThing().getStatus() != ThingStatus.ONLINE) {
201 // if the local cache is empty, we need to refresh
202 if (prices != null) {
206 // Note: all this magic is made to avoid refreshing the data too often, since the API is rate-limited
207 // to 100 requests per day.
209 // do not refresh before 15:00, since the prices for the next day are available only after 14:00
210 ZonedDateTime now = ZonedDateTime.now(zone);
211 if (now.getHour() < 15) {
215 // refresh then every 3 hours, if the last refresh was more than an hour ago
216 if (now.getHour() % 3 == 0 && lastRefresh.getEpochSecond() < now.minusHours(1).toEpochSecond()) {
218 // update the last refresh time
219 lastRefresh = Instant.now();
221 // return true to indicate an update is needed
228 public ZoneId getTimeZone() {
233 public synchronized SortedSet<AwattarPrice> getPrices() {
234 if (prices == null) {
240 public @Nullable AwattarPrice getPriceFor(long timestamp) {
241 SortedSet<AwattarPrice> localPrices = getPrices();
242 if (localPrices == null || !containsPriceFor(timestamp)) {
245 return localPrices.stream().filter(e -> e.timerange().contains(timestamp)).findAny().orElse(null);
248 public boolean containsPriceFor(long timestamp) {
249 SortedSet<AwattarPrice> localPrices = getPrices();
250 return localPrices != null && localPrices.first().timerange().start() <= timestamp
251 && localPrices.last().timerange().end() > timestamp;
255 public void handleCommand(ChannelUID channelUID, Command command) {
256 if (command instanceof RefreshType) {
259 logger.debug("Binding {} only supports refresh command", BINDING_ID);