]> git.basschouten.com Git - openhab-addons.git/blob
7a4b1633df74fab61aa09d2586ab0b5c69d109e0
[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 websocketReconnect = 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 Collections.singleton(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 (r.getResponseCode() == 403) {
127             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
128                     "Allow authentication for 3rd party apps. Trying again in " + POLL_FREQUENCY_SEC + " seconds");
129             stopTimer();
130             scheduledFuture = scheduler.schedule(() -> requestApiKey(), POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
131         } else if (r.getResponseCode() == 200) {
132             ApiKeyMessage[] response = Objects.requireNonNull(gson.fromJson(r.getBody(), ApiKeyMessage[].class));
133             if (response.length == 0) {
134                 throw new IllegalStateException("Authorisation request response is empty");
135             }
136             config.apikey = response[0].success.username;
137             Configuration configuration = editConfiguration();
138             configuration.put(CONFIG_APIKEY, config.apikey);
139             updateConfiguration(configuration);
140             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Waiting for configuration");
141             initializeBridgeState();
142         } else {
143             throw new IllegalStateException("Unknown status code for authorisation request");
144         }
145     }
146
147     /**
148      * get the full state of the bridge from the cache
149      *
150      * @return a CompletableFuture that returns an Optional of the bridge full state
151      */
152     public CompletableFuture<Optional<BridgeFullState>> getBridgeFullState() {
153         return fullStateCache.getValue(this::refreshFullStateCache);
154     }
155
156     /**
157      * refresh the full bridge state (used for initial processing and state-lookup)
158      *
159      * @return Completable future with an Optional of the BridgeFullState
160      */
161     private CompletableFuture<Optional<BridgeFullState>> refreshFullStateCache() {
162         logger.trace("{} starts refreshing the fullStateCache", thing.getUID());
163         if (config.apikey == null) {
164             return CompletableFuture.completedFuture(Optional.empty());
165         }
166         String url = buildUrl(config.getHostWithoutPort(), config.httpPort, config.apikey);
167         return http.get(url, config.timeout).thenApply(r -> {
168             if (r.getResponseCode() == 403) {
169                 return Optional.ofNullable((BridgeFullState) null);
170             } else if (r.getResponseCode() == 200) {
171                 return Optional.ofNullable(gson.fromJson(r.getBody(), BridgeFullState.class));
172             } else {
173                 throw new IllegalStateException("Unknown status code for full state request");
174             }
175         }).handle((v, t) -> {
176             if (t == null) {
177                 return v;
178             } else if (t instanceof SocketTimeoutException || t instanceof TimeoutException
179                     || t instanceof CompletionException) {
180                 logger.debug("Get full state failed", t);
181             } else {
182                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, t.getMessage());
183             }
184             return Optional.empty();
185         });
186     }
187
188     /**
189      * Perform a request to the REST API for retrieving the full bridge state with all sensors and switches
190      * and configuration.
191      */
192     public void initializeBridgeState() {
193         getBridgeFullState().thenAccept(fullState -> fullState.ifPresentOrElse(state -> {
194             if (state.config.name.isEmpty()) {
195                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
196                         "You are connected to a HUE bridge, not a deCONZ software!");
197                 return;
198             }
199             if (state.config.websocketport == 0) {
200                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
201                         "deCONZ software too old. No websocket support!");
202                 return;
203             }
204
205             // Add some information about the bridge
206             Map<String, String> editProperties = editProperties();
207             editProperties.put("apiversion", state.config.apiversion);
208             editProperties.put("swversion", state.config.swversion);
209             editProperties.put("fwversion", state.config.fwversion);
210             editProperties.put("uuid", state.config.uuid);
211             editProperties.put("zigbeechannel", String.valueOf(state.config.zigbeechannel));
212             editProperties.put("ipaddress", state.config.ipaddress);
213             ignoreConfigurationUpdate = true;
214             updateProperties(editProperties);
215             ignoreConfigurationUpdate = false;
216
217             // Use requested websocket port if no specific port is given
218             websocketPort = config.port == 0 ? state.config.websocketport : config.port;
219             websocketReconnect = true;
220             startWebsocket();
221         }, () -> {
222             // initial response was empty, re-trying in POLL_FREQUENCY_SEC seconds
223             scheduledFuture = scheduler.schedule(this::initializeBridgeState, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
224         })).exceptionally(e -> {
225             if (e != null) {
226                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
227             } else {
228                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
229             }
230             logger.warn("Initial full state parsing failed", e);
231             return null;
232         });
233     }
234
235     /**
236      * Starts the websocket connection.
237      * {@link #initializeBridgeState} need to be called first to obtain the websocket port.
238      */
239     private void startWebsocket() {
240         if (websocket.isConnected() || websocketPort == 0 || websocketReconnect == false) {
241             return;
242         }
243
244         stopTimer();
245         scheduledFuture = scheduler.schedule(this::startWebsocket, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
246
247         websocket.start(config.getHostWithoutPort() + ":" + websocketPort);
248     }
249
250     /**
251      * Perform a request to the REST API for generating an API key.
252      *
253      */
254     private CompletableFuture<?> requestApiKey() {
255         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Requesting API Key");
256         stopTimer();
257         String url = buildUrl(config.getHostWithoutPort(), config.httpPort);
258         return http.post(url, "{\"devicetype\":\"openHAB\"}", config.timeout).thenAccept(this::parseAPIKeyResponse)
259                 .exceptionally(e -> {
260                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
261                     logger.warn("Authorisation failed", e);
262                     return null;
263                 });
264     }
265
266     @Override
267     public void initialize() {
268         logger.debug("Start initializing!");
269         config = getConfigAs(DeconzBridgeConfig.class);
270         if (config.apikey == null) {
271             requestApiKey();
272         } else {
273             initializeBridgeState();
274         }
275     }
276
277     @Override
278     public void dispose() {
279         websocketReconnect = false;
280         stopTimer();
281         websocket.close();
282     }
283
284     @Override
285     public void connectionError(@Nullable Throwable e) {
286         if (e != null) {
287             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
288         } else {
289             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Unknown reason");
290         }
291         stopTimer();
292         // Wait for POLL_FREQUENCY_SEC after a connection error before trying again
293         scheduledFuture = scheduler.schedule(this::startWebsocket, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
294     }
295
296     @Override
297     public void connectionEstablished() {
298         stopTimer();
299         updateStatus(ThingStatus.ONLINE);
300     }
301
302     @Override
303     public void connectionLost(String reason) {
304         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
305         startWebsocket();
306     }
307
308     /**
309      * Return the websocket connection.
310      */
311     public WebSocketConnection getWebsocketConnection() {
312         return websocket;
313     }
314
315     /**
316      * Return the http connection.
317      */
318     public AsyncHttpClient getHttp() {
319         return http;
320     }
321
322     /**
323      * Return the bridge configuration.
324      */
325     public DeconzBridgeConfig getBridgeConfig() {
326         return config;
327     }
328 }