]> git.basschouten.com Git - openhab-addons.git/blob
a394924232258f3e61b6e7a7698c6aaacf4591a0
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.nest.internal.wwn.handler;
14
15 import static java.util.concurrent.TimeUnit.SECONDS;
16 import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.JSON_CONTENT_TYPE;
17
18 import java.io.ByteArrayInputStream;
19 import java.io.IOException;
20 import java.nio.charset.StandardCharsets;
21 import java.util.Collection;
22 import java.util.HashSet;
23 import java.util.List;
24 import java.util.Properties;
25 import java.util.Set;
26 import java.util.concurrent.CopyOnWriteArrayList;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29
30 import javax.ws.rs.client.ClientBuilder;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.nest.internal.wwn.WWNUtils;
35 import org.openhab.binding.nest.internal.wwn.config.WWNAccountConfiguration;
36 import org.openhab.binding.nest.internal.wwn.discovery.WWNDiscoveryService;
37 import org.openhab.binding.nest.internal.wwn.dto.WWNErrorData;
38 import org.openhab.binding.nest.internal.wwn.dto.WWNIdentifiable;
39 import org.openhab.binding.nest.internal.wwn.dto.WWNTopLevelData;
40 import org.openhab.binding.nest.internal.wwn.dto.WWNUpdateRequest;
41 import org.openhab.binding.nest.internal.wwn.exceptions.FailedResolvingWWNUrlException;
42 import org.openhab.binding.nest.internal.wwn.exceptions.FailedSendingWWNDataException;
43 import org.openhab.binding.nest.internal.wwn.exceptions.InvalidWWNAccessTokenException;
44 import org.openhab.binding.nest.internal.wwn.listener.WWNStreamingDataListener;
45 import org.openhab.binding.nest.internal.wwn.listener.WWNThingDataListener;
46 import org.openhab.binding.nest.internal.wwn.rest.WWNAuthorizer;
47 import org.openhab.binding.nest.internal.wwn.rest.WWNStreamingRestClient;
48 import org.openhab.binding.nest.internal.wwn.update.WWNCompositeUpdateHandler;
49 import org.openhab.core.config.core.Configuration;
50 import org.openhab.core.io.net.http.HttpUtil;
51 import org.openhab.core.thing.Bridge;
52 import org.openhab.core.thing.ChannelUID;
53 import org.openhab.core.thing.Thing;
54 import org.openhab.core.thing.ThingStatus;
55 import org.openhab.core.thing.ThingStatusDetail;
56 import org.openhab.core.thing.binding.BaseBridgeHandler;
57 import org.openhab.core.thing.binding.ThingHandler;
58 import org.openhab.core.thing.binding.ThingHandlerService;
59 import org.openhab.core.types.Command;
60 import org.openhab.core.types.RefreshType;
61 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
64
65 /**
66  * This account handler connects to Nest and handles all the WWN API requests. It pulls down the
67  * updated data, polls the system and does all the co-ordination with the other handlers
68  * to get the data updated to the correct things.
69  *
70  * @author David Bennett - Initial contribution
71  * @author Martin van Wingerden - Use listeners not only for discovery but for all data processing
72  * @author Wouter Born - Improve exception and URL redirect handling
73  */
74 @NonNullByDefault
75 public class WWNAccountHandler extends BaseBridgeHandler implements WWNStreamingDataListener {
76
77     private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
78
79     private final Logger logger = LoggerFactory.getLogger(WWNAccountHandler.class);
80
81     private final ClientBuilder clientBuilder;
82     private final SseEventSourceFactory eventSourceFactory;
83     private final List<WWNUpdateRequest> nestUpdateRequests = new CopyOnWriteArrayList<>();
84     private final WWNCompositeUpdateHandler updateHandler = new WWNCompositeUpdateHandler(
85             this::getPresentThingsNestIds);
86
87     private @NonNullByDefault({}) WWNAuthorizer authorizer;
88     private @NonNullByDefault({}) WWNAccountConfiguration config;
89
90     private @Nullable ScheduledFuture<?> initializeJob;
91     private @Nullable ScheduledFuture<?> transmitJob;
92     private @Nullable WWNRedirectUrlSupplier redirectUrlSupplier;
93     private @Nullable WWNStreamingRestClient streamingRestClient;
94
95     /**
96      * Creates the bridge handler to connect to Nest.
97      *
98      * @param bridge The bridge to connect to Nest with.
99      */
100     public WWNAccountHandler(Bridge bridge, ClientBuilder clientBuilder, SseEventSourceFactory eventSourceFactory) {
101         super(bridge);
102         this.clientBuilder = clientBuilder;
103         this.eventSourceFactory = eventSourceFactory;
104     }
105
106     /**
107      * Initialize the connection to Nest.
108      */
109     @Override
110     public void initialize() {
111         logger.debug("Initializing Nest bridge handler");
112
113         config = getConfigAs(WWNAccountConfiguration.class);
114         authorizer = new WWNAuthorizer(config);
115         updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Starting poll query");
116
117         initializeJob = scheduler.schedule(() -> {
118             try {
119                 logger.debug("Product ID      {}", config.productId);
120                 logger.debug("Product Secret  {}", config.productSecret);
121                 logger.debug("Pincode         {}", config.pincode);
122                 logger.debug("Access Token    {}", getExistingOrNewAccessToken());
123                 redirectUrlSupplier = createRedirectUrlSupplier();
124                 restartStreamingUpdates();
125             } catch (InvalidWWNAccessTokenException e) {
126                 logger.debug("Invalid access token", e);
127                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
128                         "Token is invalid and could not be refreshed: " + e.getMessage());
129             }
130         }, 0, TimeUnit.SECONDS);
131
132         logger.debug("Finished initializing Nest bridge handler");
133     }
134
135     /**
136      * Clean up the handler.
137      */
138     @Override
139     public void dispose() {
140         logger.debug("Nest bridge disposed");
141         stopStreamingUpdates();
142
143         ScheduledFuture<?> localInitializeJob = initializeJob;
144         if (localInitializeJob != null && !localInitializeJob.isCancelled()) {
145             localInitializeJob.cancel(true);
146             initializeJob = null;
147         }
148
149         ScheduledFuture<?> localTransmitJob = transmitJob;
150         if (localTransmitJob != null && !localTransmitJob.isCancelled()) {
151             localTransmitJob.cancel(true);
152             transmitJob = null;
153         }
154
155         this.authorizer = null;
156         this.redirectUrlSupplier = null;
157         this.streamingRestClient = null;
158     }
159
160     public <T> boolean addThingDataListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
161         return updateHandler.addListener(dataClass, listener);
162     }
163
164     public <T> boolean addThingDataListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
165         return updateHandler.addListener(dataClass, nestId, listener);
166     }
167
168     /**
169      * Adds the update request into the queue for doing something with, send immediately if the queue is empty.
170      */
171     public void addUpdateRequest(WWNUpdateRequest request) {
172         nestUpdateRequests.add(request);
173         scheduleTransmitJobForPendingRequests();
174     }
175
176     protected WWNRedirectUrlSupplier createRedirectUrlSupplier() throws InvalidWWNAccessTokenException {
177         return new WWNRedirectUrlSupplier(getHttpHeaders());
178     }
179
180     private String getExistingOrNewAccessToken() throws InvalidWWNAccessTokenException {
181         String accessToken = config.accessToken;
182         if (accessToken == null || accessToken.isEmpty()) {
183             accessToken = authorizer.getNewAccessToken();
184             config.accessToken = accessToken;
185             config.pincode = "";
186             // Update and save the access token in the bridge configuration
187             Configuration configuration = editConfiguration();
188             configuration.put(WWNAccountConfiguration.ACCESS_TOKEN, config.accessToken);
189             configuration.put(WWNAccountConfiguration.PINCODE, config.pincode);
190             updateConfiguration(configuration);
191             logger.debug("Retrieved new access token: {}", config.accessToken);
192             return accessToken;
193         } else {
194             logger.debug("Re-using access token from configuration: {}", accessToken);
195             return accessToken;
196         }
197     }
198
199     protected Properties getHttpHeaders() throws InvalidWWNAccessTokenException {
200         Properties httpHeaders = new Properties();
201         httpHeaders.put("Authorization", "Bearer " + getExistingOrNewAccessToken());
202         httpHeaders.put("Content-Type", JSON_CONTENT_TYPE);
203         return httpHeaders;
204     }
205
206     public @Nullable <T> T getLastUpdate(Class<T> dataClass, String nestId) {
207         return updateHandler.getLastUpdate(dataClass, nestId);
208     }
209
210     public <T> List<T> getLastUpdates(Class<T> dataClass) {
211         return updateHandler.getLastUpdates(dataClass);
212     }
213
214     private WWNRedirectUrlSupplier getOrCreateRedirectUrlSupplier() throws InvalidWWNAccessTokenException {
215         WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
216         if (localRedirectUrlSupplier == null) {
217             localRedirectUrlSupplier = createRedirectUrlSupplier();
218             redirectUrlSupplier = localRedirectUrlSupplier;
219         }
220         return localRedirectUrlSupplier;
221     }
222
223     private Set<String> getPresentThingsNestIds() {
224         Set<String> nestIds = new HashSet<>();
225         for (Thing thing : getThing().getThings()) {
226             ThingHandler handler = thing.getHandler();
227             if (handler != null && thing.getStatusInfo().getStatusDetail() != ThingStatusDetail.GONE) {
228                 nestIds.add(((WWNIdentifiable) handler).getId());
229             }
230         }
231         return nestIds;
232     }
233
234     @Override
235     public Collection<Class<? extends ThingHandlerService>> getServices() {
236         return List.of(WWNDiscoveryService.class);
237     }
238
239     /**
240      * Handles an incoming command update
241      */
242     @Override
243     public void handleCommand(ChannelUID channelUID, Command command) {
244         if (command instanceof RefreshType) {
245             logger.debug("Refresh command received");
246             updateHandler.resendLastUpdates();
247         }
248     }
249
250     private void jsonToPutUrl(WWNUpdateRequest request)
251             throws FailedSendingWWNDataException, InvalidWWNAccessTokenException, FailedResolvingWWNUrlException {
252         try {
253             WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
254             if (localRedirectUrlSupplier == null) {
255                 throw new FailedResolvingWWNUrlException("redirectUrlSupplier is null");
256             }
257
258             String url = localRedirectUrlSupplier.getRedirectUrl() + request.getUpdatePath();
259             logger.debug("Putting data to: {}", url);
260
261             String jsonContent = WWNUtils.toJson(request.getValues());
262             logger.debug("PUT content: {}", jsonContent);
263
264             ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8));
265             String jsonResponse = HttpUtil.executeUrl("PUT", url, getHttpHeaders(), inputStream, JSON_CONTENT_TYPE,
266                     REQUEST_TIMEOUT);
267             logger.debug("PUT response: {}", jsonResponse);
268
269             WWNErrorData error = WWNUtils.fromJson(jsonResponse, WWNErrorData.class);
270             if (error.getError() != null && !error.getError().isBlank()) {
271                 logger.debug("Nest API error: {}", error);
272                 logger.warn("Nest API error: {}", error.getMessage());
273             }
274         } catch (IOException e) {
275             throw new FailedSendingWWNDataException("Failed to send data", e);
276         }
277     }
278
279     @Override
280     public void onAuthorizationRevoked(String token) {
281         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
282                 "Authorization token revoked: " + token);
283     }
284
285     @Override
286     public void onConnected() {
287         updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Streaming data connection established");
288         scheduleTransmitJobForPendingRequests();
289     }
290
291     @Override
292     public void onDisconnected() {
293         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Streaming data disconnected");
294     }
295
296     @Override
297     public void onError(String message) {
298         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
299     }
300
301     @Override
302     public void onNewTopLevelData(WWNTopLevelData data) {
303         updateHandler.handleUpdate(data);
304         updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Receiving streaming data");
305     }
306
307     public <T> boolean removeThingDataListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
308         return updateHandler.removeListener(dataClass, listener);
309     }
310
311     public <T> boolean removeThingDataListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
312         return updateHandler.removeListener(dataClass, nestId, listener);
313     }
314
315     private void restartStreamingUpdates() {
316         synchronized (this) {
317             stopStreamingUpdates();
318             startStreamingUpdates();
319         }
320     }
321
322     private void scheduleTransmitJobForPendingRequests() {
323         ScheduledFuture<?> localTransmitJob = transmitJob;
324         if (!nestUpdateRequests.isEmpty() && (localTransmitJob == null || localTransmitJob.isDone())) {
325             transmitJob = scheduler.schedule(this::transmitQueue, 0, SECONDS);
326         }
327     }
328
329     private void startStreamingUpdates() {
330         synchronized (this) {
331             try {
332                 WWNStreamingRestClient localStreamingRestClient = new WWNStreamingRestClient(
333                         getExistingOrNewAccessToken(), clientBuilder, eventSourceFactory,
334                         getOrCreateRedirectUrlSupplier(), scheduler);
335                 localStreamingRestClient.addStreamingDataListener(this);
336                 localStreamingRestClient.start();
337
338                 streamingRestClient = localStreamingRestClient;
339             } catch (InvalidWWNAccessTokenException e) {
340                 logger.debug("Invalid access token", e);
341                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
342                         "Token is invalid and could not be refreshed: " + e.getMessage());
343             }
344         }
345     }
346
347     private void stopStreamingUpdates() {
348         WWNStreamingRestClient localStreamingRestClient = streamingRestClient;
349         if (localStreamingRestClient != null) {
350             synchronized (this) {
351                 localStreamingRestClient.stop();
352                 localStreamingRestClient.removeStreamingDataListener(this);
353                 streamingRestClient = null;
354             }
355         }
356     }
357
358     private void transmitQueue() {
359         if (getThing().getStatus() == ThingStatus.OFFLINE) {
360             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
361                     "Not transmitting events because bridge is OFFLINE");
362             return;
363         }
364
365         try {
366             while (!nestUpdateRequests.isEmpty()) {
367                 // nestUpdateRequests is a CopyOnWriteArrayList so its iterator does not support remove operations
368                 WWNUpdateRequest request = nestUpdateRequests.get(0);
369                 jsonToPutUrl(request);
370                 nestUpdateRequests.remove(request);
371             }
372         } catch (InvalidWWNAccessTokenException e) {
373             logger.debug("Invalid access token", e);
374             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
375                     "Token is invalid and could not be refreshed: " + e.getMessage());
376         } catch (FailedResolvingWWNUrlException e) {
377             logger.debug("Unable to resolve redirect URL", e);
378             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
379             scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
380         } catch (FailedSendingWWNDataException e) {
381             logger.debug("Error sending data", e);
382             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
383             scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
384
385             WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
386             if (localRedirectUrlSupplier != null) {
387                 localRedirectUrlSupplier.resetCache();
388             }
389         }
390     }
391 }