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