]> git.basschouten.com Git - openhab-addons.git/blob
b62909d740f26819600a9c9a77fd28765121d922
[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.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_MARKET_NET;
16 import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_TOTAL_NET;
17
18 import java.time.Instant;
19 import java.time.ZoneId;
20 import java.time.ZonedDateTime;
21 import java.util.SortedSet;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.function.Function;
25
26 import javax.measure.Unit;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
32 import org.openhab.binding.awattar.internal.AwattarPrice;
33 import org.openhab.binding.awattar.internal.api.AwattarApi;
34 import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException;
35 import org.openhab.core.i18n.TimeZoneProvider;
36 import org.openhab.core.library.types.QuantityType;
37 import org.openhab.core.library.unit.CurrencyUnits;
38 import org.openhab.core.thing.Bridge;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.BaseBridgeHandler;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.RefreshType;
45 import org.openhab.core.types.TimeSeries;
46 import org.openhab.core.types.util.UnitUtils;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49
50 /**
51  * The {@link AwattarBridgeHandler} is responsible for retrieving data from the
52  * aWATTar API via the {@link AwattarApi}.
53  *
54  * The API provides hourly prices for the current day and, starting from 14:00,
55  * hourly prices for the next day.
56  * Check the documentation at <a href="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 static final int DATA_REFRESH_INTERVAL = 60;
65
66     private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class);
67     private final HttpClient httpClient;
68
69     private @Nullable ScheduledFuture<?> dataRefresher;
70     private Instant lastRefresh = Instant.EPOCH;
71
72     // This cache stores price data for up to two days
73     private @Nullable SortedSet<AwattarPrice> prices;
74     private ZoneId zone;
75
76     private @Nullable AwattarApi awattarApi;
77
78     public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
79         super(thing);
80         this.httpClient = httpClient;
81         zone = timeZoneProvider.getTimeZone();
82     }
83
84     @Override
85     public void initialize() {
86         updateStatus(ThingStatus.UNKNOWN);
87         AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class);
88
89         try {
90             awattarApi = new AwattarApi(httpClient, zone, config);
91
92             dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L,
93                     TimeUnit.MILLISECONDS);
94         } catch (IllegalArgumentException e) {
95             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.unsupported.country");
96         }
97     }
98
99     @Override
100     public void dispose() {
101         ScheduledFuture<?> localRefresher = dataRefresher;
102         if (localRefresher != null) {
103             localRefresher.cancel(true);
104         }
105         dataRefresher = null;
106         prices = null;
107     }
108
109     void refreshIfNeeded() {
110         if (needRefresh()) {
111             refresh();
112         }
113     }
114
115     /**
116      * Refresh the data from the API.
117      *
118      *
119      */
120     private void refresh() {
121         try {
122             // Method is private and only called when dataRefresher is initialized.
123             // DataRefresher is initialized after successful creation of AwattarApi.
124             prices = awattarApi.getData();
125
126             TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
127             TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
128
129             Unit<?> priceUnit = getPriceUnit();
130
131             for (AwattarPrice price : prices) {
132                 Instant timestamp = Instant.ofEpochMilli(price.timerange().start());
133
134                 netMarketSeries.add(timestamp, new QuantityType<>(price.netPrice() / 100.0, priceUnit));
135                 netTotalSeries.add(timestamp, new QuantityType<>(price.netTotal() / 100.0, priceUnit));
136             }
137
138             // update channels
139             sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries);
140             sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries);
141
142             updateStatus(ThingStatus.ONLINE);
143         } catch (AwattarApiException e) {
144             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
145         }
146     }
147
148     private Unit<?> getPriceUnit() {
149         Unit<?> priceUnit = UnitUtils.parseUnit("EUR/kWh");
150         if (priceUnit == null) {
151             priceUnit = CurrencyUnits.BASE_ENERGY_PRICE;
152             logger.info("Using {} instead of EUR/kWh, because it is not available", priceUnit);
153         }
154         return priceUnit;
155     }
156
157     private void createAndSendTimeSeries(String channelId, Function<AwattarPrice, Double> valueFunction) {
158         SortedSet<AwattarPrice> locPrices = getPrices();
159         Unit<?> priceUnit = getPriceUnit();
160         if (locPrices == null) {
161             return;
162         }
163         TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
164         locPrices.forEach(p -> {
165             timeSeries.add(Instant.ofEpochMilli(p.timerange().start()),
166                     new QuantityType<>(valueFunction.apply(p) / 100.0, priceUnit));
167         });
168         sendTimeSeries(channelId, timeSeries);
169     }
170
171     /**
172      * Check if the data needs to be refreshed.
173      *
174      * The data is refreshed if:
175      * - the thing is offline
176      * - the local cache is empty
177      * - the current time is after 15:00 and the last refresh was more than an hour
178      * ago
179      * - the current time is after 18:00 and the last refresh was more than an hour
180      * ago
181      * - the current time is after 21:00 and the last refresh was more than an hour
182      * ago
183      *
184      * @return true if the data needs to be refreshed
185      */
186     private boolean needRefresh() {
187         // if the thing is offline, we need to refresh
188         if (getThing().getStatus() != ThingStatus.ONLINE) {
189             return true;
190         }
191
192         // if the local cache is empty, we need to refresh
193         if (prices == null) {
194             return true;
195         }
196
197         // Note: all this magic is made to avoid refreshing the data too often, since
198         // the API is rate-limited
199         // to 100 requests per day.
200
201         // do not refresh before 15:00, since the prices for the next day are available
202         // only after 14:00
203         ZonedDateTime now = ZonedDateTime.now(zone);
204         if (now.getHour() < 15) {
205             return false;
206         }
207
208         // refresh then every 3 hours, if the last refresh was more than an hour ago
209         if (now.getHour() % 3 == 0 && lastRefresh.getEpochSecond() < now.minusHours(1).toEpochSecond()) {
210
211             // update the last refresh time
212             lastRefresh = Instant.now();
213
214             // return true to indicate an update is needed
215             return true;
216         }
217
218         return false;
219     }
220
221     public ZoneId getTimeZone() {
222         return zone;
223     }
224
225     @Nullable
226     public synchronized SortedSet<AwattarPrice> getPrices() {
227         if (prices == null) {
228             refresh();
229         }
230         return prices;
231     }
232
233     public @Nullable AwattarPrice getPriceFor(long timestamp) {
234         SortedSet<AwattarPrice> localPrices = getPrices();
235         if (localPrices == null || !containsPriceFor(timestamp)) {
236             return null;
237         }
238         return localPrices.stream().filter(e -> e.timerange().contains(timestamp)).findAny().orElse(null);
239     }
240
241     public boolean containsPriceFor(long timestamp) {
242         SortedSet<AwattarPrice> localPrices = getPrices();
243         return localPrices != null && localPrices.first().timerange().start() <= timestamp
244                 && localPrices.last().timerange().end() > timestamp;
245     }
246
247     @Override
248     public void handleCommand(ChannelUID channelUID, Command command) {
249         if (command instanceof RefreshType) {
250             switch (channelUID.getId()) {
251                 case CHANNEL_MARKET_NET -> createAndSendTimeSeries(CHANNEL_MARKET_NET, AwattarPrice::netPrice);
252                 case CHANNEL_TOTAL_NET -> createAndSendTimeSeries(CHANNEL_TOTAL_NET, AwattarPrice::netTotal);
253             }
254         }
255     }
256 }