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