2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.deconz.internal.handler;
15 import static org.openhab.binding.deconz.internal.BindingConstants.*;
16 import static org.openhab.binding.deconz.internal.Util.buildUrl;
18 import java.net.SocketTimeoutException;
19 import java.util.Collection;
21 import java.util.Objects;
22 import java.util.Optional;
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;
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;
56 import com.google.gson.Gson;
59 * The bridge Thing is responsible for requesting all available sensors and switches and propagate
60 * them to the discovery service.
62 * It performs the authorization process if necessary.
64 * A websocket connection is established to the deCONZ software and kept alive.
66 * @author David Graeff - Initial contribution
69 public class DeconzBridgeHandler extends BaseBridgeHandler implements WebSocketConnectionListener {
70 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(BRIDGE_TYPE);
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;
84 private final ExpiringCacheAsync<Optional<BridgeFullState>> fullStateCache = new ExpiringCacheAsync<>(1000);
86 /** The poll frequency for the API Key verification */
87 private static final int POLL_FREQUENCY_SEC = 10;
88 private boolean ignoreConnectionLost = true;
90 public DeconzBridgeHandler(Bridge thing, WebSocketFactory webSocketFactory, AsyncHttpClient http, Gson gson) {
94 this.webSocketFactory = webSocketFactory;
95 this.webSocketConnection = createNewWebSocketConnection();
98 private WebSocketConnection createNewWebSocketConnection() {
99 String websocketID = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
100 return new WebSocketConnection(this, webSocketFactory.createWebSocketClient(websocketID), gson,
101 config.websocketTimeout);
105 public Collection<Class<? extends ThingHandlerService>> getServices() {
106 return Set.of(ThingDiscoveryService.class, BridgeActions.class);
110 public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
111 if (!ignoreConfigurationUpdate) {
112 super.handleConfigurationUpdate(configurationParameters);
117 public void handleCommand(ChannelUID channelUID, Command command) {
121 public void thingUpdated(Thing thing) {
124 // we need to create a new websocket connection, because it can't be restarted
125 webSocketConnection = createNewWebSocketConnection();
130 * Stops the API request or websocket reconnect timer
132 private void stopTimer() {
133 ScheduledFuture<?> future = connectionJob;
134 if (future != null) {
135 future.cancel(false);
136 connectionJob = null;
141 * Parses the response message to the API key generation REST API.
143 * @param r The response
145 private void parseAPIKeyResponse(AsyncHttpClient.Result r) {
146 if (thingDisposing) {
147 // discard response if thing handler is already disposing
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");
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");
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();
167 throw new IllegalStateException("Unknown status code for authorisation request");
172 * get the full state of the bridge from the cache
174 * @return a CompletableFuture that returns an Optional of the bridge full state
176 public CompletableFuture<Optional<BridgeFullState>> getBridgeFullState() {
177 return fullStateCache.getValue(this::refreshFullStateCache);
181 * refresh the full bridge state (used for initial processing and state-lookup)
183 * @return Completable future with an Optional of the BridgeFullState
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());
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));
197 throw new IllegalStateException("Unknown status code for full state request");
199 }).handle((v, t) -> {
202 } else if (t instanceof SocketTimeoutException || t instanceof TimeoutException
203 || t instanceof CompletionException) {
204 logger.debug("Get full state failed", t);
206 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, t.getMessage());
208 return Optional.empty();
213 * Perform a request to the REST API for retrieving the full bridge state with all sensors and switches
216 public void initializeBridgeState() {
217 getBridgeFullState().thenAccept(fullState -> fullState.ifPresentOrElse(state -> {
218 if (thingDisposing) {
219 // discard response if thing handler is already disposing
222 if (state.config.name.isEmpty()) {
223 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
224 "You are connected to a HUE bridge, not a deCONZ software!");
227 if (state.config.websocketport == 0) {
228 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
229 "deCONZ software too old. No websocket support!");
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;
245 // Use requested websocket port if no specific port is given
246 websocketPort = config.port == 0 ? state.config.websocketport : config.port;
247 startWebSocketConnection();
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);
253 })).exceptionally(e -> {
255 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
257 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
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);
268 * Starts the websocket connection.
269 * {@link #initializeBridgeState} need to be called first to obtain the websocket port.
271 private void startWebSocketConnection() {
272 ignoreConnectionLost = false;
273 if (webSocketConnection.isConnected() || websocketPort == 0 || thingDisposing) {
278 connectionJob = scheduler.schedule(this::startWebSocketConnection, POLL_FREQUENCY_SEC, TimeUnit.SECONDS);
280 webSocketConnection.start(config.getHostWithoutPort() + ":" + websocketPort);
284 * Perform a request to the REST API for generating an API key.
287 private void requestApiKey() {
288 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Requesting API Key");
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);
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) {
309 initializeBridgeState();
314 public void dispose() {
315 thingDisposing = true;
317 webSocketConnection.dispose();
321 public void webSocketConnectionEstablished() {
323 updateStatus(ThingStatus.ONLINE);
327 public void webSocketConnectionLost(String reason) {
328 if (ignoreConnectionLost) {
331 ignoreConnectionLost = true;
332 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
335 // make sure we get a new connection
336 webSocketConnection.dispose();
337 webSocketConnection = createNewWebSocketConnection();
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);
344 * Return the websocket connection.
346 public WebSocketConnection getWebSocketConnection() {
347 return webSocketConnection;
351 * Send an object to the gateway
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
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);
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);
372 return CompletableFuture.failedFuture(new IllegalArgumentException("Unknown HTTP Method"));