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