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.tradfri.internal.handler;
15 import static org.openhab.binding.tradfri.internal.TradfriBindingConstants.*;
17 import java.io.IOException;
19 import java.net.URISyntaxException;
20 import java.util.Collection;
21 import java.util.Collections;
22 import java.util.Objects;
24 import java.util.UUID;
25 import java.util.concurrent.CopyOnWriteArraySet;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import org.eclipse.californium.core.CoapClient;
30 import org.eclipse.californium.core.CoapResponse;
31 import org.eclipse.californium.core.network.CoapEndpoint;
32 import org.eclipse.californium.elements.exception.ConnectorException;
33 import org.eclipse.californium.scandium.DTLSConnector;
34 import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
35 import org.eclipse.californium.scandium.dtls.pskstore.AdvancedSinglePskStore;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.openhab.binding.tradfri.internal.CoapCallback;
39 import org.openhab.binding.tradfri.internal.DeviceUpdateListener;
40 import org.openhab.binding.tradfri.internal.TradfriBindingConstants;
41 import org.openhab.binding.tradfri.internal.TradfriCoapClient;
42 import org.openhab.binding.tradfri.internal.TradfriCoapHandler;
43 import org.openhab.binding.tradfri.internal.config.TradfriGatewayConfig;
44 import org.openhab.binding.tradfri.internal.discovery.TradfriDiscoveryService;
45 import org.openhab.binding.tradfri.internal.model.TradfriVersion;
46 import org.openhab.core.config.core.Configuration;
47 import org.openhab.core.thing.Bridge;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.thing.ThingStatus;
51 import org.openhab.core.thing.ThingStatusDetail;
52 import org.openhab.core.thing.binding.BaseBridgeHandler;
53 import org.openhab.core.thing.binding.ThingHandler;
54 import org.openhab.core.thing.binding.ThingHandlerService;
55 import org.openhab.core.types.Command;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
59 import com.google.gson.JsonArray;
60 import com.google.gson.JsonElement;
61 import com.google.gson.JsonObject;
62 import com.google.gson.JsonParseException;
63 import com.google.gson.JsonParser;
64 import com.google.gson.JsonSyntaxException;
67 * The {@link TradfriGatewayHandler} is responsible for handling commands, which are
68 * sent to one of the channels.
70 * @author Kai Kreuzer - Initial contribution
73 public class TradfriGatewayHandler extends BaseBridgeHandler implements CoapCallback {
75 protected final Logger logger = LoggerFactory.getLogger(getClass());
77 private static final TradfriVersion MIN_SUPPORTED_VERSION = new TradfriVersion("1.2.42");
79 private @NonNullByDefault({}) TradfriCoapClient deviceClient;
80 private @NonNullByDefault({}) String gatewayURI;
81 private @NonNullByDefault({}) String gatewayInfoURI;
82 private @NonNullByDefault({}) DTLSConnector dtlsConnector;
83 private @Nullable CoapEndpoint endPoint;
85 private final Set<DeviceUpdateListener> deviceUpdateListeners = new CopyOnWriteArraySet<>();
87 private @Nullable ScheduledFuture<?> scanJob;
89 public TradfriGatewayHandler(Bridge bridge) {
94 public void handleCommand(ChannelUID channelUID, Command command) {
95 // there are no channels on the gateway yet
99 public void initialize() {
100 TradfriGatewayConfig configuration = getConfigAs(TradfriGatewayConfig.class);
102 if (isNullOrEmpty(configuration.host)) {
103 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
104 "Host must be specified in the configuration!");
108 if (isNullOrEmpty(configuration.code)) {
109 if (isNullOrEmpty(configuration.identity) || isNullOrEmpty(configuration.preSharedKey)) {
110 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
111 "Either security code or identity and pre-shared key must be provided in the configuration!");
114 establishConnection();
117 String currentFirmware = thing.getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION);
118 if (!isNullOrEmpty(currentFirmware) && MIN_SUPPORTED_VERSION
119 .compareTo(new TradfriVersion(Objects.requireNonNull(currentFirmware))) > 0) {
120 // older firmware not supported
121 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
123 "Gateway firmware version '%s' is too old! Minimum supported firmware version is '%s'.",
124 currentFirmware, MIN_SUPPORTED_VERSION.toString()));
128 // Running async operation to retrieve new <'identity','key'> pair
129 scheduler.execute(() -> {
130 boolean success = obtainIdentityAndPreSharedKey();
132 establishConnection();
139 public Collection<Class<? extends ThingHandlerService>> getServices() {
140 return Collections.singleton(TradfriDiscoveryService.class);
143 private void establishConnection() {
144 TradfriGatewayConfig configuration = getConfigAs(TradfriGatewayConfig.class);
146 this.gatewayURI = "coaps://" + configuration.host + ":" + configuration.port + "/" + DEVICES;
147 this.gatewayInfoURI = "coaps://" + configuration.host + ":" + configuration.port + "/" + GATEWAY + "/"
150 URI uri = new URI(gatewayURI);
151 deviceClient = new TradfriCoapClient(uri);
152 } catch (URISyntaxException e) {
153 logger.error("Illegal gateway URI '{}': {}", gatewayURI, e.getMessage());
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
158 DtlsConnectorConfig.Builder builder = new DtlsConnectorConfig.Builder();
159 builder.setAdvancedPskStore(
160 new AdvancedSinglePskStore(configuration.identity, configuration.preSharedKey.getBytes()));
161 builder.setMaxConnections(100);
162 builder.setStaleConnectionThreshold(60);
163 dtlsConnector = new DTLSConnector(builder.build());
164 endPoint = new CoapEndpoint.Builder().setConnector(dtlsConnector).build();
165 deviceClient.setEndpoint(endPoint);
166 updateStatus(ThingStatus.UNKNOWN);
168 // schedule a new scan every minute
169 scanJob = scheduler.scheduleWithFixedDelay(this::startScan, 0, 1, TimeUnit.MINUTES);
173 * Authenticates against the gateway with the security code in order to receive a pre-shared key for a newly
174 * generated identity.
175 * As this requires a remote request, this method might be long-running.
177 * @return true, if credentials were successfully obtained, false otherwise
179 protected boolean obtainIdentityAndPreSharedKey() {
180 TradfriGatewayConfig configuration = getConfigAs(TradfriGatewayConfig.class);
182 String identity = UUID.randomUUID().toString().replace("-", "");
183 String preSharedKey = null;
185 CoapResponse gatewayResponse;
186 String authUrl = null;
187 String responseText = null;
189 DtlsConnectorConfig.Builder builder = new DtlsConnectorConfig.Builder();
190 builder.setAdvancedPskStore(new AdvancedSinglePskStore("Client_identity", configuration.code.getBytes()));
192 DTLSConnector dtlsConnector = new DTLSConnector(builder.build());
193 CoapEndpoint.Builder authEndpointBuilder = new CoapEndpoint.Builder();
194 authEndpointBuilder.setConnector(dtlsConnector);
195 CoapEndpoint authEndpoint = authEndpointBuilder.build();
196 authUrl = "coaps://" + configuration.host + ":" + configuration.port + "/15011/9063";
198 CoapClient deviceClient = new CoapClient(new URI(authUrl));
199 deviceClient.setTimeout(TimeUnit.SECONDS.toMillis(10));
200 deviceClient.setEndpoint(authEndpoint);
202 JsonObject json = new JsonObject();
203 json.addProperty(CLIENT_IDENTITY_PROPOSED, identity);
205 gatewayResponse = deviceClient.post(json.toString(), 0);
207 authEndpoint.destroy();
208 deviceClient.shutdown();
210 if (gatewayResponse == null) {
211 // seems we ran in a timeout, which potentially also happens
212 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
213 "No response from gateway. Might be due to an invalid security code.");
217 if (gatewayResponse.isSuccess()) {
218 responseText = gatewayResponse.getResponseText();
219 json = JsonParser.parseString(responseText).getAsJsonObject();
220 preSharedKey = json.get(NEW_PSK_BY_GW).getAsString();
222 if (isNullOrEmpty(preSharedKey)) {
223 logger.error("Received pre-shared key is empty for thing {} on gateway at {}", getThing().getUID(),
225 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
226 "Pre-shared key was not obtain successfully");
229 logger.debug("Received pre-shared key for gateway '{}'", configuration.host);
230 logger.debug("Using identity '{}' with pre-shared key '{}'.", identity, preSharedKey);
232 Configuration editedConfig = editConfiguration();
233 editedConfig.put(TradfriBindingConstants.GATEWAY_CONFIG_CODE, null);
234 editedConfig.put(TradfriBindingConstants.GATEWAY_CONFIG_IDENTITY, identity);
235 editedConfig.put(TradfriBindingConstants.GATEWAY_CONFIG_PRE_SHARED_KEY, preSharedKey);
236 updateConfiguration(editedConfig);
242 "Failed obtaining pre-shared key for identity '{}' (response code '{}', response text '{}')",
243 identity, gatewayResponse.getCode(),
244 isNullOrEmpty(gatewayResponse.getResponseText()) ? "<empty>"
245 : gatewayResponse.getResponseText());
246 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
247 .format("Failed obtaining pre-shared key with status code '%s'", gatewayResponse.getCode()));
249 } catch (URISyntaxException e) {
250 logger.error("Illegal gateway URI '{}'", authUrl, e);
251 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
252 } catch (JsonParseException e) {
253 logger.warn("Invalid response received from gateway '{}'", responseText, e);
254 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
255 String.format("Invalid response received from gateway '%s'", responseText));
256 } catch (ConnectorException | IOException e) {
257 logger.debug("Error connecting to gateway ", e);
258 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
259 String.format("Error connecting to gateway."));
265 public void dispose() {
266 if (scanJob != null) {
267 scanJob.cancel(true);
270 if (endPoint != null) {
274 if (deviceClient != null) {
275 deviceClient.shutdown();
282 * Does a request to the gateway to list all available devices/services.
283 * The response is received and processed by the method {@link onUpdate(JsonElement data)}.
285 public void startScan() {
286 if (endPoint != null) {
287 requestGatewayInfo();
288 deviceClient.get(new TradfriCoapHandler(this));
293 * Returns the root URI of the gateway.
295 * @return root URI of the gateway with coaps scheme
297 public String getGatewayURI() {
302 * Returns the coap endpoint that can be used within coap clients.
304 * @return the coap endpoint
306 public @Nullable CoapEndpoint getEndpoint() {
311 public void onUpdate(JsonElement data) {
312 logger.debug("onUpdate response: {}", data);
313 if (endPoint != null) {
315 JsonArray array = data.getAsJsonArray();
316 for (int i = 0; i < array.size(); i++) {
317 requestDeviceDetails(array.get(i).getAsString());
319 } catch (JsonSyntaxException e) {
320 logger.debug("JSON error: {}", e.getMessage());
321 setStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
326 private synchronized void requestGatewayInfo() {
327 // we are reusing our coap client and merely temporarily set a gateway info to call
328 deviceClient.setURI(gatewayInfoURI);
329 deviceClient.asyncGet().thenAccept(data -> {
330 logger.debug("requestGatewayInfo response: {}", data);
331 JsonObject json = JsonParser.parseString(data).getAsJsonObject();
332 String firmwareVersion = json.get(VERSION).getAsString();
333 getThing().setProperty(Thing.PROPERTY_FIRMWARE_VERSION, firmwareVersion);
334 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
337 deviceClient.setURI(gatewayURI);
340 private synchronized void requestDeviceDetails(String instanceId) {
341 // we are reusing our coap client and merely temporarily set a sub-URI to call
342 deviceClient.setURI(gatewayURI + "/" + instanceId);
343 deviceClient.asyncGet().thenAccept(data -> {
344 logger.debug("requestDeviceDetails response: {}", data);
345 JsonObject json = JsonParser.parseString(data).getAsJsonObject();
346 deviceUpdateListeners.forEach(listener -> listener.onUpdate(instanceId, json));
349 deviceClient.setURI(gatewayURI);
353 public void setStatus(ThingStatus status, ThingStatusDetail statusDetail) {
354 // to fix connection issues after a gateway reboot, a session resume is forced for the next command
355 if (status == ThingStatus.OFFLINE && statusDetail == ThingStatusDetail.COMMUNICATION_ERROR) {
356 logger.debug("Gateway communication error. Forcing a re-initialization!");
361 // are we still connected at all?
362 if (endPoint != null) {
363 updateStatus(status, statusDetail);
368 * Registers a listener, which is informed about device details.
370 * @param listener the listener to register
372 public void registerDeviceUpdateListener(DeviceUpdateListener listener) {
373 this.deviceUpdateListeners.add(listener);
377 * Unregisters a given listener.
379 * @param listener the listener to unregister
381 public void unregisterDeviceUpdateListener(DeviceUpdateListener listener) {
382 this.deviceUpdateListeners.remove(listener);
385 private boolean isNullOrEmpty(@Nullable String string) {
386 return string == null || string.isEmpty();
390 public void thingUpdated(Thing thing) {
391 super.thingUpdated(thing);
393 logger.info("Bridge configuration updated. Updating paired things (if any).");
394 for (Thing t : getThing().getThings()) {
395 final ThingHandler thingHandler = t.getHandler();
396 if (thingHandler != null) {
397 thingHandler.thingUpdated(t);