]> git.basschouten.com Git - openhab-addons.git/blob
f33ce3a34dea45448201efc1de669c74d56fbccb
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.awattar.internal.handler;
14
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.*;
18
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;
31
32 import javax.measure.Unit;
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.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;
56
57 import com.google.gson.Gson;
58 import com.google.gson.JsonSyntaxException;
59
60 /**
61  * The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API.
62  *
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" />
65  *
66  *
67  *
68  * @author Wolfgang Klimt - Initial contribution
69  */
70 @NonNullByDefault
71 public class AwattarBridgeHandler extends BaseBridgeHandler {
72     private static final int DATA_REFRESH_INTERVAL = 60;
73
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;
78
79     private static final String URLDE = "https://api.awattar.de/v1/marketdata";
80     private static final String URLAT = "https://api.awattar.at/v1/marketdata";
81     private String url;
82
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;
87     private ZoneId zone;
88     private final TimeZoneProvider timeZoneProvider;
89
90     public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
91         super(thing);
92         this.httpClient = httpClient;
93         url = URLDE;
94         this.timeZoneProvider = timeZoneProvider;
95         zone = timeZoneProvider.getTimeZone();
96     }
97
98     @Override
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) {
106             case "DE":
107                 url = URLDE;
108                 break;
109             case "AT":
110                 url = URLAT;
111                 break;
112             default:
113                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
114                         "@text/error.unsupported.country");
115                 return;
116         }
117
118         dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L,
119                 TimeUnit.MILLISECONDS);
120     }
121
122     @Override
123     public void dispose() {
124         ScheduledFuture<?> localRefresher = dataRefresher;
125         if (localRefresher != null) {
126             localRefresher.cancel(true);
127         }
128         dataRefresher = null;
129         prices = null;
130     }
131
132     void refreshIfNeeded() {
133         if (needRefresh()) {
134             refresh();
135         }
136     }
137
138     private void refresh() {
139         try {
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();
146
147             StringBuilder request = new StringBuilder(url);
148             request.append("?start=").append(start).append("&end=").append(end);
149
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);
156
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);
164
165                     Unit<?> priceUnit = getPriceUnit();
166
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);
173
174                         netMarketSeries.add(timestamp, new QuantityType<>(netMarket / 100.0, priceUnit));
175                         netTotalSeries.add(timestamp, new QuantityType<>(netTotal / 100.0, priceUnit));
176
177                         result.add(new AwattarPrice(netMarket, grossMarket, netTotal, grossTotal,
178                                 new TimeRange(d.startTimestamp, d.endTimestamp)));
179                     }
180                     prices = result;
181
182                     // update channels
183                     sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries);
184                     sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries);
185
186                     updateStatus(ThingStatus.ONLINE);
187                 } else {
188                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
189                             "@text/error.invalid.data");
190                 }
191             } else {
192                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
193                         "@text/warn.awattar.statuscode");
194             }
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");
203         }
204     }
205
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);
211         }
212         return priceUnit;
213     }
214
215     private void createAndSendTimeSeries(String channelId, Function<AwattarPrice, Double> valueFunction) {
216         SortedSet<AwattarPrice> prices = getPrices();
217         Unit<?> priceUnit = getPriceUnit();
218         if (prices == null) {
219             return;
220         }
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));
225         });
226         sendTimeSeries(channelId, timeSeries);
227     }
228
229     /**
230      * Check if the data needs to be refreshed.
231      *
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
238      *
239      * @return true if the data needs to be refreshed
240      */
241     private boolean needRefresh() {
242         // if the thing is offline, we need to refresh
243         if (getThing().getStatus() != ThingStatus.ONLINE) {
244             return true;
245         }
246
247         // if the local cache is empty, we need to refresh
248         if (prices == null) {
249             return true;
250         }
251
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.
254
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) {
258             return false;
259         }
260
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()) {
263
264             // update the last refresh time
265             lastRefresh = Instant.now();
266
267             // return true to indicate an update is needed
268             return true;
269         }
270
271         return false;
272     }
273
274     public ZoneId getTimeZone() {
275         return zone;
276     }
277
278     @Nullable
279     public synchronized SortedSet<AwattarPrice> getPrices() {
280         if (prices == null) {
281             refresh();
282         }
283         return prices;
284     }
285
286     public @Nullable AwattarPrice getPriceFor(long timestamp) {
287         SortedSet<AwattarPrice> localPrices = getPrices();
288         if (localPrices == null || !containsPriceFor(timestamp)) {
289             return null;
290         }
291         return localPrices.stream().filter(e -> e.timerange().contains(timestamp)).findAny().orElse(null);
292     }
293
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;
298     }
299
300     @Override
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);
306             }
307         }
308     }
309 }