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