]> git.basschouten.com Git - openhab-addons.git/blob
66416db0a19ac336b8620e0aae9bac8f6605a170
[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.shelly.internal.coap;
14
15 import static org.openhab.binding.shelly.internal.ShellyBindingConstants.*;
16 import static org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.*;
17 import static org.openhab.binding.shelly.internal.util.ShellyUtils.*;
18
19 import java.net.UnknownHostException;
20 import java.util.LinkedHashMap;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.TreeMap;
24
25 import org.eclipse.californium.core.CoapClient;
26 import org.eclipse.californium.core.coap.CoAP.Code;
27 import org.eclipse.californium.core.coap.CoAP.ResponseCode;
28 import org.eclipse.californium.core.coap.CoAP.Type;
29 import org.eclipse.californium.core.coap.MessageObserverAdapter;
30 import org.eclipse.californium.core.coap.Option;
31 import org.eclipse.californium.core.coap.OptionNumberRegistry;
32 import org.eclipse.californium.core.coap.Request;
33 import org.eclipse.californium.core.coap.Response;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.shelly.internal.api.ShellyApiException;
37 import org.openhab.binding.shelly.internal.api.ShellyDeviceProfile;
38 import org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.CoIotDescrBlk;
39 import org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.CoIotDescrSen;
40 import org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.CoIotDevDescrTypeAdapter;
41 import org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.CoIotDevDescription;
42 import org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.CoIotGenericSensorList;
43 import org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.CoIotSensor;
44 import org.openhab.binding.shelly.internal.coap.ShellyCoapJSonDTO.CoIotSensorTypeAdapter;
45 import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
46 import org.openhab.binding.shelly.internal.handler.ShellyBaseHandler;
47 import org.openhab.core.types.State;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import com.google.gson.Gson;
52 import com.google.gson.GsonBuilder;
53 import com.google.gson.JsonSyntaxException;
54
55 /**
56  * The {@link ShellyCoapHandler} handles the CoIoT/CoAP registration and events.
57  *
58  * @author Markus Michels - Initial contribution
59  */
60 @NonNullByDefault
61 public class ShellyCoapHandler implements ShellyCoapListener {
62     private static final byte[] EMPTY_BYTE = new byte[0];
63
64     private final Logger logger = LoggerFactory.getLogger(ShellyCoapHandler.class);
65     private final ShellyBaseHandler thingHandler;
66     private ShellyThingConfiguration config = new ShellyThingConfiguration();
67     private final GsonBuilder gsonBuilder = new GsonBuilder();
68     private final Gson gson;
69     private String thingName;
70
71     private boolean coiotBound = false;
72     private ShellyCoIoTInterface coiot;
73     private int coiotVers = -1;
74
75     private final ShellyCoapServer coapServer;
76     private @Nullable CoapClient statusClient;
77     private Request reqDescription = new Request(Code.GET, Type.CON);
78     private Request reqStatus = new Request(Code.GET, Type.CON);
79     private boolean discovering = false;
80
81     private int lastSerial = -1;
82     private String lastPayload = "";
83     private Map<String, CoIotDescrBlk> blkMap = new LinkedHashMap<>();
84     private Map<String, CoIotDescrSen> sensorMap = new LinkedHashMap<>();
85     private final ShellyDeviceProfile profile;
86
87     public ShellyCoapHandler(ShellyBaseHandler thingHandler, ShellyCoapServer coapServer) {
88         this.thingHandler = thingHandler;
89         this.thingName = thingHandler.thingName;
90         this.coapServer = coapServer;
91         this.coiot = new ShellyCoIoTVersion1(thingName, thingHandler, blkMap, sensorMap); // Default
92
93         gsonBuilder.registerTypeAdapter(CoIotDevDescription.class, new CoIotDevDescrTypeAdapter());
94         gsonBuilder.registerTypeAdapter(CoIotGenericSensorList.class, new CoIotSensorTypeAdapter());
95         gson = gsonBuilder.create();
96         profile = thingHandler.getProfile();
97     }
98
99     /**
100      * Initialize CoAP access, send discovery packet and start Status server
101      *
102      * @parm thingName Thing name derived from Thing Type/hostname
103      * @parm config ShellyThingConfiguration
104      * @thows ShellyApiException
105      */
106     public synchronized void start(String thingName, ShellyThingConfiguration config) throws ShellyApiException {
107         try {
108             this.thingName = thingName;
109             this.config = config;
110             if (isStarted()) {
111                 logger.trace("{}: CoAP Listener was already started", thingName);
112                 stop();
113             }
114
115             logger.debug("{}: Starting CoAP Listener", thingName);
116             coapServer.start(config.localIp, this);
117             statusClient = new CoapClient(completeUrl(config.deviceIp, COLOIT_URI_DEVSTATUS))
118                     .setTimeout((long) SHELLY_API_TIMEOUT_MS).useNONs().setEndpoint(coapServer.getEndpoint());
119             discover();
120         } catch (UnknownHostException e) {
121             logger.debug("{}: CoAP Exception", thingName, e);
122             throw new ShellyApiException("Unknown Host: " + config.deviceIp, e);
123         }
124     }
125
126     public boolean isStarted() {
127         return statusClient != null;
128     }
129
130     /**
131      * Process an inbound Response (or mapped Request): decode CoAP options. handle discovery result or status updates
132      *
133      * @param response The Response packet
134      */
135     @Override
136     public void processResponse(@Nullable Response response) {
137         if (response == null) {
138             return; // other device instance
139         }
140         String ip = response.getSourceContext().getPeerAddress().toString();
141         if (!ip.contains(config.deviceIp)) {
142             return;
143         }
144
145         String payload = "";
146         String devId = "";
147         String uri = "";
148         // int validity = 0;
149         int serial = -1;
150         try {
151             if (logger.isDebugEnabled()) {
152                 logger.debug("{}: CoIoT Message from {} (MID={}): {}", thingName,
153                         response.getSourceContext().getPeerAddress(), response.getMID(), response.getPayloadString());
154             }
155             if (response.isCanceled() || response.isDuplicate() || response.isRejected()) {
156                 logger.debug("{} ({}): Packet was canceled, rejected or is a duplicate -> discard", thingName, devId);
157                 return;
158             }
159
160             if (response.getCode() == ResponseCode.CONTENT) {
161                 payload = response.getPayloadString();
162                 List<Option> options = response.getOptions().asSortedList();
163                 int i = 0;
164                 while (i < options.size()) {
165                     Option opt = options.get(i);
166                     switch (opt.getNumber()) {
167                         case OptionNumberRegistry.URI_PATH:
168                             uri = COLOIT_URI_BASE + opt.getStringValue();
169                             break;
170                         case COIOT_OPTION_GLOBAL_DEVID:
171                             devId = opt.getStringValue();
172                             String sVersion = substringAfterLast(devId, "#");
173                             int iVersion = Integer.parseInt(sVersion);
174                             if (coiotBound && (coiotVers != iVersion)) {
175                                 logger.debug(
176                                         "{}: CoIoT versopm has changed from {} to {}, maybe the firmware was upgraded",
177                                         thingName, coiotVers, iVersion);
178                                 thingHandler.reinitializeThing();
179                                 coiotBound = false;
180                             }
181                             if (!coiotBound) {
182                                 thingHandler.updateProperties(PROPERTY_COAP_VERSION, sVersion);
183                                 logger.debug("{}: CoIoT Version {} detected", thingName, iVersion);
184                                 if (iVersion == COIOT_VERSION_1) {
185                                     coiot = new ShellyCoIoTVersion1(thingName, thingHandler, blkMap, sensorMap);
186                                 } else if (iVersion == COIOT_VERSION_2) {
187                                     coiot = new ShellyCoIoTVersion2(thingName, thingHandler, blkMap, sensorMap);
188                                 } else {
189                                     logger.warn("{}: Unsupported CoAP version detected: {}", thingName, sVersion);
190                                     return;
191                                 }
192                                 coiotVers = iVersion;
193                                 coiotBound = true;
194                             }
195                             break;
196                         case COIOT_OPTION_STATUS_VALIDITY:
197                             // validity = o.getIntegerValue();
198                             break;
199                         case COIOT_OPTION_STATUS_SERIAL:
200                             serial = opt.getIntegerValue();
201                             break;
202                         default:
203                             logger.debug("{} ({}): COAP option {} with value {} skipped", thingName, devId,
204                                     opt.getNumber(), opt.getValue());
205                     }
206                     i++;
207                 }
208
209                 // If we received a CoAP message successful the thing must be online
210                 thingHandler.setThingOnline();
211
212                 // The device changes the serial on every update, receiving a message with the same serial is a
213                 // duplicate, excep for battery devices! Those reset the serial every time when they wake-up
214                 if ((serial == lastSerial) && payload.equals(lastPayload)
215                         && (!profile.hasBattery || ((serial & 0xFF) != 0))) {
216                     logger.debug("{}: Serial {} was already processed, ignore update", thingName, serial);
217                     return;
218                 }
219
220                 // fixed malformed JSON :-(
221                 payload = fixJSON(payload);
222
223                 if (uri.equalsIgnoreCase(COLOIT_URI_DEVDESC) || (uri.isEmpty() && payload.contains(COIOT_TAG_BLK))) {
224                     handleDeviceDescription(devId, payload);
225                 } else if (uri.equalsIgnoreCase(COLOIT_URI_DEVSTATUS)
226                         || (uri.isEmpty() && payload.contains(COIOT_TAG_GENERIC))) {
227                     handleStatusUpdate(devId, payload, serial);
228                 }
229             } else {
230                 // error handling
231                 logger.debug("{}: Unknown Response Code {} received, payload={}", thingName, response.getCode(),
232                         response.getPayloadString());
233             }
234
235             if (!discovering) {
236                 // Observe Status Updates
237                 reqStatus = sendRequest(reqStatus, config.deviceIp, COLOIT_URI_DEVSTATUS, Type.NON);
238                 discovering = true;
239             }
240         } catch (IllegalArgumentException | NullPointerException e) {
241             logger.debug("{}: Unable to process CoIoT Message for payload={}", thingName, payload, e);
242             resetSerial();
243         }
244     }
245
246     /**
247      * Process a CoIoT device description message. This includes definitions on device units (Relay0, Relay1, Sensors
248      * etc.) as well as a definition of sensors and actors. This information needs to be stored allowing to map ids from
249      * status updates to the device units and matching the correct thing channel.
250      *
251      * @param devId The device id reported in the CoIoT message.
252      * @param payload Device desciption in JSon format, example:
253      *            {"blk":[{"I":0,"D":"Relay0"}],"sen":[{"I":112,"T":"Switch","R":"0/1","L":0}],"act":[{"I":211,"D":"Switch","L":0,"P":[{"I":2011,"D":"ToState","R":"0/1"}]}]}
254      */
255     private void handleDeviceDescription(String devId, String payload) {
256         logger.debug("{}: CoIoT Device Description for {}: {}", thingName, devId, payload);
257
258         try {
259             boolean valid = true;
260
261             // Decode Json
262             CoIotDevDescription descr = gson.fromJson(payload, CoIotDevDescription.class);
263             for (int i = 0; i < descr.blk.size(); i++) {
264                 CoIotDescrBlk blk = descr.blk.get(i);
265                 logger.debug("{}:    id={}: {}", thingName, blk.id, blk.desc);
266                 if (!blkMap.containsKey(blk.id)) {
267                     blkMap.put(blk.id, blk);
268                 } else {
269                     blkMap.replace(blk.id, blk);
270                 }
271                 if ((blk.type != null) && !blk.type.isEmpty()) {
272                     // in fact it is a sen entry - that's vioaling the Spec
273                     logger.trace("{}:    fix: auto-create sensor definition for id {}/{}!", thingName, blk.id,
274                             blk.desc);
275                     CoIotDescrSen sen = new CoIotDescrSen();
276                     sen.id = blk.id;
277                     sen.desc = blk.desc;
278                     sen.type = blk.type;
279                     sen.range = blk.range;
280                     sen.links = blk.links;
281                     valid &= addSensor(sen);
282                 }
283             }
284
285             // Save to thing properties
286             thingHandler.updateProperties(PROPERTY_COAP_DESCR, payload);
287
288             logger.debug("{}: Adding {} sensor definitions", thingName, descr.sen.size());
289             if (descr.sen != null) {
290                 for (int i = 0; i < descr.sen.size(); i++) {
291                     valid &= addSensor(descr.sen.get(i));
292                 }
293             }
294
295             if (!valid) {
296                 logger.debug(
297                         "{}: Incompatible device description detected for CoIoT version {} (id length mismatch), discarding!",
298                         thingName, coiot.getVersion());
299                 thingHandler.updateProperties(PROPERTY_COAP_DESCR, "");
300                 discover();
301                 return;
302             }
303         } catch (JsonSyntaxException e) {
304             logger.warn("{}: Unable to parse CoAP Device Description! JSON={}", thingName, payload);
305         } catch (NullPointerException | IllegalArgumentException e) {
306             logger.warn("{}: Unable to parse CoAP Device Description! JSON={}", thingName, payload, e);
307         }
308     }
309
310     /**
311      * Add a new sensor to the sensor table
312      *
313      * @param sen CoIotDescrSen of the sensor
314      */
315     private synchronized boolean addSensor(CoIotDescrSen sen) {
316         logger.debug("{}:    id {}: {}, Type={}, Range={}, Links={}", thingName, sen.id, sen.desc, sen.type, sen.range,
317                 sen.links);
318         // CoIoT version 2 changes from 3 digit IDs to 4 digit IDs
319         // We need to make sure that the persisted device description matches,
320         // otherwise the stored one is discarded and a new discovery is triggered
321         // This happens on firmware up/downgrades (version 1.8 brings CoIoT v2 with 4 digit IDs)
322         int vers = coiot.getVersion();
323         if (((vers == COIOT_VERSION_1) && (sen.id.length() > 3))
324                 || ((vers >= COIOT_VERSION_2) && (sen.id.length() < 4))) {
325             return false;
326         }
327
328         try {
329             CoIotDescrSen fixed = coiot.fixDescription(sen, blkMap);
330             if (!sensorMap.containsKey(fixed.id)) {
331                 sensorMap.put(sen.id, fixed);
332             } else {
333                 sensorMap.replace(sen.id, fixed);
334             }
335         } catch (NullPointerException | IllegalArgumentException e) { // depending on firmware release the CoAP device
336                                                                       // description is buggy
337             logger.debug("{}: Unable to decode sensor definition -> skip", thingName, e);
338         }
339
340         return true;
341     }
342
343     /**
344      * Process CoIoT status update message. If a status update is received, but the device description has not been
345      * received yet a GET is send to query device description.
346      *
347      * @param devId device id included in the status packet
348      * @param payload CoAP payload (Json format), example: {"G":[[0,112,0]]}
349      * @param serial Serial for this request. If this the the same as last serial
350      *            the update was already sent and processed so this one gets
351      *            ignored.
352      */
353     private void handleStatusUpdate(String devId, String payload, int serial) {
354         logger.debug("{}: CoIoT Sensor data {} (serial={})", thingName, payload, serial);
355         if (blkMap.isEmpty()) {
356             // send discovery packet
357             resetSerial();
358             discover();
359
360             // try to uses description from last initialization
361             String savedDescr = thingHandler.getProperty(PROPERTY_COAP_DESCR);
362             if (savedDescr.isEmpty()) {
363                 logger.debug("{}: Device description not yet received, trigger auto-initialization", thingName);
364                 return;
365             }
366
367             // simulate received device description to create element table
368             logger.debug("{}: Device description for {} restored: {}", thingName, devId, savedDescr);
369             handleDeviceDescription(devId, savedDescr);
370         }
371
372         // Parse Json,
373         CoIotGenericSensorList list = gson.fromJson(fixJSON(payload), CoIotGenericSensorList.class);
374         if (list.generic == null) {
375             logger.debug("{}: Sensor list has invalid format! Payload: {}", devId, payload);
376             return;
377         }
378
379         List<CoIotSensor> sensorUpdates = list.generic;
380         Map<String, State> updates = new TreeMap<String, State>();
381         logger.debug("{}: {} CoAP sensor updates received", thingName, sensorUpdates.size());
382         int failed = 0;
383         for (int i = 0; i < sensorUpdates.size(); i++) {
384             try {
385                 CoIotSensor s = sensorUpdates.get(i);
386                 if (!sensorMap.containsKey(s.id)) {
387                     logger.debug("{}: Invalid id in sensor description: {}, index {}", thingName, s.id, i);
388                     failed++;
389                     continue;
390                 }
391                 CoIotDescrSen sen = sensorMap.get(s.id);
392                 // find matching sensor definition from device description, use the Link ID as index
393                 sen = coiot.fixDescription(sen, blkMap);
394                 if (!blkMap.containsKey(sen.links)) {
395                     logger.debug("{}: Invalid CoAP description: sen.links({}", thingName, getString(sen.links));
396                     continue;
397                 }
398
399                 if (!blkMap.containsKey(sen.links)) {
400                     logger.debug("{}: Unable to find BLK for link {} from sen.id={}", thingName, sen.links, sen.id);
401                     continue;
402                 }
403                 CoIotDescrBlk element = blkMap.get(sen.links);
404                 logger.trace("{}:  Sensor value[{}]: id={}, Value={} ({}, Type={}, Range={}, Link={}: {})", thingName,
405                         i, s.id, getString(s.valueStr).isEmpty() ? s.value : s.valueStr, sen.desc, sen.type, sen.range,
406                         sen.links, element.desc);
407
408                 if (!coiot.handleStatusUpdate(sensorUpdates, sen, s, updates)) {
409                     logger.debug("{}: CoIoT data for id {}, type {}/{} not processed, value={}; payload={}", thingName,
410                             sen.id, sen.type, sen.desc, s.value, payload);
411                 }
412             } catch (NullPointerException | IllegalArgumentException e) {
413                 // even the processing of one value failed we continue with the next one (sometimes this is caused by
414                 // buggy formats provided by the device
415                 logger.debug("{}: Unable to process data from sensor[{}], devId={}, payload={}", thingName, i, devId,
416                         payload, e);
417             }
418         }
419
420         if (!updates.isEmpty()) {
421             int updated = 0;
422             for (Map.Entry<String, State> u : updates.entrySet()) {
423                 updated += thingHandler.updateChannel(u.getKey(), u.getValue(), false) ? 1 : 0;
424             }
425             if (updated > 0) {
426                 logger.debug("{}: {} channels updated from CoIoT status, serial={}", thingName, updated, serial);
427                 if (profile.isSensor || profile.isRoller) {
428                     // CoAP is currently lacking the lastUpdate info, so we use host timestamp
429                     thingHandler.updateChannel(profile.getControlGroup(0), CHANNEL_LAST_UPDATE, getTimestamp());
430                 }
431             }
432
433             // Old firmware release are lacking various status values, which are not updated using CoIoT.
434             // In this case we keep a refresh so it gets polled using REST. Beginning with Firmware 1.6 most
435             // of the values are available
436             if ((!thingHandler.autoCoIoT && (thingHandler.scheduledUpdates <= 1))
437                     || (thingHandler.autoCoIoT && !profile.isLight && !profile.hasBattery)) {
438                 thingHandler.requestUpdates(1, false);
439             }
440         } else {
441             if (failed == sensorUpdates.size()) {
442                 logger.debug("{}: Device description problem detected, re-discover", thingName);
443                 coiotBound = false;
444                 discover();
445             }
446         }
447
448         // Remember serial, new packets with same serial will be ignored
449         lastSerial = serial;
450         lastPayload = payload;
451     }
452
453     private void discover() {
454         reqDescription = sendRequest(reqDescription, config.deviceIp, COLOIT_URI_DEVDESC, Type.CON);
455     }
456
457     /**
458      * Fix malformed JSON - stupid, but the devices sometimes return malformed JSON with then causes a
459      * JsonSyntaxException
460      *
461      * @param json to be checked/fixed
462      */
463     private static String fixJSON(String payload) {
464         String json = payload;
465         json = json.replace("}{", "},{");
466         json = json.replace("][", "],[");
467         json = json.replace("],,[", "],[");
468         return json;
469     }
470
471     /**
472      * Send a new request (Discovery to get Device Description). Before a pending
473      * request will be canceled.
474      *
475      * @param request The current request (this will be canceled an a new one will
476      *            be created)
477      * @param ipAddress Device's IP address
478      * @param uri The URI we are calling (CoIoT = /cit/d or /cit/s)
479      * @param con true: send as CON, false: send as NON
480      * @return new packet
481      */
482     private Request sendRequest(@Nullable Request request, String ipAddress, String uri, Type con) {
483         if ((request != null) && !request.isCanceled()) {
484             request.cancel();
485         }
486
487         resetSerial();
488         return newRequest(ipAddress, uri, con).send();
489     }
490
491     /**
492      * Allocate a new Request structure. A message observer will be added to get the
493      * callback when a response has been received.
494      *
495      * @param ipAddress IP address of the device
496      * @param uri URI to be addressed
497      * @param uri The URI we are calling (CoIoT = /cit/d or /cit/s)
498      * @param con true: send as CON, false: send as NON
499      * @return new packet
500      */
501
502     private Request newRequest(String ipAddress, String uri, Type con) {
503         // We need to build our own Request to set an empty Token
504         Request request = new Request(Code.GET, con);
505         request.setURI(completeUrl(ipAddress, uri));
506         request.setToken(EMPTY_BYTE);
507         request.addMessageObserver(new MessageObserverAdapter() {
508             @Override
509             public void onResponse(@Nullable Response response) {
510                 processResponse(response);
511             }
512
513             @Override
514             public void onCancel() {
515                 logger.debug("{}: CoAP Request was canceled", thingName);
516             }
517
518             @Override
519             public void onTimeout() {
520                 logger.debug("{}: CoAP Request timed out", thingName);
521             }
522         });
523         return request;
524     }
525
526     /**
527      * Reset serial and payload used to detect duplicate messages, which have to be ignored.
528      * We can't rely that the device manages serials correctly all the time. There are firmware releases sending updated
529      * sensor information with the serial from the last packet, which is wrong. We bypass this problem by comparing also
530      * the payload.
531      */
532     private void resetSerial() {
533         lastSerial = -1;
534         lastPayload = "";
535     }
536
537     public int getVersion() {
538         return coiotVers;
539     }
540
541     /**
542      * Cancel pending requests and shutdown the client
543      */
544     public synchronized void stop() {
545         if (isStarted()) {
546             logger.debug("{}: Stopping CoAP Listener", thingName);
547             coapServer.stop(this);
548             if (statusClient != null) {
549                 statusClient.shutdown();
550                 statusClient = null;
551             }
552             if (!reqDescription.isCanceled()) {
553                 reqDescription.cancel();
554             }
555             if (!reqStatus.isCanceled()) {
556                 reqStatus.cancel();
557             }
558         }
559         resetSerial();
560         coiotBound = false;
561     }
562
563     public void dispose() {
564         stop();
565     }
566
567     private static String completeUrl(String ipAddress, String uri) {
568         return "coap://" + ipAddress + ":" + COIOT_PORT + uri;
569     }
570 }