2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.shelly.internal.coap;
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.*;
19 import java.net.UnknownHostException;
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;
48 import com.google.gson.Gson;
49 import com.google.gson.GsonBuilder;
50 import com.google.gson.JsonSyntaxException;
53 * The {@link ShellyCoapHandler} handles the CoIoT/CoAP registration and events.
55 * @author Markus Michels - Initial contribution
58 public class ShellyCoapHandler implements ShellyCoapListener {
59 private static final byte[] EMPTY_BYTE = new byte[0];
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;
68 private boolean coiotBound = false;
69 private ShellyCoIoTInterface coiot;
70 private int coiotVers = -1;
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;
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;
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
90 gsonBuilder.registerTypeAdapter(CoIotDevDescription.class, new CoIotDevDescrTypeAdapter());
91 gsonBuilder.registerTypeAdapter(CoIotGenericSensorList.class, new CoIotSensorTypeAdapter());
92 gson = gsonBuilder.create();
93 profile = thingHandler.getProfile();
97 * Initialize CoAP access, send discovery packet and start Status server
99 * @parm thingName Thing name derived from Thing Type/hostname
100 * @parm config ShellyThingConfiguration
101 * @thows ShellyApiException
103 public synchronized void start(String thingName, ShellyThingConfiguration config) throws ShellyApiException {
105 this.thingName = thingName;
106 this.config = config;
108 logger.trace("{}: CoAP Listener was already started", thingName);
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());
117 } catch (UnknownHostException e) {
118 logger.debug("{}: CoAP Exception", thingName, e);
119 throw new ShellyApiException("Unknown Host: " + config.deviceIp, e);
123 public boolean isStarted() {
124 return statusClient != null;
128 * Process an inbound Response (or mapped Request): decode CoAP options. handle discovery result or status updates
130 * @param response The Response packet
133 public void processResponse(@Nullable Response response) {
134 if (response == null) {
135 return; // other device instance
137 String ip = response.getSourceContext().getPeerAddress().toString();
138 if (!ip.contains(config.deviceIp)) {
148 if (logger.isDebugEnabled()) {
149 logger.debug("{}: CoIoT Message from {} (MID={}): {}", thingName,
150 response.getSourceContext().getPeerAddress(), response.getMID(), response.getPayloadString());
152 if (response.isCanceled() || response.isDuplicate() || response.isRejected()) {
153 logger.debug("{} ({}): Packet was canceled, rejected or is a duplicate -> discard", thingName, devId);
157 if (response.getCode() == ResponseCode.CONTENT) {
158 payload = response.getPayloadString();
159 List<Option> options = response.getOptions().asSortedList();
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();
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)) {
173 "{}: CoIoT versopm has changed from {} to {}, maybe the firmware was upgraded",
174 thingName, coiotVers, iVersion);
175 thingHandler.reinitializeThing();
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);
186 logger.warn("{}: Unsupported CoAP version detected: {}", thingName, sVersion);
189 coiotVers = iVersion;
193 case COIOT_OPTION_STATUS_VALIDITY:
194 // validity = o.getIntegerValue();
196 case COIOT_OPTION_STATUS_SERIAL:
197 serial = opt.getIntegerValue();
200 logger.debug("{} ({}): COAP option {} with value {} skipped", thingName, devId,
201 opt.getNumber(), opt.getValue());
206 // If we received a CoAP message successful the thing must be online
207 thingHandler.setThingOnline();
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);
217 // fixed malformed JSON :-(
218 payload = fixJSON(payload);
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);
228 logger.debug("{}: Unknown Response Code {} received, payload={}", thingName, response.getCode(),
229 response.getPayloadString());
233 // Observe Status Updates
234 reqStatus = sendRequest(reqStatus, config.deviceIp, COLOIT_URI_DEVSTATUS, Type.NON);
237 } catch (JsonSyntaxException | IllegalArgumentException | NullPointerException e) {
238 logger.debug("{}: Unable to process CoIoT Message for payload={}", thingName, payload, e);
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.
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"}]}]}
252 private void handleDeviceDescription(String devId, String payload) {
253 logger.debug("{}: CoIoT Device Description for {}: {}", thingName, devId, payload);
256 boolean valid = true;
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);
266 blkMap.replace(blk.id, blk);
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,
272 CoIotDescrSen sen = new CoIotDescrSen();
276 sen.range = blk.range;
277 sen.links = blk.links;
278 valid &= addSensor(sen);
282 // Save to thing properties
283 thingHandler.updateProperties(PROPERTY_COAP_DESCR, payload);
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));
294 "{}: Incompatible device description detected for CoIoT version {} (id length mismatch), discarding!",
295 thingName, coiot.getVersion());
296 thingHandler.updateProperties(PROPERTY_COAP_DESCR, "");
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);
308 * Add a new sensor to the sensor table
310 * @param sen CoIotDescrSen of the sensor
312 private synchronized boolean addSensor(CoIotDescrSen sen) {
313 logger.debug("{}: id {}: {}, Type={}, Range={}, Links={}", thingName, sen.id, sen.desc, sen.type, sen.range,
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))) {
326 CoIotDescrSen fixed = coiot.fixDescription(sen, blkMap);
327 if (!sensorMap.containsKey(fixed.id)) {
328 sensorMap.put(sen.id, fixed);
330 sensorMap.replace(sen.id, fixed);
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);
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.
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
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
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);
364 // simulate received device description to create element table
365 logger.debug("{}: Device description for {} restored: {}", thingName, devId, savedDescr);
366 handleDeviceDescription(devId, savedDescr);
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);
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());
380 for (int i = 0; i < sensorUpdates.size(); i++) {
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);
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));
397 if (!blkMap.containsKey(sen.links)) {
398 logger.debug("{}: Unable to find BLK for link {} from sen.id={}", thingName, sen.links, sen.id);
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);
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);
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,
418 if (!updates.isEmpty()) {
420 for (Map.Entry<String, State> u : updates.entrySet()) {
421 updated += thingHandler.updateChannel(u.getKey(), u.getValue(), false) ? 1 : 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());
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);
439 if (failed == sensorUpdates.size()) {
440 logger.debug("{}: Device description problem detected, re-discover", thingName);
446 // Remember serial, new packets with same serial will be ignored
448 lastPayload = payload;
451 private void discover() {
452 reqDescription = sendRequest(reqDescription, config.deviceIp, COLOIT_URI_DEVDESC, Type.CON);
456 * Fix malformed JSON - stupid, but the devices sometimes return malformed JSON with then causes a
457 * JsonSyntaxException
459 * @param json to be checked/fixed
461 private static String fixJSON(String payload) {
462 String json = payload;
463 json = json.replace("}{", "},{");
464 json = json.replace("][", "],[");
465 json = json.replace("],,[", "],[");
470 * Send a new request (Discovery to get Device Description). Before a pending
471 * request will be canceled.
473 * @param request The current request (this will be canceled an a new one will
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
480 private Request sendRequest(@Nullable Request request, String ipAddress, String uri, Type con) {
481 if ((request != null) && !request.isCanceled()) {
486 return newRequest(ipAddress, uri, con).send();
490 * Allocate a new Request structure. A message observer will be added to get the
491 * callback when a response has been received.
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
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() {
507 public void onResponse(@Nullable Response response) {
508 processResponse(response);
512 public void onCancel() {
513 logger.debug("{}: CoAP Request was canceled", thingName);
517 public void onTimeout() {
518 logger.debug("{}: CoAP Request timed out", thingName);
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
530 private void resetSerial() {
535 public int getVersion() {
540 * Cancel pending requests and shutdown the client
542 public synchronized void stop() {
544 logger.debug("{}: Stopping CoAP Listener", thingName);
545 coapServer.stop(this);
546 if (statusClient != null) {
547 statusClient.shutdown();
550 if (!reqDescription.isCanceled()) {
551 reqDescription.cancel();
553 if (!reqStatus.isCanceled()) {
561 public void dispose() {
565 private static String completeUrl(String ipAddress, String uri) {
566 return "coap://" + ipAddress + ":" + COIOT_PORT + uri;