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