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