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.nest.internal.wwn.handler;
15 import static java.util.concurrent.TimeUnit.SECONDS;
16 import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.JSON_CONTENT_TYPE;
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;
26 import java.util.concurrent.CopyOnWriteArrayList;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
30 import javax.ws.rs.client.ClientBuilder;
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;
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.
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
75 public class WWNAccountHandler extends BaseBridgeHandler implements WWNStreamingDataListener {
77 private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
79 private final Logger logger = LoggerFactory.getLogger(WWNAccountHandler.class);
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);
87 private @NonNullByDefault({}) WWNAuthorizer authorizer;
88 private @NonNullByDefault({}) WWNAccountConfiguration config;
90 private @Nullable ScheduledFuture<?> initializeJob;
91 private @Nullable ScheduledFuture<?> transmitJob;
92 private @Nullable WWNRedirectUrlSupplier redirectUrlSupplier;
93 private @Nullable WWNStreamingRestClient streamingRestClient;
96 * Creates the bridge handler to connect to Nest.
98 * @param bridge The bridge to connect to Nest with.
100 public WWNAccountHandler(Bridge bridge, ClientBuilder clientBuilder, SseEventSourceFactory eventSourceFactory) {
102 this.clientBuilder = clientBuilder;
103 this.eventSourceFactory = eventSourceFactory;
107 * Initialize the connection to Nest.
110 public void initialize() {
111 logger.debug("Initializing Nest bridge handler");
113 config = getConfigAs(WWNAccountConfiguration.class);
114 authorizer = new WWNAuthorizer(config);
115 updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Starting poll query");
117 initializeJob = scheduler.schedule(() -> {
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());
130 }, 0, TimeUnit.SECONDS);
132 logger.debug("Finished initializing Nest bridge handler");
136 * Clean up the handler.
139 public void dispose() {
140 logger.debug("Nest bridge disposed");
141 stopStreamingUpdates();
143 ScheduledFuture<?> localInitializeJob = initializeJob;
144 if (localInitializeJob != null && !localInitializeJob.isCancelled()) {
145 localInitializeJob.cancel(true);
146 initializeJob = null;
149 ScheduledFuture<?> localTransmitJob = transmitJob;
150 if (localTransmitJob != null && !localTransmitJob.isCancelled()) {
151 localTransmitJob.cancel(true);
155 this.authorizer = null;
156 this.redirectUrlSupplier = null;
157 this.streamingRestClient = null;
160 public <T> boolean addThingDataListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
161 return updateHandler.addListener(dataClass, listener);
164 public <T> boolean addThingDataListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
165 return updateHandler.addListener(dataClass, nestId, listener);
169 * Adds the update request into the queue for doing something with, send immediately if the queue is empty.
171 public void addUpdateRequest(WWNUpdateRequest request) {
172 nestUpdateRequests.add(request);
173 scheduleTransmitJobForPendingRequests();
176 protected WWNRedirectUrlSupplier createRedirectUrlSupplier() throws InvalidWWNAccessTokenException {
177 return new WWNRedirectUrlSupplier(getHttpHeaders());
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;
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);
194 logger.debug("Re-using access token from configuration: {}", accessToken);
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);
206 public @Nullable <T> T getLastUpdate(Class<T> dataClass, String nestId) {
207 return updateHandler.getLastUpdate(dataClass, nestId);
210 public <T> List<T> getLastUpdates(Class<T> dataClass) {
211 return updateHandler.getLastUpdates(dataClass);
214 private WWNRedirectUrlSupplier getOrCreateRedirectUrlSupplier() throws InvalidWWNAccessTokenException {
215 WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
216 if (localRedirectUrlSupplier == null) {
217 localRedirectUrlSupplier = createRedirectUrlSupplier();
218 redirectUrlSupplier = localRedirectUrlSupplier;
220 return localRedirectUrlSupplier;
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());
235 public Collection<Class<? extends ThingHandlerService>> getServices() {
236 return List.of(WWNDiscoveryService.class);
240 * Handles an incoming command update
243 public void handleCommand(ChannelUID channelUID, Command command) {
244 if (command instanceof RefreshType) {
245 logger.debug("Refresh command received");
246 updateHandler.resendLastUpdates();
250 private void jsonToPutUrl(WWNUpdateRequest request)
251 throws FailedSendingWWNDataException, InvalidWWNAccessTokenException, FailedResolvingWWNUrlException {
253 WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
254 if (localRedirectUrlSupplier == null) {
255 throw new FailedResolvingWWNUrlException("redirectUrlSupplier is null");
258 String url = localRedirectUrlSupplier.getRedirectUrl() + request.getUpdatePath();
259 logger.debug("Putting data to: {}", url);
261 String jsonContent = WWNUtils.toJson(request.getValues());
262 logger.debug("PUT content: {}", jsonContent);
264 ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8));
265 String jsonResponse = HttpUtil.executeUrl("PUT", url, getHttpHeaders(), inputStream, JSON_CONTENT_TYPE,
267 logger.debug("PUT response: {}", jsonResponse);
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());
274 } catch (IOException e) {
275 throw new FailedSendingWWNDataException("Failed to send data", e);
280 public void onAuthorizationRevoked(String token) {
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
282 "Authorization token revoked: " + token);
286 public void onConnected() {
287 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Streaming data connection established");
288 scheduleTransmitJobForPendingRequests();
292 public void onDisconnected() {
293 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Streaming data disconnected");
297 public void onError(String message) {
298 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
302 public void onNewTopLevelData(WWNTopLevelData data) {
303 updateHandler.handleUpdate(data);
304 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Receiving streaming data");
307 public <T> boolean removeThingDataListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
308 return updateHandler.removeListener(dataClass, listener);
311 public <T> boolean removeThingDataListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
312 return updateHandler.removeListener(dataClass, nestId, listener);
315 private void restartStreamingUpdates() {
316 synchronized (this) {
317 stopStreamingUpdates();
318 startStreamingUpdates();
322 private void scheduleTransmitJobForPendingRequests() {
323 ScheduledFuture<?> localTransmitJob = transmitJob;
324 if (!nestUpdateRequests.isEmpty() && (localTransmitJob == null || localTransmitJob.isDone())) {
325 transmitJob = scheduler.schedule(this::transmitQueue, 0, SECONDS);
329 private void startStreamingUpdates() {
330 synchronized (this) {
332 WWNStreamingRestClient localStreamingRestClient = new WWNStreamingRestClient(
333 getExistingOrNewAccessToken(), clientBuilder, eventSourceFactory,
334 getOrCreateRedirectUrlSupplier(), scheduler);
335 localStreamingRestClient.addStreamingDataListener(this);
336 localStreamingRestClient.start();
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());
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;
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");
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);
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);
385 WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
386 if (localRedirectUrlSupplier != null) {
387 localRedirectUrlSupplier.resetCache();