]> git.basschouten.com Git - openhab-addons.git/blob
a81b107f47c8e6bfca0b735b492dc3169c7b61c6
[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.deconz.internal.handler;
14
15 import static org.openhab.binding.deconz.internal.BindingConstants.*;
16 import static org.openhab.binding.deconz.internal.Util.buildUrl;
17
18 import java.net.SocketTimeoutException;
19 import java.util.Collection;
20 import java.util.Map;
21 import java.util.Objects;
22 import java.util.Optional;
23 import java.util.Set;
24 import java.util.concurrent.CompletableFuture;
25 import java.util.concurrent.CompletionException;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.http.HttpMethod;
33 import org.openhab.binding.deconz.internal.action.BridgeActions;
34 import org.openhab.binding.deconz.internal.discovery.ThingDiscoveryService;
35 import org.openhab.binding.deconz.internal.dto.ApiKeyMessage;
36 import org.openhab.binding.deconz.internal.dto.BridgeFullState;
37 import org.openhab.binding.deconz.internal.netutils.AsyncHttpClient;
38 import org.openhab.binding.deconz.internal.netutils.WebSocketConnection;
39 import org.openhab.binding.deconz.internal.netutils.WebSocketConnectionListener;
40 import org.openhab.core.cache.ExpiringCacheAsync;
41 import org.openhab.core.config.core.Configuration;
42 import org.openhab.core.io.net.http.WebSocketFactory;
43 import org.openhab.core.thing.Bridge;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.ThingTypeUID;
49 import org.openhab.core.thing.binding.BaseBridgeHandler;
50 import org.openhab.core.thing.binding.ThingHandlerService;
51 import org.openhab.core.thing.util.ThingWebClientUtil;
52 import org.openhab.core.types.Command;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
55
56 import com.google.gson.Gson;
57
58 /**
59  * The bridge Thing is responsible for requesting all available sensors and switches and propagate
60  * them to the discovery service.
61  *
62  * It performs the authorization process if necessary.
63  *
64  * A websocket connection is established to the deCONZ software and kept alive.
65  *
66  * @author David Graeff - Initial contribution
67  */
68 @NonNullByDefault
69 public class DeconzBridgeHandler extends BaseBridgeHandler implements WebSocketConnectionListener {
70     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(BRIDGE_TYPE);
71
72     private final Logger logger = LoggerFactory.getLogger(DeconzBridgeHandler.class);
73     private final AsyncHttpClient http;
74     private final WebSocketFactory webSocketFactory;
75     private DeconzBridgeConfig config = new DeconzBridgeConfig();
76     private final Gson gson;
77     private @Nullable ScheduledFuture<?> connectionJob;
78     private int websocketPort = 0;
79     /** Prevent a dispose/init cycle while this flag is set. Use for property updates */
80     private boolean ignoreConfigurationUpdate;
81     private boolean thingDisposing = false;
82     private WebSocketConnection webSocketConnection;
83
84     private final ExpiringCacheAsync<Optional<BridgeFullState>> fullStateCache = new ExpiringCacheAsync<>(1000);
85
86     /** The poll frequency for the API Key verification */
87     private static final int POLL_FREQUENCY_SEC = 10;
88     private boolean ignoreConnectionLost = true;
89
90     public DeconzBridgeHandler(Bridge thing, WebSocketFactory webSocketFactory, AsyncHttpClient http, Gson gson) {
91         super(thing);
92         this.http = http;
93         this.gson = gson;
94         this.webSocketFactory = webSocketFactory;
95         this.webSocketConnection = createNewWebSocketConnection();
96     }
97
98     private WebSocketConnection createNewWebSocketConnection() {
99         String websocketID = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
100         return new WebSocketConnection(this, webSocketFactory.createWebSocketClient(websocketID), gson,
101                 config.websocketTimeout);
102     }
103
104     @Override
105     public Collection<Class<? extends ThingHandlerService>> getServices() {
106         return Set.of(ThingDiscoveryService.class, BridgeActions.class);
107     }
108
109     @Override
110     public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
111         if (!ignoreConfigurationUpdate) {
112             super.handleConfigurationUpdate(configurationParameters);
113         }
114     }
115
116     @Override
117     public void handleCommand(ChannelUID channelUID, Command command) {
118     }
119
120     @Override
121     public void thingUpdated(Thing thing) {
122         dispose();
123         this.thing = thing;
124         // we need to create a new websocket connection, because it can't be restarted
125         webSocketConnection = createNewWebSocketConnection();
126         initialize();
127     }
128
129     /**
130      * Stops the API request or websocket reconnect timer
131      */
132     private void stopTimer() {
133         ScheduledFuture<?> future = connectionJob;
134         if (future != null) {
135             future.cancel(false);
136             connectionJob = null;
137         }
138     }
139
140     /**
141      * Parses the response message to the API key generation REST API.
142      *
143      * @param r The response
144      */
145     private void parseAPIKeyResponse(AsyncHttpClient.Result r) {
146         if (thingDisposing) {
147             // discard response if thing handler is already disposing
148             return;
149         }
150         if (r.getResponseCode() == 403) {
151             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
152                     "Allow authentication for 3rd party apps. Trying again in " + POLL_FREQUENCY_SEC + " seconds");
153             stopTimer();
154             connectionJob = scheduler.schedule(this::requestApiKey, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
155         } else if (r.getResponseCode() == 200) {
156             ApiKeyMessage[] response = Objects.requireNonNull(gson.fromJson(r.getBody(), ApiKeyMessage[].class));
157             if (response.length == 0) {
158                 throw new IllegalStateException("Authorisation request response is empty");
159             }
160             config.apikey = response[0].success.username;
161             Configuration configuration = editConfiguration();
162             configuration.put(CONFIG_APIKEY, config.apikey);
163             updateConfiguration(configuration);
164             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Waiting for configuration");
165             initializeBridgeState();
166         } else {
167             throw new IllegalStateException("Unknown status code for authorisation request");
168         }
169     }
170
171     /**
172      * get the full state of the bridge from the cache
173      *
174      * @return a CompletableFuture that returns an Optional of the bridge full state
175      */
176     public CompletableFuture<Optional<BridgeFullState>> getBridgeFullState() {
177         return fullStateCache.getValue(this::refreshFullStateCache);
178     }
179
180     /**
181      * refresh the full bridge state (used for initial processing and state-lookup)
182      *
183      * @return Completable future with an Optional of the BridgeFullState
184      */
185     private CompletableFuture<Optional<BridgeFullState>> refreshFullStateCache() {
186         logger.trace("{} starts refreshing the fullStateCache", thing.getUID());
187         if (config.apikey == null || thingDisposing) {
188             return CompletableFuture.completedFuture(Optional.empty());
189         }
190         String url = buildUrl(config.getHostWithoutPort(), config.httpPort, config.apikey);
191         return http.get(url, config.timeout).thenApply(r -> {
192             if (r.getResponseCode() == 403) {
193                 return Optional.ofNullable((BridgeFullState) null);
194             } else if (r.getResponseCode() == 200) {
195                 return Optional.ofNullable(gson.fromJson(r.getBody(), BridgeFullState.class));
196             } else {
197                 throw new IllegalStateException("Unknown status code for full state request");
198             }
199         }).handle((v, t) -> {
200             if (t == null) {
201                 return v;
202             } else if (t instanceof SocketTimeoutException || t instanceof TimeoutException
203                     || t instanceof CompletionException) {
204                 logger.debug("Get full state failed", t);
205             } else {
206                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, t.getMessage());
207             }
208             return Optional.empty();
209         });
210     }
211
212     /**
213      * Perform a request to the REST API for retrieving the full bridge state with all sensors and switches
214      * and configuration.
215      */
216     public void initializeBridgeState() {
217         getBridgeFullState().thenAccept(fullState -> fullState.ifPresentOrElse(state -> {
218             if (thingDisposing) {
219                 // discard response if thing handler is already disposing
220                 return;
221             }
222             if (state.config.name.isEmpty()) {
223                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
224                         "You are connected to a HUE bridge, not a deCONZ software!");
225                 return;
226             }
227             if (state.config.websocketport == 0) {
228                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
229                         "deCONZ software too old. No websocket support!");
230                 return;
231             }
232
233             // Add some information about the bridge
234             Map<String, String> editProperties = editProperties();
235             editProperties.put("apiversion", state.config.apiversion);
236             editProperties.put("swversion", state.config.swversion);
237             editProperties.put("fwversion", state.config.fwversion);
238             editProperties.put("uuid", state.config.uuid);
239             editProperties.put("zigbeechannel", String.valueOf(state.config.zigbeechannel));
240             editProperties.put("ipaddress", state.config.ipaddress);
241             ignoreConfigurationUpdate = true;
242             updateProperties(editProperties);
243             ignoreConfigurationUpdate = false;
244
245             // Use requested websocket port if no specific port is given
246             websocketPort = config.port == 0 ? state.config.websocketport : config.port;
247             startWebSocketConnection();
248         }, () -> {
249             // initial response was empty, re-trying in POLL_FREQUENCY_SEC seconds
250             if (!thingDisposing) {
251                 connectionJob = scheduler.schedule(this::initializeBridgeState, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
252             }
253         })).exceptionally(e -> {
254             if (e != null) {
255                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
256             } else {
257                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
258             }
259             logger.warn("Initial full state request or result parsing failed", e);
260             if (!thingDisposing) {
261                 connectionJob = scheduler.schedule(this::initializeBridgeState, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
262             }
263             return null;
264         });
265     }
266
267     /**
268      * Starts the websocket connection.
269      * {@link #initializeBridgeState} need to be called first to obtain the websocket port.
270      */
271     private void startWebSocketConnection() {
272         ignoreConnectionLost = false;
273         if (webSocketConnection.isConnected() || websocketPort == 0 || thingDisposing) {
274             return;
275         }
276
277         stopTimer();
278         connectionJob = scheduler.schedule(this::startWebSocketConnection, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
279
280         webSocketConnection.start(config.getHostWithoutPort() + ":" + websocketPort);
281     }
282
283     /**
284      * Perform a request to the REST API for generating an API key.
285      *
286      */
287     private void requestApiKey() {
288         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Requesting API Key");
289         stopTimer();
290         String url = buildUrl(config.getHostWithoutPort(), config.httpPort);
291         http.post(url, "{\"devicetype\":\"openHAB\"}", config.timeout).thenAccept(this::parseAPIKeyResponse)
292                 .exceptionally(e -> {
293                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
294                     logger.warn("Authorisation failed", e);
295                     return null;
296                 });
297     }
298
299     @Override
300     public void initialize() {
301         logger.debug("Start initializing bridge {}", thing.getUID());
302         thingDisposing = false;
303         config = getConfigAs(DeconzBridgeConfig.class);
304         webSocketConnection.setWatchdogInterval(config.websocketTimeout);
305         updateStatus(ThingStatus.UNKNOWN);
306         if (config.apikey == null) {
307             requestApiKey();
308         } else {
309             initializeBridgeState();
310         }
311     }
312
313     @Override
314     public void dispose() {
315         thingDisposing = true;
316         stopTimer();
317         webSocketConnection.dispose();
318     }
319
320     @Override
321     public void webSocketConnectionEstablished() {
322         stopTimer();
323         updateStatus(ThingStatus.ONLINE);
324     }
325
326     @Override
327     public void webSocketConnectionLost(String reason) {
328         if (ignoreConnectionLost) {
329             return;
330         }
331         ignoreConnectionLost = true;
332         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
333         stopTimer();
334
335         // make sure we get a new connection
336         webSocketConnection.dispose();
337         webSocketConnection = createNewWebSocketConnection();
338
339         // Wait for POLL_FREQUENCY_SEC after a connection was closed before trying again
340         connectionJob = scheduler.schedule(this::startWebSocketConnection, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
341     }
342
343     /**
344      * Return the websocket connection.
345      */
346     public WebSocketConnection getWebSocketConnection() {
347         return webSocketConnection;
348     }
349
350     /**
351      * Send an object to the gateway
352      *
353      * @param endPoint the endpoint (e.g. "lights/2/state")
354      * @param object the object (or null if no object)
355      * @param httpMethod the HTTP Method
356      * @return CompletableFuture of the result
357      */
358     public CompletableFuture<AsyncHttpClient.Result> sendObject(String endPoint, @Nullable Object object,
359             HttpMethod httpMethod) {
360         String json = object == null ? null : gson.toJson(object);
361         String url = buildUrl(config.host, config.httpPort, config.apikey, endPoint);
362         logger.trace("Sending {} via {} to {}", json, httpMethod, url);
363
364         if (httpMethod == HttpMethod.PUT) {
365             return http.put(url, json, config.timeout);
366         } else if (httpMethod == HttpMethod.POST) {
367             return http.post(url, json, config.timeout);
368         } else if (httpMethod == HttpMethod.DELETE) {
369             return http.delete(url, config.timeout);
370         }
371
372         return CompletableFuture.failedFuture(new IllegalArgumentException("Unknown HTTP Method"));
373     }
374 }