2 * Copyright (c) 2010-2024 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.hydrawise.internal.handler;
15 import java.io.IOException;
16 import java.util.ArrayList;
17 import java.util.Collection;
18 import java.util.Collections;
19 import java.util.List;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.openhab.binding.hydrawise.internal.HydrawiseControllerListener;
28 import org.openhab.binding.hydrawise.internal.api.HydrawiseAuthenticationException;
29 import org.openhab.binding.hydrawise.internal.api.HydrawiseConnectionException;
30 import org.openhab.binding.hydrawise.internal.api.graphql.HydrawiseGraphQLClient;
31 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Customer;
32 import org.openhab.binding.hydrawise.internal.api.graphql.dto.QueryResponse;
33 import org.openhab.binding.hydrawise.internal.config.HydrawiseAccountConfiguration;
34 import org.openhab.binding.hydrawise.internal.discovery.HydrawiseCloudControllerDiscoveryService;
35 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
36 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
37 import org.openhab.core.auth.client.oauth2.OAuthClientService;
38 import org.openhab.core.auth.client.oauth2.OAuthException;
39 import org.openhab.core.auth.client.oauth2.OAuthFactory;
40 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
41 import org.openhab.core.config.core.Configuration;
42 import org.openhab.core.thing.Bridge;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.binding.BaseBridgeHandler;
47 import org.openhab.core.thing.binding.ThingHandlerService;
48 import org.openhab.core.types.Command;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
53 * The {@link HydrawiseAccountHandler} is responsible for handling for connecting to a Hydrawise account and polling for
56 * @author Dan Cunningham - Initial contribution
59 public class HydrawiseAccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
60 private final Logger logger = LoggerFactory.getLogger(HydrawiseAccountHandler.class);
62 * Minimum amount of time we can poll for updates
64 private static final int MIN_REFRESH_SECONDS = 30;
65 private static final int TOKEN_REFRESH_SECONDS = 60;
66 private static final String BASE_URL = "https://app.hydrawise.com/api/v2/";
67 private static final String AUTH_URL = BASE_URL + "oauth/access-token";
68 private static final String CLIENT_SECRET = "zn3CrjglwNV1";
69 private static final String CLIENT_ID = "hydrawise_app";
70 private static final String SCOPE = "all";
71 private final List<HydrawiseControllerListener> controllerListeners = Collections
72 .synchronizedList(new ArrayList<>());
73 private final HttpClient httpClient;
74 private final OAuthFactory oAuthFactory;
75 private @Nullable OAuthClientService oAuthService;
76 private @Nullable HydrawiseGraphQLClient apiClient;
77 private @Nullable ScheduledFuture<?> pollFuture;
78 private @Nullable ScheduledFuture<?> tokenFuture;
79 private @Nullable Customer lastData;
82 public HydrawiseAccountHandler(final Bridge bridge, final HttpClient httpClient, final OAuthFactory oAuthFactory) {
84 this.httpClient = httpClient;
85 this.oAuthFactory = oAuthFactory;
89 public void handleCommand(ChannelUID channelUID, Command command) {
93 public void initialize() {
94 OAuthClientService oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), AUTH_URL,
95 AUTH_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false);
96 this.oAuthService = oAuthService;
97 oAuthService.addAccessTokenRefreshListener(this);
98 this.apiClient = new HydrawiseGraphQLClient(httpClient, oAuthService);
99 logger.debug("Handler initialized.");
100 scheduler.schedule(() -> configure(oAuthService), 0, TimeUnit.SECONDS);
104 public void dispose() {
105 logger.debug("Handler disposed.");
108 OAuthClientService oAuthService = this.oAuthService;
109 if (oAuthService != null) {
110 oAuthService.removeAccessTokenRefreshListener(this);
111 oAuthFactory.ungetOAuthService(getThing().toString());
112 this.oAuthService = null;
117 public void handleRemoval() {
118 oAuthFactory.deleteServiceAndAccessToken(getThing().toString());
119 super.handleRemoval();
123 public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
124 logger.debug("Auth Token Refreshed, expires in {}", tokenResponse.getExpiresIn());
128 public Collection<Class<? extends ThingHandlerService>> getServices() {
129 return Set.of(HydrawiseCloudControllerDiscoveryService.class);
132 public void addControllerListeners(HydrawiseControllerListener listener) {
133 this.controllerListeners.add(listener);
134 Customer data = lastData;
136 listener.onData(data.controllers);
140 public void removeControllerListeners(HydrawiseControllerListener listener) {
141 synchronized (controllerListeners) {
142 this.controllerListeners.remove(listener);
146 public @Nullable HydrawiseGraphQLClient graphQLClient() {
150 public @Nullable Customer lastData() {
154 public void refreshData(int delaySeconds) {
155 initPolling(delaySeconds, this.refresh);
158 private void configure(OAuthClientService oAuthService) {
159 HydrawiseAccountConfiguration config = getConfig().as(HydrawiseAccountConfiguration.class);
161 if (!config.userName.isEmpty() && !config.password.isEmpty()) {
162 if (!config.savePassword) {
163 Configuration editedConfig = editConfiguration();
164 editedConfig.remove("password");
165 updateConfiguration(editedConfig);
167 oAuthService.getAccessTokenByResourceOwnerPasswordCredentials(config.userName, config.password, SCOPE);
168 } else if (oAuthService.getAccessTokenResponse() == null) {
169 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Login credentials required.");
172 this.refresh = Math.max(config.refreshInterval, MIN_REFRESH_SECONDS);
173 initPolling(0, refresh);
174 } catch (OAuthException | IOException e) {
175 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
176 } catch (OAuthResponseException e) {
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Login credentials required.");
182 * Starts/Restarts polling with an initial delay. This allows changes in the poll cycle for when commands are sent
183 * and we need to poll sooner then the next refresh cycle.
185 private synchronized void initPolling(int initalDelay, int refresh) {
187 pollFuture = scheduler.scheduleWithFixedDelay(this::poll, initalDelay, refresh, TimeUnit.SECONDS);
191 * The API will randomly reject a request with a 401 not authorized, waiting a min and refreshing the token usually
194 private synchronized void retryToken() {
196 tokenFuture = scheduler.schedule(() -> {
198 OAuthClientService oAuthService = this.oAuthService;
199 if (oAuthService != null) {
200 oAuthService.refreshToken();
201 initPolling(0, refresh);
203 } catch (OAuthException | IOException | OAuthResponseException e) {
204 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
206 }, TOKEN_REFRESH_SECONDS, TimeUnit.SECONDS);
210 * Stops/clears this thing's polling future
212 private void clearPolling() {
213 clearFuture(pollFuture);
216 private void clearTokenRefresh() {
217 clearFuture(tokenFuture);
220 private void clearFuture(@Nullable final ScheduledFuture<?> future) {
221 if (future != null) {
226 private void poll() {
230 private void poll(boolean retry) {
232 QueryResponse response = apiClient.queryControllers();
233 if (response == null) {
234 throw new HydrawiseConnectionException("Malformed response");
236 if (response.errors != null && !response.errors.isEmpty()) {
237 throw new HydrawiseConnectionException(response.errors.stream().map(error -> error.message).reduce("",
238 (messages, message) -> messages + message + ". "));
240 if (getThing().getStatus() != ThingStatus.ONLINE) {
241 updateStatus(ThingStatus.ONLINE);
243 lastData = response.data.me;
244 synchronized (controllerListeners) {
245 controllerListeners.forEach(listener -> {
246 listener.onData(response.data.me.controllers);
249 } catch (HydrawiseConnectionException e) {
251 logger.debug("Retrying failed poll", e);
254 logger.debug("Will try again during next poll period", e);
255 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
257 } catch (HydrawiseAuthenticationException e) {
258 logger.debug("Token has been rejected, will try to refresh token in {} secs: {}", TOKEN_REFRESH_SECONDS,
259 e.getLocalizedMessage());