2 * Copyright (c) 2010-2023 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.ecowatt.internal.handler;
15 import static org.openhab.binding.ecowatt.internal.EcowattBindingConstants.*;
17 import java.time.Duration;
18 import java.time.LocalDateTime;
19 import java.time.ZonedDateTime;
20 import java.time.format.DateTimeFormatter;
21 import java.time.temporal.ChronoUnit;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.openhab.binding.ecowatt.internal.configuration.EcowattConfiguration;
29 import org.openhab.binding.ecowatt.internal.exception.EcowattApiLimitException;
30 import org.openhab.binding.ecowatt.internal.restapi.EcowattApiResponse;
31 import org.openhab.binding.ecowatt.internal.restapi.EcowattDaySignals;
32 import org.openhab.binding.ecowatt.internal.restapi.EcowattRestApi;
33 import org.openhab.core.auth.client.oauth2.OAuthFactory;
34 import org.openhab.core.cache.ExpiringCache;
35 import org.openhab.core.i18n.CommunicationException;
36 import org.openhab.core.i18n.TimeZoneProvider;
37 import org.openhab.core.i18n.TranslationProvider;
38 import org.openhab.core.library.types.DecimalType;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.Thing;
41 import org.openhab.core.thing.ThingStatus;
42 import org.openhab.core.thing.ThingStatusDetail;
43 import org.openhab.core.thing.binding.BaseThingHandler;
44 import org.openhab.core.types.Command;
45 import org.openhab.core.types.RefreshType;
46 import org.openhab.core.types.State;
47 import org.openhab.core.types.UnDefType;
48 import org.osgi.framework.Bundle;
49 import org.osgi.framework.FrameworkUtil;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
54 * The {@link EcowattHandler} is responsible for updating the state of the channels
56 * @author Laurent Garnier - Initial contribution
59 public class EcowattHandler extends BaseThingHandler {
61 private final Logger logger = LoggerFactory.getLogger(EcowattHandler.class);
63 private final OAuthFactory oAuthFactory;
64 private final HttpClient httpClient;
65 private final TranslationProvider i18nProvider;
66 private final TimeZoneProvider timeZoneProvider;
67 private final Bundle bundle;
69 private @Nullable EcowattRestApi api;
70 private ExpiringCache<EcowattApiResponse> cachedApiResponse = new ExpiringCache<>(Duration.ofHours(4),
71 this::getApiResponse); // cache the API response during 4 hours
73 private @Nullable ScheduledFuture<?> updateJob;
75 public EcowattHandler(Thing thing, OAuthFactory oAuthFactory, HttpClient httpClient,
76 TranslationProvider i18nProvider, TimeZoneProvider timeZoneProvider) {
78 this.oAuthFactory = oAuthFactory;
79 this.httpClient = httpClient;
80 this.i18nProvider = i18nProvider;
81 this.timeZoneProvider = timeZoneProvider;
82 this.bundle = FrameworkUtil.getBundle(this.getClass());
86 public void handleCommand(ChannelUID channelUID, Command command) {
87 if (command == RefreshType.REFRESH) {
88 updateChannel(channelUID.getId());
93 public void initialize() {
94 EcowattConfiguration config = getConfigAs(EcowattConfiguration.class);
96 final String idClient = config.idClient;
97 final String idSecret = config.idSecret;
99 if (idClient.isBlank() || idSecret.isBlank()) {
100 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
101 "@text/offline.config-error-unset-parameters");
103 api = new EcowattRestApi(oAuthFactory, httpClient, thing.getUID().getAsString(), idClient, idSecret);
104 updateStatus(ThingStatus.UNKNOWN);
105 scheduleNextUpdate(0, true);
110 public void dispose() {
112 EcowattRestApi localApi = api;
113 if (localApi != null) {
120 public void handleRemoval() {
121 oAuthFactory.deleteServiceAndAccessToken(thing.getUID().getAsString());
122 super.handleRemoval();
126 * Schedule the next update of channels.
128 * After this update is run, a new update will be rescheduled, either just after the API is reachable again or at
129 * the beginning of the following hour.
131 * @param delayInSeconds the delay in seconds before running the next update
132 * @param retryIfApiLimitReached true if a retry is expected when the update fails due to reached API limit
134 private void scheduleNextUpdate(long delayInSeconds, boolean retryIfApiLimitReached) {
135 logger.debug("scheduleNextUpdate delay={}s retryIfLimitReached={}", delayInSeconds, retryIfApiLimitReached);
136 updateJob = scheduler.schedule(() -> {
137 int retryDelay = updateChannels(retryIfApiLimitReached);
138 long delayNextUpdate;
139 if (retryDelay > 0) {
140 // Schedule a new update just after the API is reachable again
141 logger.debug("retryDelay {}", retryDelay);
142 delayNextUpdate = retryDelay;
144 // Schedule a new update at the beginning of the following hour
145 final LocalDateTime now = LocalDateTime.now();
146 final LocalDateTime beginningNextHour = now.plusHours(1).truncatedTo(ChronoUnit.HOURS);
147 delayNextUpdate = ChronoUnit.SECONDS.between(now, beginningNextHour);
149 // Add 3s of additional delay for security...
150 delayNextUpdate += 3;
151 scheduleNextUpdate(delayNextUpdate, retryDelay == 0);
152 }, delayInSeconds, TimeUnit.SECONDS);
155 private void stopScheduledJob() {
156 ScheduledFuture<?> job = updateJob;
163 private EcowattApiResponse getApiResponse() {
164 EcowattRestApi localApi = api;
165 if (localApi == null) {
166 return new EcowattApiResponse();
169 EcowattApiResponse response;
171 response = localApi.getSignals();
172 } catch (CommunicationException e) {
173 Throwable cause = e.getCause();
175 logger.warn("{}: {}", e.getMessage(bundle, i18nProvider), cause.getMessage());
177 logger.warn("{}", e.getMessage(bundle, i18nProvider));
179 response = new EcowattApiResponse(e);
184 private int updateChannels(boolean retryIfApiLimitReached) {
185 return updateChannel(null, retryIfApiLimitReached);
188 private void updateChannel(String channelId) {
189 updateChannel(channelId, false);
192 private synchronized int updateChannel(@Nullable String channelId, boolean retryIfApiLimitReached) {
193 logger.debug("updateChannel channelId={}, retryIfApiLimitReached={}", channelId, retryIfApiLimitReached);
195 EcowattApiResponse response = cachedApiResponse.getValue();
196 if (response == null || !response.succeeded()) {
197 CommunicationException exception = response == null ? null : response.getException();
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
199 exception == null ? null : exception.getRawMessage());
201 // Invalidate the cache to be sure the next request will trigger the API
202 cachedApiResponse.invalidateValue();
204 if (retryIfApiLimitReached && exception instanceof EcowattApiLimitException limitException
205 && limitException.getRetryAfter() > 0) {
206 // Will retry when the API is available again (just after the limit expired)
207 retryDelay = limitException.getRetryAfter();
210 updateStatus(ThingStatus.ONLINE);
213 ZonedDateTime now = ZonedDateTime.now(timeZoneProvider.getTimeZone());
214 logger.debug("now {}", now.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
215 if ((channelId == null || CHANNEL_TODAY_SIGNAL.equals(channelId)) && isLinked(CHANNEL_TODAY_SIGNAL)) {
216 updateState(CHANNEL_TODAY_SIGNAL, getDaySignalState(response, now));
218 if ((channelId == null || CHANNEL_TOMORROW_SIGNAL.equals(channelId)) && isLinked(CHANNEL_TOMORROW_SIGNAL)) {
219 updateState(CHANNEL_TOMORROW_SIGNAL, getDaySignalState(response, now.plusDays(1)));
221 if ((channelId == null || CHANNEL_IN_TWO_DAYS_SIGNAL.equals(channelId))
222 && isLinked(CHANNEL_IN_TWO_DAYS_SIGNAL)) {
223 updateState(CHANNEL_IN_TWO_DAYS_SIGNAL, getDaySignalState(response, now.plusDays(2)));
225 if ((channelId == null || CHANNEL_IN_THREE_DAYS_SIGNAL.equals(channelId))
226 && isLinked(CHANNEL_IN_THREE_DAYS_SIGNAL)) {
227 updateState(CHANNEL_IN_THREE_DAYS_SIGNAL, getDaySignalState(response, now.plusDays(3)));
229 if ((channelId == null || CHANNEL_CURRENT_HOUR_SIGNAL.equals(channelId))
230 && isLinked(CHANNEL_CURRENT_HOUR_SIGNAL)) {
231 updateState(CHANNEL_CURRENT_HOUR_SIGNAL, getHourSignalState(response, now));
238 * Get the signal applicable for a given day from the API response
240 * @param response the API response
241 * @param dateTime the date and time to consider
242 * @return the found valid signal as a channel state or UndefType.UNDEF if not found
244 public static State getDaySignalState(@Nullable EcowattApiResponse response, ZonedDateTime dateTime) {
245 EcowattDaySignals signals = response == null ? null : response.getDaySignals(dateTime);
246 return signals != null && signals.getDaySignal() >= 1 && signals.getDaySignal() <= 3
247 ? new DecimalType(signals.getDaySignal())
252 * Get the signal applicable for a given day and hour from the API response
254 * @param response the API response
255 * @param dateTime the date and time to consider
256 * @return the found valid signal as a channel state or UndefType.UNDEF if not found
258 public static State getHourSignalState(@Nullable EcowattApiResponse response, ZonedDateTime dateTime) {
259 EcowattDaySignals signals = response == null ? null : response.getDaySignals(dateTime);
260 ZonedDateTime day = signals == null ? null : signals.getDay();
261 if (signals != null && day != null) {
262 // Move the current time to the same offset as the data returned by the API to get and use the right current
263 // hour index in these data
264 int hour = dateTime.withZoneSameInstant(day.getZone()).getHour();
265 int value = signals.getHourSignal(hour);
266 LoggerFactory.getLogger(EcowattHandler.class).debug("hour {} value {}", hour, value);
267 if (value >= 1 && value <= 3) {
268 return new DecimalType(value);
271 return UnDefType.UNDEF;