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