]> git.basschouten.com Git - openhab-addons.git/blob
8bd65306efb92b56a647a219a583ef1d76a2790c
[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.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
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;
49
50 import com.google.gson.Gson;
51 import com.google.gson.JsonSyntaxException;
52
53 /**
54  * The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API.
55  *
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" />
58  *
59  *
60  *
61  * @author Wolfgang Klimt - Initial contribution
62  */
63 @NonNullByDefault
64 public class AwattarBridgeHandler extends BaseBridgeHandler {
65     private static final int DATA_REFRESH_INTERVAL = 60;
66
67     private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class);
68     private final HttpClient httpClient;
69     private @Nullable ScheduledFuture<?> dataRefresher;
70
71     private static final String URLDE = "https://api.awattar.de/v1/marketdata";
72     private static final String URLAT = "https://api.awattar.at/v1/marketdata";
73     private String url;
74
75     // This cache stores price data for up to two days
76     private @Nullable SortedSet<AwattarPrice> prices;
77     private double vatFactor = 0;
78     private double basePrice = 0;
79     private ZoneId zone;
80     private final TimeZoneProvider timeZoneProvider;
81
82     public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
83         super(thing);
84         this.httpClient = httpClient;
85         url = URLDE;
86         this.timeZoneProvider = timeZoneProvider;
87         zone = timeZoneProvider.getTimeZone();
88     }
89
90     @Override
91     public void initialize() {
92         updateStatus(ThingStatus.UNKNOWN);
93         AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class);
94         vatFactor = 1 + (config.vatPercent / 100);
95         basePrice = config.basePrice;
96         zone = timeZoneProvider.getTimeZone();
97         switch (config.country) {
98             case "DE":
99                 url = URLDE;
100                 break;
101             case "AT":
102                 url = URLAT;
103                 break;
104             default:
105                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
106                         "@text/error.unsupported.country");
107                 return;
108         }
109
110         dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000,
111                 TimeUnit.MILLISECONDS);
112     }
113
114     @Override
115     public void dispose() {
116         ScheduledFuture<?> localRefresher = dataRefresher;
117         if (localRefresher != null) {
118             localRefresher.cancel(true);
119         }
120         dataRefresher = null;
121         prices = null;
122     }
123
124     void refreshIfNeeded() {
125         if (needRefresh()) {
126             refresh();
127         }
128         updateStatus(ThingStatus.ONLINE);
129     }
130
131     private void refresh() {
132         try {
133             // we start one day in the past to cover ranges that already started yesterday
134             ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1);
135             long start = zdt.toInstant().toEpochMilli();
136             // Starting from midnight yesterday we add three days so that the range covers the whole next day.
137             zdt = zdt.plusDays(3);
138             long end = zdt.toInstant().toEpochMilli();
139
140             StringBuilder request = new StringBuilder(url);
141             request.append("?start=").append(start).append("&end=").append(end);
142
143             logger.trace("aWATTar API request: = '{}'", request);
144             ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET)
145                     .timeout(10, TimeUnit.SECONDS).send();
146             int httpStatus = contentResponse.getStatus();
147             String content = contentResponse.getContentAsString();
148             logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content);
149
150             if (httpStatus == OK_200) {
151                 Gson gson = new Gson();
152                 SortedSet<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));
153                 AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
154                 if (apiData != null) {
155                     for (Datum d : apiData.data) {
156                         double netPrice = d.marketprice / 10.0;
157                         TimeRange timerange = new TimeRange(d.startTimestamp, d.endTimestamp);
158                         result.add(new AwattarPrice(netPrice, netPrice * vatFactor, netPrice + basePrice,
159                                 (netPrice + basePrice) * vatFactor, timerange));
160                     }
161                     prices = result;
162                     updateStatus(ThingStatus.ONLINE);
163                 } else {
164                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
165                             "@text/error.invalid.data");
166                 }
167             } else {
168                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
169                         "@text/warn.awattar.statuscode");
170             }
171         } catch (JsonSyntaxException e) {
172             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.json");
173         } catch (InterruptedException e) {
174             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.interrupted");
175         } catch (ExecutionException e) {
176             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.execution");
177         } catch (TimeoutException e) {
178             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.timeout");
179         }
180     }
181
182     private boolean needRefresh() {
183         if (getThing().getStatus() != ThingStatus.ONLINE) {
184             return true;
185         }
186         SortedSet<AwattarPrice> localPrices = prices;
187         return localPrices == null
188                 || localPrices.last().timerange().start() < Instant.now().toEpochMilli() + 9 * 3600 * 1000;
189     }
190
191     public ZoneId getTimeZone() {
192         return zone;
193     }
194
195     @Nullable
196     public synchronized SortedSet<AwattarPrice> getPrices() {
197         if (prices == null) {
198             refresh();
199         }
200         return prices;
201     }
202
203     public @Nullable AwattarPrice getPriceFor(long timestamp) {
204         SortedSet<AwattarPrice> localPrices = getPrices();
205         if (localPrices == null || !containsPriceFor(timestamp)) {
206             return null;
207         }
208         return localPrices.stream().filter(e -> e.timerange().contains(timestamp)).findAny().orElse(null);
209     }
210
211     public boolean containsPriceFor(long timestamp) {
212         SortedSet<AwattarPrice> localPrices = getPrices();
213         return localPrices != null && localPrices.first().timerange().start() <= timestamp
214                 && localPrices.last().timerange().end() > timestamp;
215     }
216
217     @Override
218     public void handleCommand(ChannelUID channelUID, Command command) {
219         if (command instanceof RefreshType) {
220             refresh();
221         } else {
222             logger.debug("Binding {} only supports refresh command", BINDING_ID);
223         }
224     }
225 }