]> git.basschouten.com Git - openhab-addons.git/blob
8be5e5c2e1b24417d879d1a65b3ee575e236467a
[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.hydrawise.internal.handler;
14
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;
20 import java.util.Set;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23
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;
51
52 /**
53  * The {@link HydrawiseAccountHandler} is responsible for handling for connecting to a Hydrawise account and polling for
54  * controller data
55  *
56  * @author Dan Cunningham - Initial contribution
57  */
58 @NonNullByDefault
59 public class HydrawiseAccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
60     private final Logger logger = LoggerFactory.getLogger(HydrawiseAccountHandler.class);
61     /**
62      * Minimum amount of time we can poll for updates
63      */
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;
80     private int refresh;
81
82     public HydrawiseAccountHandler(final Bridge bridge, final HttpClient httpClient, final OAuthFactory oAuthFactory) {
83         super(bridge);
84         this.httpClient = httpClient;
85         this.oAuthFactory = oAuthFactory;
86     }
87
88     @Override
89     public void handleCommand(ChannelUID channelUID, Command command) {
90     }
91
92     @Override
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);
101     }
102
103     @Override
104     public void dispose() {
105         logger.debug("Handler disposed.");
106         clearPolling();
107         clearTokenRefresh();
108         OAuthClientService oAuthService = this.oAuthService;
109         if (oAuthService != null) {
110             oAuthService.removeAccessTokenRefreshListener(this);
111             oAuthFactory.ungetOAuthService(getThing().toString());
112             this.oAuthService = null;
113         }
114     }
115
116     @Override
117     public void handleRemoval() {
118         oAuthFactory.deleteServiceAndAccessToken(getThing().toString());
119         super.handleRemoval();
120     }
121
122     @Override
123     public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
124         logger.debug("Auth Token Refreshed, expires in {}", tokenResponse.getExpiresIn());
125     }
126
127     @Override
128     public Collection<Class<? extends ThingHandlerService>> getServices() {
129         return Set.of(HydrawiseCloudControllerDiscoveryService.class);
130     }
131
132     public void addControllerListeners(HydrawiseControllerListener listener) {
133         this.controllerListeners.add(listener);
134         Customer data = lastData;
135         if (data != null) {
136             listener.onData(data.controllers);
137         }
138     }
139
140     public void removeControllerListeners(HydrawiseControllerListener listener) {
141         synchronized (controllerListeners) {
142             this.controllerListeners.remove(listener);
143         }
144     }
145
146     public @Nullable HydrawiseGraphQLClient graphQLClient() {
147         return apiClient;
148     }
149
150     public @Nullable Customer lastData() {
151         return lastData;
152     }
153
154     public void refreshData(int delaySeconds) {
155         initPolling(delaySeconds, this.refresh);
156     }
157
158     private void configure(OAuthClientService oAuthService) {
159         HydrawiseAccountConfiguration config = getConfig().as(HydrawiseAccountConfiguration.class);
160         try {
161             if (!config.userName.isEmpty() && !config.password.isEmpty()) {
162                 if (!config.savePassword) {
163                     Configuration editedConfig = editConfiguration();
164                     editedConfig.remove("password");
165                     updateConfiguration(editedConfig);
166                 }
167                 oAuthService.getAccessTokenByResourceOwnerPasswordCredentials(config.userName, config.password, SCOPE);
168             } else if (oAuthService.getAccessTokenResponse() == null) {
169                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Login credentials required.");
170                 return;
171             }
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.");
178         }
179     }
180
181     /**
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.
184      */
185     private synchronized void initPolling(int initalDelay, int refresh) {
186         clearPolling();
187         pollFuture = scheduler.scheduleWithFixedDelay(this::poll, initalDelay, refresh, TimeUnit.SECONDS);
188     }
189
190     /**
191      * The API will randomly reject a request with a 401 not authorized, waiting a min and refreshing the token usually
192      * fixes it
193      */
194     private synchronized void retryToken() {
195         clearTokenRefresh();
196         tokenFuture = scheduler.schedule(() -> {
197             try {
198                 OAuthClientService oAuthService = this.oAuthService;
199                 if (oAuthService != null) {
200                     oAuthService.refreshToken();
201                     initPolling(0, refresh);
202                 }
203             } catch (OAuthException | IOException | OAuthResponseException e) {
204                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
205             }
206         }, TOKEN_REFRESH_SECONDS, TimeUnit.SECONDS);
207     }
208
209     /**
210      * Stops/clears this thing's polling future
211      */
212     private void clearPolling() {
213         clearFuture(pollFuture);
214     }
215
216     private void clearTokenRefresh() {
217         clearFuture(tokenFuture);
218     }
219
220     private void clearFuture(@Nullable final ScheduledFuture<?> future) {
221         if (future != null) {
222             future.cancel(true);
223         }
224     }
225
226     private void poll() {
227         poll(true);
228     }
229
230     private void poll(boolean retry) {
231         try {
232             QueryResponse response = apiClient.queryControllers();
233             if (response == null) {
234                 throw new HydrawiseConnectionException("Malformed response");
235             }
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 + ". "));
239             }
240             if (getThing().getStatus() != ThingStatus.ONLINE) {
241                 updateStatus(ThingStatus.ONLINE);
242             }
243             lastData = response.data.me;
244             synchronized (controllerListeners) {
245                 controllerListeners.forEach(listener -> {
246                     listener.onData(response.data.me.controllers);
247                 });
248             }
249         } catch (HydrawiseConnectionException e) {
250             if (retry) {
251                 logger.debug("Retrying failed poll", e);
252                 poll(false);
253             } else {
254                 logger.debug("Will try again during next poll period", e);
255                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
256             }
257         } catch (HydrawiseAuthenticationException e) {
258             logger.debug("Token has been rejected, will try to refresh token in {} secs: {}", TOKEN_REFRESH_SECONDS,
259                     e.getLocalizedMessage());
260             clearPolling();
261             retryToken();
262         }
263     }
264 }