]> git.basschouten.com Git - openhab-addons.git/blob
2cf1749d2879dbedb5e99276df1ee219657c253a
[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.BINDING_ID;
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.SortedMap;
24 import java.util.TreeMap;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
35 import org.openhab.binding.awattar.internal.AwattarPrice;
36 import org.openhab.binding.awattar.internal.dto.AwattarApiData;
37 import org.openhab.binding.awattar.internal.dto.Datum;
38 import org.openhab.core.i18n.TimeZoneProvider;
39 import org.openhab.core.thing.Bridge;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.ThingStatus;
42 import org.openhab.core.thing.ThingStatusDetail;
43 import org.openhab.core.thing.binding.BaseBridgeHandler;
44 import org.openhab.core.types.Command;
45 import org.openhab.core.types.RefreshType;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import com.google.gson.Gson;
50 import com.google.gson.JsonSyntaxException;
51
52 /**
53  * The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API.
54  *
55  * The API provides hourly prices for the current day and, starting from 14:00, hourly prices for the next day.
56  * Check the documentation at https://www.awattar.de/services/api
57  *
58  *
59  *
60  * @author Wolfgang Klimt - Initial contribution
61  */
62 @NonNullByDefault
63 public class AwattarBridgeHandler extends BaseBridgeHandler {
64     private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class);
65     private final HttpClient httpClient;
66     @Nullable
67     private ScheduledFuture<?> dataRefresher;
68
69     private static final String URLDE = "https://api.awattar.de/v1/marketdata";
70     private static final String URLAT = "https://api.awattar.at/v1/marketdata";
71     private String url;
72
73     // This cache stores price data for up to two days
74     @Nullable
75     private SortedMap<Long, AwattarPrice> priceMap;
76     private final int dataRefreshInterval = 60;
77     private double vatFactor = 0;
78     private long lastUpdated = 0;
79     private double basePrice = 0;
80     private long minTimestamp = 0;
81     private long maxTimestamp = 0;
82     private ZoneId zone;
83     private TimeZoneProvider timeZoneProvider;
84
85     public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
86         super(thing);
87         this.httpClient = httpClient;
88         url = URLDE;
89         this.timeZoneProvider = timeZoneProvider;
90         zone = timeZoneProvider.getTimeZone();
91     }
92
93     @Override
94     public void initialize() {
95         updateStatus(ThingStatus.UNKNOWN);
96         AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class);
97         vatFactor = 1 + (config.vatPercent / 100);
98         basePrice = config.basePrice;
99         zone = timeZoneProvider.getTimeZone();
100         switch (config.country) {
101             case "DE":
102                 url = URLDE;
103                 break;
104             case "AT":
105                 url = URLAT;
106                 break;
107             default:
108                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
109                         "@text/error.unsupported.country");
110                 return;
111         }
112
113         dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, dataRefreshInterval * 1000,
114                 TimeUnit.MILLISECONDS);
115     }
116
117     @Override
118     public void dispose() {
119         ScheduledFuture<?> localRefresher = dataRefresher;
120         if (localRefresher != null) {
121             localRefresher.cancel(true);
122         }
123         dataRefresher = null;
124         priceMap = null;
125         lastUpdated = 0;
126     }
127
128     public void refreshIfNeeded() {
129         if (needRefresh()) {
130             refresh();
131         }
132         updateStatus(ThingStatus.ONLINE);
133     }
134
135     private void getPrices() {
136         try {
137             // we start one day in the past to cover ranges that already started yesterday
138             ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1);
139             long start = zdt.toInstant().toEpochMilli();
140             // Starting from midnight yesterday we add three days so that the range covers the whole next day.
141             zdt = zdt.plusDays(3);
142             long end = zdt.toInstant().toEpochMilli();
143
144             StringBuilder request = new StringBuilder(url);
145             request.append("?start=").append(start).append("&end=").append(end);
146
147             logger.trace("aWATTar API request: = '{}'", request);
148             ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET)
149                     .timeout(10, TimeUnit.SECONDS).send();
150             int httpStatus = contentResponse.getStatus();
151             String content = contentResponse.getContentAsString();
152             logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content);
153
154             switch (httpStatus) {
155                 case OK_200:
156                     Gson gson = new Gson();
157                     SortedMap<Long, AwattarPrice> result = new TreeMap<>();
158                     minTimestamp = 0;
159                     maxTimestamp = 0;
160                     AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
161                     if (apiData != null) {
162                         for (Datum d : apiData.data) {
163                             result.put(d.startTimestamp,
164                                     new AwattarPrice(d.marketprice / 10.0, d.startTimestamp, d.endTimestamp, zone));
165                             updateMin(d.startTimestamp);
166                             updateMax(d.endTimestamp);
167                         }
168                         priceMap = result;
169                         updateStatus(ThingStatus.ONLINE);
170                         lastUpdated = Instant.now().toEpochMilli();
171                     } else {
172                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
173                                 "@text/error.invalid.data");
174                     }
175                     break;
176
177                 default:
178                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
179                             "@text/warn.awattar.statuscode");
180             }
181         } catch (JsonSyntaxException e) {
182             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.json");
183         } catch (InterruptedException e) {
184             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.interrupted");
185         } catch (ExecutionException e) {
186             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.execution");
187         } catch (TimeoutException e) {
188             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.timeout");
189         }
190     }
191
192     private boolean needRefresh() {
193         if (getThing().getStatus() != ThingStatus.ONLINE) {
194             return true;
195         }
196         SortedMap<Long, AwattarPrice> localMap = priceMap;
197         if (localMap == null) {
198             return true;
199         }
200         return localMap.lastKey() < Instant.now().toEpochMilli() + 9 * 3600 * 1000;
201     }
202
203     private void refresh() {
204         getPrices();
205     }
206
207     public double getVatFactor() {
208         return vatFactor;
209     }
210
211     public double getBasePrice() {
212         return basePrice;
213     }
214
215     public long getLastUpdated() {
216         return lastUpdated;
217     }
218
219     public ZoneId getTimeZone() {
220         return zone;
221     }
222
223     @Nullable
224     public synchronized SortedMap<Long, AwattarPrice> getPriceMap() {
225         if (priceMap == null) {
226             refresh();
227         }
228         return priceMap;
229     }
230
231     @Nullable
232     public AwattarPrice getPriceFor(long timestamp) {
233         SortedMap<Long, AwattarPrice> priceMap = getPriceMap();
234         if (priceMap == null) {
235             return null;
236         }
237         if (!containsPriceFor(timestamp)) {
238             return null;
239         }
240         for (AwattarPrice price : priceMap.values()) {
241             if (timestamp >= price.getStartTimestamp() && timestamp < price.getEndTimestamp()) {
242                 return price;
243             }
244         }
245         return null;
246     }
247
248     public boolean containsPriceFor(long timestamp) {
249         return minTimestamp <= timestamp && maxTimestamp >= timestamp;
250     }
251
252     @Override
253     public void handleCommand(ChannelUID channelUID, Command command) {
254         if (command instanceof RefreshType) {
255             refresh();
256         } else {
257             logger.debug("Binding {} only supports refresh command", BINDING_ID);
258         }
259     }
260
261     private void updateMin(long ts) {
262         minTimestamp = (minTimestamp == 0) ? ts : Math.min(minTimestamp, ts);
263     }
264
265     private void updateMax(long ts) {
266         maxTimestamp = (maxTimestamp == 0) ? ts : Math.max(ts, maxTimestamp);
267     }
268 }