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