]> git.basschouten.com Git - openhab-addons.git/blob
54cd538c1a64c9ef1fd5cc7fa1c69796b837cf85
[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.handler;
14
15 import static org.openhab.binding.shelly.internal.ShellyBindingConstants.*;
16 import static org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.*;
17 import static org.openhab.binding.shelly.internal.discovery.ShellyThingCreator.*;
18 import static org.openhab.binding.shelly.internal.handler.ShellyComponents.*;
19 import static org.openhab.binding.shelly.internal.util.ShellyUtils.*;
20 import static org.openhab.core.thing.Thing.*;
21
22 import java.net.InetAddress;
23 import java.net.UnknownHostException;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.TreeMap;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.openhab.binding.shelly.internal.api.ShellyApiException;
34 import org.openhab.binding.shelly.internal.api.ShellyApiInterface;
35 import org.openhab.binding.shelly.internal.api.ShellyApiResult;
36 import org.openhab.binding.shelly.internal.api.ShellyDeviceProfile;
37 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO;
38 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyFavPos;
39 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyInputState;
40 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyOtaCheckResult;
41 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsDevice;
42 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsStatus;
43 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyThermnostat;
44 import org.openhab.binding.shelly.internal.api1.Shelly1CoapHandler;
45 import org.openhab.binding.shelly.internal.api1.Shelly1CoapJSonDTO;
46 import org.openhab.binding.shelly.internal.api1.Shelly1CoapServer;
47 import org.openhab.binding.shelly.internal.api1.Shelly1HttpApi;
48 import org.openhab.binding.shelly.internal.api2.Shelly2ApiRpc;
49 import org.openhab.binding.shelly.internal.api2.ShellyBluApi;
50 import org.openhab.binding.shelly.internal.config.ShellyBindingConfiguration;
51 import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
52 import org.openhab.binding.shelly.internal.discovery.ShellyThingCreator;
53 import org.openhab.binding.shelly.internal.provider.ShellyChannelDefinitions;
54 import org.openhab.binding.shelly.internal.provider.ShellyTranslationProvider;
55 import org.openhab.binding.shelly.internal.util.ShellyChannelCache;
56 import org.openhab.binding.shelly.internal.util.ShellyVersionDTO;
57 import org.openhab.core.library.types.DecimalType;
58 import org.openhab.core.library.types.OnOffType;
59 import org.openhab.core.library.types.OpenClosedType;
60 import org.openhab.core.library.types.QuantityType;
61 import org.openhab.core.thing.Channel;
62 import org.openhab.core.thing.ChannelUID;
63 import org.openhab.core.thing.Thing;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.ThingTypeUID;
67 import org.openhab.core.thing.binding.BaseThingHandler;
68 import org.openhab.core.thing.binding.builder.ThingBuilder;
69 import org.openhab.core.thing.type.ChannelTypeUID;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.openhab.core.types.State;
73 import org.openhab.core.types.StateOption;
74 import org.openhab.core.types.UnDefType;
75 import org.slf4j.Logger;
76 import org.slf4j.LoggerFactory;
77
78 /**
79  * The {@link ShellyBaseHandler} is responsible for handling commands, which are
80  * sent to one of the channels.
81  *
82  * @author Markus Michels - Initial contribution
83  */
84 @NonNullByDefault
85 public abstract class ShellyBaseHandler extends BaseThingHandler
86         implements ShellyThingInterface, ShellyDeviceListener, ShellyManagerInterface {
87
88     protected final Logger logger = LoggerFactory.getLogger(ShellyBaseHandler.class);
89     protected final ShellyChannelDefinitions channelDefinitions;
90
91     public String thingName = "";
92     public String thingType = "";
93
94     protected final ShellyApiInterface api;
95     private final HttpClient httpClient;
96
97     private ShellyBindingConfiguration bindingConfig;
98     protected ShellyThingConfiguration config = new ShellyThingConfiguration();
99     protected ShellyDeviceProfile profile = new ShellyDeviceProfile(); // init empty profile to avoid NPE
100     private ShellyDeviceStats stats = new ShellyDeviceStats();
101     private @Nullable Shelly1CoapHandler coap;
102
103     private final ShellyTranslationProvider messages;
104     private final ShellyChannelCache cache;
105     private final int cacheCount = UPDATE_SETTINGS_INTERVAL_SECONDS / UPDATE_STATUS_INTERVAL_SECONDS;
106
107     private boolean gen2 = false;
108     private final boolean blu;
109     protected boolean autoCoIoT = false;
110
111     // Thing status
112     private boolean channelsCreated = false;
113     private boolean stopping = false;
114     private int vibrationFilter = 0;
115     private String lastWakeupReason = "";
116
117     // Scheduler
118     private long watchdog = now();
119     protected int scheduledUpdates = 0;
120     private int skipCount = UPDATE_SKIP_COUNT;
121     private int skipUpdate = 0;
122     private boolean refreshSettings = false;
123     private @Nullable ScheduledFuture<?> statusJob;
124     private @Nullable ScheduledFuture<?> initJob;
125
126     /**
127      * Constructor
128      *
129      * @param thing The Thing object
130      * @param translationProvider
131      * @param bindingConfig The binding configuration (beside thing
132      *            configuration)
133      * @param thingTable
134      * @param coapServer coap server instance
135      * @param httpClient from httpService
136      */
137     public ShellyBaseHandler(final Thing thing, final ShellyTranslationProvider translationProvider,
138             final ShellyBindingConfiguration bindingConfig, ShellyThingTable thingTable,
139             final Shelly1CoapServer coapServer, final HttpClient httpClient) {
140         super(thing);
141
142         this.thingName = getString(thing.getLabel());
143         this.messages = translationProvider;
144         this.cache = new ShellyChannelCache(this);
145         this.channelDefinitions = new ShellyChannelDefinitions(messages);
146         this.bindingConfig = bindingConfig;
147         this.config = getConfigAs(ShellyThingConfiguration.class);
148         this.httpClient = httpClient;
149
150         Map<String, String> properties = thing.getProperties();
151         String gen = getString(properties.get(PROPERTY_DEV_GEN));
152         String thingType = getThingType();
153         if (gen.isEmpty() && thingType.startsWith("shellyplus") || thingType.startsWith("shellypro")) {
154             gen = "2";
155         }
156         gen2 = "2".equals(gen);
157         blu = thingType.startsWith("shellyblu");
158         this.api = !blu ? !gen2 ? new Shelly1HttpApi(thingName, this) : new Shelly2ApiRpc(thingName, thingTable, this)
159                 : new ShellyBluApi(thingName, thingTable, this);
160         if (gen2) {
161             config.eventsCoIoT = false;
162         }
163         if (config.eventsCoIoT) {
164             this.coap = new Shelly1CoapHandler(this, coapServer);
165         }
166     }
167
168     @Override
169     public boolean checkRepresentation(String key) {
170         return key.equalsIgnoreCase(getUID()) || key.equalsIgnoreCase(config.deviceAddress)
171                 || key.equalsIgnoreCase(config.serviceName) || key.equalsIgnoreCase(getThingName());
172     }
173
174     /**
175      * Schedule asynchronous Thing initialization, register thing to event dispatcher
176      */
177     @Override
178     public void initialize() {
179         // start background initialization:
180         initJob = scheduler.schedule(() -> {
181             boolean start = true;
182             try {
183                 initializeThingConfig();
184                 logger.debug("{}: Device config: Device address={}, HTTP user/password={}/{}, update interval={}",
185                         thingName, config.deviceAddress, config.userId.isEmpty() ? "<non>" : config.userId,
186                         config.password.isEmpty() ? "<none>" : "***", config.updateInterval);
187                 logger.debug(
188                         "{}: Configured Events: Button: {}, Switch (on/off): {}, Push: {}, Roller: {}, Sensor: {}, CoIoT: {}, Enable AutoCoIoT: {}",
189                         thingName, config.eventsButton, config.eventsSwitch, config.eventsPush, config.eventsRoller,
190                         config.eventsSensorReport, config.eventsCoIoT, bindingConfig.autoCoIoT);
191                 start = initializeThing();
192             } catch (ShellyApiException e) {
193                 ShellyApiResult res = e.getApiResult();
194                 String mid = "";
195                 if (e.isJsonError()) { // invalid JSON format
196                     mid = "offline.status-error-unexpected-error";
197                     start = false;
198                 } else if (isAuthorizationFailed(res)) {
199                     mid = "offline.conf-error-access-denied";
200                     start = false;
201                 } else if (profile.alwaysOn && e.isConnectionError()) {
202                     mid = "offline.status-error-connect";
203                 }
204                 if (!mid.isEmpty()) {
205                     setThingOffline(ThingStatusDetail.COMMUNICATION_ERROR, mid, e.toString());
206                 }
207                 logger.debug("{}: Unable to initialize: {}, retrying later", thingName, e.toString());
208             } catch (IllegalArgumentException e) {
209                 logger.debug("{}: Unable to initialize, retrying later", thingName, e);
210             } finally {
211                 // even this initialization failed we start the status update
212                 // the updateJob will then try to auto-initialize the thing
213                 // in this case the thing stays in status INITIALIZING
214                 if (start) {
215                     startUpdateJob();
216                 }
217             }
218         }, 2, TimeUnit.SECONDS);
219     }
220
221     @Override
222     public ShellyThingConfiguration getThingConfig() {
223         return config;
224     }
225
226     @Override
227     public HttpClient getHttpClient() {
228         return httpClient;
229     }
230
231     @Override
232     public void startScan() {
233         if (api.isInitialized()) {
234             api.startScan();
235         }
236     }
237
238     /**
239      * This routine is called every time the Thing configuration has been changed
240      */
241     @Override
242     public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
243         super.handleConfigurationUpdate(configurationParameters);
244         logger.debug("{}: Thing config updated, re-initialize", thingName);
245         if (coap != null) {
246             coap.stop();
247         }
248         stopping = false;
249         reinitializeThing();// force re-initialization
250     }
251
252     /**
253      * Initialize Thing: Initialize API access, get settings and initialize Device Profile
254      * If the device is password protected and the credentials are missing or don't match the API access will throw an
255      * Exception. In this case the thing type will be changed to shelly-unknown. The user has the option to edit the
256      * thing config and set the correct credentials. The thing type will be changed to the requested one if the
257      * credentials are correct and the API access is initialized successful.
258      *
259      * @throws ShellyApiException e.g. http returned non-ok response, check e.getMessage() for details.
260      */
261     public boolean initializeThing() throws ShellyApiException {
262         // Init from thing type to have a basic profile, gets updated when device info is received from API
263         refreshSettings = false;
264         lastWakeupReason = "";
265         cache.setThingName(thingName);
266         cache.clear();
267         resetStats();
268
269         logger.debug("{}: Start initializing for thing {}, type {}, IP address {}, Gen2: {}, CoIoT: {}", thingName,
270                 getThing().getLabel(), thingType, config.deviceAddress, gen2, config.eventsCoIoT);
271         if (config.deviceAddress.isEmpty()) {
272             setThingOffline(ThingStatusDetail.CONFIGURATION_ERROR, "config-status.error.missing-device-address");
273             return false;
274         }
275
276         updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.CONFIGURATION_PENDING,
277                 messages.get("status.unknown.initializing"));
278
279         profile.initFromThingType(thingType); // do some basic initialization
280
281         // Gen 1 only: Setup CoAP listener to we get the CoAP message, which triggers initialization even the thing
282         // could not be fully initialized here. In this case the CoAP messages triggers auto-initialization (like the
283         // Action URL does when enabled)
284         if (coap != null && config.eventsCoIoT && !profile.alwaysOn) {
285             coap.start(thingName, config);
286         }
287
288         // Initialize API access, exceptions will be catched by initialize()
289         api.initialize();
290         ShellySettingsDevice devInfo = api.getDeviceInfo();
291         if (getBool(devInfo.auth) && config.password.isEmpty()) {
292             setThingOffline(ThingStatusDetail.CONFIGURATION_ERROR, "offline.conf-error-no-credentials");
293             return false;
294         }
295         if (config.serviceName.isEmpty()) {
296             config.serviceName = getString(profile.hostname).toLowerCase();
297         }
298
299         api.setConfig(thingName, config);
300         ShellyDeviceProfile tmpPrf = api.getDeviceProfile(thingType);
301         tmpPrf.isGen2 = gen2;
302         tmpPrf.auth = devInfo.auth; // missing in /settings
303
304         if (this.getThing().getThingTypeUID().equals(THING_TYPE_SHELLYPROTECTED)) {
305             changeThingType(thingName, tmpPrf.mode);
306             return false; // force re-initialization
307         }
308         // Validate device mode
309         String reqMode = thingType.contains("-") ? substringAfter(thingType, "-") : "";
310         if (!reqMode.isEmpty() && !tmpPrf.mode.equals(reqMode)) {
311             setThingOffline(ThingStatusDetail.CONFIGURATION_ERROR, "offline.conf-error-wrong-mode", tmpPrf.mode,
312                     reqMode);
313             return false;
314         }
315         if (!getString(devInfo.coiot).isEmpty()) {
316             // New Shelly devices might use a different endpoint for the CoAP listener
317             tmpPrf.coiotEndpoint = devInfo.coiot;
318         }
319         if (tmpPrf.settings.sleepMode != null && !tmpPrf.isTRV) {
320             // Sensor, usually 12h, H&T in USB mode 10min
321             tmpPrf.updatePeriod = "m".equalsIgnoreCase(getString(tmpPrf.settings.sleepMode.unit))
322                     ? tmpPrf.settings.sleepMode.period * 60 // minutes
323                     : tmpPrf.settings.sleepMode.period * 3600; // hours
324             tmpPrf.updatePeriod += 60; // give 1min extra
325         } else if ((tmpPrf.settings.coiot != null) && tmpPrf.settings.coiot.updatePeriod != null) {
326             // Derive from CoAP update interval, usually 2*15+10s=40sec -> 70sec
327             tmpPrf.updatePeriod = Math.max(UPDATE_SETTINGS_INTERVAL_SECONDS,
328                     2 * getInteger(tmpPrf.settings.coiot.updatePeriod)) + 10;
329         } else {
330             tmpPrf.updatePeriod = UPDATE_SETTINGS_INTERVAL_SECONDS + 10;
331         }
332
333         tmpPrf.status = api.getStatus(); // update thing properties
334         tmpPrf.updateFromStatus(tmpPrf.status);
335         addStateOptions(tmpPrf);
336
337         // update thing properties
338         updateProperties(tmpPrf, tmpPrf.status);
339         checkVersion(tmpPrf, tmpPrf.status);
340
341         startCoap(config, tmpPrf);
342         if (!gen2 && !blu) {
343             api.setActionURLs(); // register event urls
344         }
345
346         // All initialization done, so keep the profile and set Thing to ONLINE
347         fillDeviceStatus(tmpPrf.status, false);
348         postEvent(ALARM_TYPE_NONE, false);
349
350         profile = tmpPrf;
351         showThingConfig(profile);
352
353         logger.debug("{}: Thing successfully initialized.", thingName);
354         updateProperties(profile, profile.status);
355         setThingOnline(); // if API call was successful the thing must be online
356         return true; // success
357     }
358
359     /**
360      * Handle Channel Commands
361      */
362     @Override
363     public void handleCommand(ChannelUID channelUID, Command command) {
364         try {
365             if (command instanceof RefreshType) {
366                 String channelId = channelUID.getId();
367                 State value = cache.getValue(channelId);
368                 if (value != UnDefType.NULL) {
369                     updateState(channelId, value);
370                 }
371                 return;
372             }
373
374             if (!profile.isInitialized()) {
375                 logger.debug("{}: {}", thingName, messages.get("command.init", command));
376                 initializeThing();
377             } else {
378                 profile = getProfile(false);
379             }
380
381             boolean update = false;
382             switch (channelUID.getIdWithoutGroup()) {
383                 case CHANNEL_SENSE_KEY: // Shelly Sense: Send Key
384                     logger.debug("{}: Send key {}", thingName, command);
385                     api.sendIRKey(command.toString());
386                     update = true;
387                     break;
388
389                 case CHANNEL_LED_STATUS_DISABLE:
390                     logger.debug("{}: Set STATUS LED disabled to {}", thingName, command);
391                     api.setLedStatus(SHELLY_LED_STATUS_DISABLE, command == OnOffType.ON);
392                     break;
393                 case CHANNEL_LED_POWER_DISABLE:
394                     logger.debug("{}: Set POWER LED disabled to {}", thingName, command);
395                     api.setLedStatus(SHELLY_LED_POWER_DISABLE, command == OnOffType.ON);
396                     break;
397
398                 case CHANNEL_SENSOR_SLEEPTIME:
399                     logger.debug("{}: Set sensor sleep time to {}", thingName, command);
400                     int value = (int) getNumber(command);
401                     value = value > 0 ? Math.max(SHELLY_MOTION_SLEEPTIME_OFFSET, value - SHELLY_MOTION_SLEEPTIME_OFFSET)
402                             : 0;
403                     api.setSleepTime(value);
404                     break;
405                 case CHANNEL_CONTROL_SCHEDULE:
406                     if (profile.isTRV) {
407                         logger.debug("{}: {} Valve schedule/profile", thingName,
408                                 command == OnOffType.ON ? "Enable" : "Disable");
409                         api.setValveProfile(0,
410                                 command == OnOffType.OFF ? 0 : profile.status.thermostats.get(0).profile);
411                     }
412                     break;
413                 case CHANNEL_CONTROL_PROFILE:
414                     logger.debug("{}: Select profile {}", thingName, command);
415                     int id = -1;
416                     if (command instanceof Number) {
417                         id = (int) getNumber(command);
418                     } else {
419                         String cmd = command.toString();
420                         if (isDigit(cmd.charAt(0))) {
421                             id = Integer.parseInt(cmd);
422                         } else if (profile.settings.thermostats != null) {
423                             ShellyThermnostat t = profile.settings.thermostats.get(0);
424                             for (int i = 0; i < t.profileNames.length; i++) {
425                                 if (t.profileNames[i].equalsIgnoreCase(cmd)) {
426                                     id = i + 1;
427                                 }
428                             }
429                         }
430                     }
431                     if (id < 0 || id > 5) {
432                         logger.warn("{}: Invalid profile Id {} requested", thingName, profile);
433                         break;
434                     }
435                     api.setValveProfile(0, id);
436                     break;
437                 case CHANNEL_CONTROL_MODE:
438                     logger.debug("{}: Set mode to {}", thingName, command);
439                     api.setValveMode(0, CHANNEL_CONTROL_MODE.equalsIgnoreCase(command.toString()));
440                     break;
441                 case CHANNEL_CONTROL_SETTEMP:
442                     logger.debug("{}: Set temperature to {}", thingName, command);
443                     api.setValveTemperature(0, (int) getNumber(command));
444                     break;
445                 case CHANNEL_CONTROL_POSITION:
446                     logger.debug("{}: Set position to {}", thingName, command);
447                     api.setValvePosition(0, getNumber(command));
448                     break;
449                 case CHANNEL_CONTROL_BCONTROL:
450                     logger.debug("{}: Set boost mode to {}", thingName, command);
451                     api.startValveBoost(0, command == OnOffType.ON ? -1 : 0);
452                     break;
453                 case CHANNEL_CONTROL_BTIMER:
454                     logger.debug("{}: Set boost timer to {}", thingName, command);
455                     api.setValveBoostTime(0, (int) getNumber(command));
456                     break;
457                 case CHANNEL_SENSOR_MUTE:
458                     if (profile.isSmoke && ((OnOffType) command) == OnOffType.ON) {
459                         logger.debug("{}: Mute Smoke Alarm", thingName);
460                         api.muteSmokeAlarm(0);
461                         updateChannel(getString(channelUID.getGroupId()), CHANNEL_SENSOR_MUTE, OnOffType.OFF);
462                     }
463                     break;
464                 default:
465                     update = handleDeviceCommand(channelUID, command);
466                     break;
467             }
468
469             restartWatchdog();
470             if (update && !autoCoIoT && !isUpdateScheduled()) {
471                 requestUpdates(1, false);
472             }
473         } catch (ShellyApiException e) {
474             ShellyApiResult res = e.getApiResult();
475             if (isAuthorizationFailed(res)) {
476                 return;
477             }
478             if (res.isNotCalibrtated()) {
479                 logger.warn("{}: {}", thingName, messages.get("roller.calibrating"));
480             } else {
481                 logger.warn("{}: {} - {}", thingName, messages.get("command.failed", command, channelUID),
482                         e.toString());
483             }
484
485             String group = getString(channelUID.getGroupId());
486             String channel = getString(channelUID.getIdWithoutGroup());
487             State oldValue = getChannelValue(group, channel);
488             if (oldValue != UnDefType.NULL) {
489                 logger.info("{}: Restore channel value to {}", thingName, oldValue);
490                 updateChannel(group, channel, oldValue);
491             }
492
493         } catch (IllegalArgumentException e) {
494             logger.debug("{}: {}", thingName, messages.get("command.failed", command, channelUID));
495         }
496     }
497
498     private double getNumber(Command command) {
499         if (command instanceof QuantityType quantityCommand) {
500             return quantityCommand.doubleValue();
501         }
502         if (command instanceof DecimalType decimalCommand) {
503             return decimalCommand.doubleValue();
504         }
505         if (command instanceof Number numberCommand) {
506             return numberCommand.doubleValue();
507         }
508         throw new IllegalArgumentException("Invalid Number type for conversion: " + command);
509     }
510
511     /**
512      * Update device status and channels
513      */
514     protected void refreshStatus() {
515         try {
516             boolean updated = false;
517
518             if (vibrationFilter > 0) {
519                 vibrationFilter--;
520                 logger.debug("{}: Vibration events are absorbed for {} more seconds", thingName,
521                         vibrationFilter * UPDATE_STATUS_INTERVAL_SECONDS);
522             }
523
524             skipUpdate++;
525             ThingStatus thingStatus = getThing().getStatus();
526             if (refreshSettings || (scheduledUpdates > 0) || (skipUpdate % skipCount == 0)) {
527                 if (!profile.isInitialized() || ((thingStatus == ThingStatus.OFFLINE))
528                         || (thingStatus == ThingStatus.UNKNOWN)) {
529                     logger.debug("{}: Status update triggered thing initialization", thingName);
530                     initializeThing(); // may fire an exception if initialization failed
531                 }
532                 ShellySettingsStatus status = api.getStatus();
533                 boolean restarted = checkRestarted(status);
534                 profile = getProfile(refreshSettings || restarted);
535                 profile.status = status;
536                 profile.updateFromStatus(status);
537                 if (restarted) {
538                     logger.debug("{}: Device restart #{} detected", thingName, stats.restarts);
539                     stats.restarts++;
540                     postEvent(ALARM_TYPE_RESTARTED, true);
541                 }
542
543                 // If status update was successful the thing must be online,
544                 // but not while firmware update is in progress
545                 if (getThingStatusDetail() != ThingStatusDetail.FIRMWARE_UPDATING) {
546                     setThingOnline();
547                 }
548
549                 // map status to channels
550                 updateChannel(CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_NAME, getStringType(profile.settings.name));
551                 updated |= this.updateDeviceStatus(status);
552                 updated |= ShellyComponents.updateDeviceStatus(this, status);
553                 fillDeviceStatus(status, updated);
554                 updated |= updateInputs(status);
555                 updated |= updateMeters(this, status);
556                 updated |= updateSensors(this, status);
557
558                 // All channels must be created after the first cycle
559                 channelsCreated = true;
560             }
561         } catch (ShellyApiException e) {
562             // http call failed: go offline except for battery devices, which might be in
563             // sleep mode. Once the next update is successful the device goes back online
564             String status = "";
565             ShellyApiResult res = e.getApiResult();
566             if (profile.alwaysOn && e.isConnectionError()) {
567                 status = "offline.status-error-connect";
568             } else if (res.isHttpAccessUnauthorized()) {
569                 status = "offline.conf-error-access-denied";
570             } else if (isWatchdogStarted()) {
571                 if (!isWatchdogExpired()) {
572                     logger.debug("{}: Ignore API Timeout on {} {}, retry later", thingName, res.method, res.url);
573                 } else {
574                     if (isThingOnline()) {
575                         status = "offline.status-error-watchdog";
576                     }
577                 }
578             } else if (e.isJSONException()) {
579                 status = "offline.status-error-unexpected-api-result";
580                 logger.debug("{}: Unable to parse API response: {}; json={}", thingName, res.getUrl(), res.response, e);
581             } else if (res.isHttpTimeout()) {
582                 // Watchdog not started, e.g. device in sleep mode
583                 if (isThingOnline()) { // ignore when already offline
584                     status = "offline.status-error-watchdog";
585                 }
586             } else {
587                 status = "offline.status-error-unexpected-api-result";
588                 logger.debug("{}: Unexpected API result: {}", thingName, res.response, e);
589             }
590
591             if (!status.isEmpty()) {
592                 setThingOffline(ThingStatusDetail.COMMUNICATION_ERROR, status);
593             }
594         } catch (NullPointerException | IllegalArgumentException e) {
595             logger.debug("{}: Unable to refresh status: {}", thingName, messages.get("statusupdate.failed"), e);
596         } finally {
597             if (scheduledUpdates > 0) {
598                 --scheduledUpdates;
599                 logger.trace("{}: {} more updates requested", thingName, scheduledUpdates);
600             } else if ((skipUpdate >= cacheCount) && !cache.isEnabled()) {
601                 logger.debug("{}: Enabling channel cache ({} updates / {}s)", thingName, skipUpdate,
602                         cacheCount * UPDATE_STATUS_INTERVAL_SECONDS);
603                 cache.enable();
604             }
605         }
606     }
607
608     private void showThingConfig(ShellyDeviceProfile profile) {
609         logger.debug("{}: Initializing device {}, type {}, Hardware: Rev: {}, batch {}; Firmware: {} / {}", thingName,
610                 profile.hostname, profile.deviceType, profile.hwRev, profile.hwBatchId, profile.fwVersion,
611                 profile.fwDate);
612         logger.debug("{}: Shelly settings info for {}: {}", thingName, profile.hostname, profile.settingsJson);
613         logger.debug(
614                 """
615                         {}: Device \
616                         hasRelays:{} (numRelays={}),isRoller:{} (numRoller={}),isDimmer:{},numMeter={},isEMeter:{}), ext. Switch Add-On: {}\
617                         ,isSensor:{},isDS:{},hasBattery:{}{},isSense:{},isMotion:{},isLight:{},isBulb:{},isDuo:{},isRGBW2:{},inColor:{}, BLU Gateway support: {}\
618                         ,alwaysOn:{}, updatePeriod:{}sec\
619                         """,
620                 thingName, profile.hasRelays, profile.numRelays, profile.isRoller, profile.numRollers, profile.isDimmer,
621                 profile.numMeters, profile.isEMeter, profile.settings.extSwitch != null ? "installed" : "n/a",
622                 profile.isSensor, profile.isDW, profile.hasBattery,
623                 profile.hasBattery ? " (low battery threshold=" + config.lowBattery + "%)" : "", profile.isSense,
624                 profile.isMotion, profile.isLight, profile.isBulb, profile.isDuo, profile.isRGBW2, profile.inColor,
625                 profile.alwaysOn, profile.updatePeriod, config.enableBluGateway);
626         if (profile.status.extTemperature != null || profile.status.extHumidity != null
627                 || profile.status.extVoltage != null || profile.status.extAnalogInput != null) {
628             logger.debug("{}: Shelly Add-On detected with at least 1 external sensor", thingName);
629         }
630     }
631
632     private void addStateOptions(ShellyDeviceProfile prf) {
633         if (prf.isTRV) {
634             String[] profileNames = prf.getValveProfileList(0);
635             String channelId = mkChannelId(CHANNEL_GROUP_CONTROL, CHANNEL_CONTROL_PROFILE);
636             logger.debug("{}: Adding TRV profile names to channel description: {}", thingName, profileNames);
637             channelDefinitions.clearStateOptions(channelId);
638             int fid = 1;
639             for (String name : profileNames) {
640                 channelDefinitions.addStateOption(channelId, "" + fid, fid + ": " + name);
641                 fid++;
642             }
643         }
644         if (prf.isRoller && prf.settings.favorites != null) {
645             String channelId = mkChannelId(CHANNEL_GROUP_ROL_CONTROL, CHANNEL_ROL_CONTROL_FAV);
646             logger.debug("{}: Adding {} roler favorite(s) to channel description", thingName,
647                     prf.settings.favorites.size());
648             channelDefinitions.clearStateOptions(channelId);
649             int fid = 1;
650             for (ShellyFavPos fav : prf.settings.favorites) {
651                 channelDefinitions.addStateOption(channelId, "" + fid, fid + ": " + fav.name);
652                 fid++;
653             }
654         }
655     }
656
657     @Override
658     public String getThingType() {
659         return thing.getThingTypeUID().getId();
660     }
661
662     @Override
663     public ThingStatus getThingStatus() {
664         return thing.getStatus();
665     }
666
667     @Override
668     public ThingStatusDetail getThingStatusDetail() {
669         return thing.getStatusInfo().getStatusDetail();
670     }
671
672     @Override
673     public boolean isThingOnline() {
674         return getThingStatus() == ThingStatus.ONLINE;
675     }
676
677     public boolean isThingOffline() {
678         return getThingStatus() == ThingStatus.OFFLINE;
679     }
680
681     @Override
682     public void setThingOnline() {
683         if (!isThingOnline()) {
684             updateStatus(ThingStatus.ONLINE);
685
686             // request 3 updates in a row (during the first 2+3*3 sec)
687             requestUpdates(profile.alwaysOn ? 3 : 1, !channelsCreated);
688         }
689
690         // Restart watchdog when status update was successful (no exception)
691         restartWatchdog();
692     }
693
694     @Override
695     public void setThingOffline(ThingStatusDetail detail, String messageKey, Object... arguments) {
696         if (!isThingOffline()) {
697             updateStatus(ThingStatus.OFFLINE, detail, messages.get(messageKey, arguments));
698             api.close(); // Gen2: disconnect WS/close http sessions
699             watchdog = 0;
700             channelsCreated = false; // check for new channels after devices gets re-initialized (e.g. new
701         }
702     }
703
704     @Override
705     public void restartWatchdog() {
706         watchdog = now();
707         updateChannel(CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_HEARTBEAT, getTimestamp());
708         logger.trace("{}: Watchdog restarted (expires in {} sec)", thingName, profile.updatePeriod);
709     }
710
711     private boolean isWatchdogExpired() {
712         long delta = now() - watchdog;
713         if ((watchdog > 0) && (delta > profile.updatePeriod)) {
714             stats.remainingWatchdog = delta;
715             return true;
716         }
717         return false;
718     }
719
720     private boolean isWatchdogStarted() {
721         return watchdog > 0;
722     }
723
724     @Override
725     public void reinitializeThing() {
726         logger.debug("{}: Re-Initialize Thing", thingName);
727         if (isStopping()) {
728             logger.debug("{}: Handler is shutting down, ignore", thingName);
729             return;
730         }
731         updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.CONFIGURATION_PENDING,
732                 messages.get("offline.status-error-restarted"));
733         requestUpdates(0, true);
734     }
735
736     @Override
737     public boolean isStopping() {
738         return stopping;
739     }
740
741     @Override
742     public void fillDeviceStatus(ShellySettingsStatus status, boolean updated) {
743         String alarm = "";
744
745         // Update uptime and WiFi, internal temp
746         ShellyComponents.updateDeviceStatus(this, status);
747         stats.wifiRssi = getInteger(status.wifiSta.rssi);
748
749         if (api.isInitialized()) {
750             stats.timeoutErrors = api.getTimeoutErrors();
751             stats.timeoutsRecorvered = api.getTimeoutsRecovered();
752         }
753         stats.remainingWatchdog = watchdog > 0 ? now() - watchdog : 0;
754
755         // Check various device indicators like overheating
756         if (checkRestarted(status)) {
757             // Force re-initialization on next status update
758             reinitializeThing();
759         } else if (getBool(status.overtemperature)) {
760             alarm = ALARM_TYPE_OVERTEMP;
761         } else if (getBool(status.overload)) {
762             alarm = ALARM_TYPE_OVERLOAD;
763         } else if (getBool(status.loaderror)) {
764             alarm = ALARM_TYPE_LOADERR;
765         }
766         State internalTemp = getChannelValue(CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_ITEMP);
767         if (internalTemp != UnDefType.NULL) {
768             int temp = ((Number) internalTemp).intValue();
769             if (temp > stats.maxInternalTemp) {
770                 stats.maxInternalTemp = temp;
771             }
772         }
773
774         if (status.uptime != null) {
775             stats.lastUptime = getLong(status.uptime);
776         }
777
778         if (!alarm.isEmpty()) {
779             postEvent(alarm, false);
780         }
781     }
782
783     @Override
784     public void incProtMessages() {
785         stats.protocolMessages++;
786     }
787
788     @Override
789     public void incProtErrors() {
790         stats.protocolErrors++;
791     }
792
793     /**
794      * Check if device has restarted and needs a new Thing initialization
795      *
796      * @return true: restart detected
797      */
798
799     private boolean checkRestarted(ShellySettingsStatus status) {
800         if (profile.isInitialized() && profile.alwaysOn /* exclude battery powered devices */
801                 && (status.uptime != null && status.uptime < stats.lastUptime
802                         || (!profile.status.update.oldVersion.isEmpty()
803                                 && !status.update.oldVersion.equals(profile.status.update.oldVersion)))) {
804             logger.debug("{}: Device has been restarted, uptime={}/{}, firmware={}/{}", thingName, stats.lastUptime,
805                     getLong(status.uptime), profile.status.update.oldVersion, status.update.oldVersion);
806             updateProperties(profile, status);
807             return true;
808         }
809         return false;
810     }
811
812     /**
813      * Save alarm to the lastAlarm channel
814      *
815      * @param event Alarm Message
816      * @param force
817      */
818     @Override
819     public void postEvent(String event, boolean force) {
820         String channelId = mkChannelId(CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_ALARM);
821         State value = cache.getValue(channelId);
822         String lastAlarm = value != UnDefType.NULL ? value.toString() : "";
823
824         if (force || !lastAlarm.equals(event)
825                 || (lastAlarm.equals(event) && now() > stats.lastAlarmTs + HEALTH_CHECK_INTERVAL_SEC)) {
826             switch (event.toUpperCase()) {
827                 case "":
828                 case "0": // DW2 1.8
829                 case SHELLY_WAKEUPT_SENSOR:
830                 case SHELLY_WAKEUPT_PERIODIC:
831                 case SHELLY_WAKEUPT_BUTTON:
832                 case SHELLY_WAKEUPT_POWERON:
833                 case SHELLY_WAKEUPT_EXT_POWER:
834                 case SHELLY_WAKEUPT_UNKNOWN:
835                     logger.debug("{}: {}", thingName, messages.get("event.filtered", event));
836                 case ALARM_TYPE_NONE:
837                     break;
838                 default:
839                     logger.debug("{}: {}", thingName, messages.get("event.triggered", event));
840                     triggerChannel(channelId, event);
841                     cache.updateChannel(channelId, getStringType(event.toUpperCase()));
842                     stats.lastAlarm = event;
843                     stats.lastAlarmTs = now();
844                     stats.alarms++;
845             }
846         }
847     }
848
849     public boolean isUpdateScheduled() {
850         return scheduledUpdates > 0;
851     }
852
853     /**
854      * Callback for device events
855      *
856      * @param address
857      * @param deviceName device receiving the event
858      * @param deviceIndex
859      * @param type the HTML input data
860      * @param parameters parameters from the event URL
861      * @return true if event was processed
862      */
863     @Override
864     public boolean onEvent(String address, String deviceName, String deviceIndex, String type,
865             Map<String, String> parameters) {
866         if (thingName.equalsIgnoreCase(deviceName) || config.deviceAddress.equals(address)
867                 || config.serviceName.equals(deviceName)) {
868             logger.debug("{}: Event received: class={}, index={}, parameters={}", deviceName, type, deviceIndex,
869                     parameters);
870             int idx = !deviceIndex.isEmpty() ? Integer.parseInt(deviceIndex) : 1;
871             if (!profile.isInitialized()) {
872                 logger.debug("{}: Device is not yet initialized, event triggers initialization", deviceName);
873                 requestUpdates(1, true);
874             } else {
875                 String group = profile.getControlGroup(idx);
876                 if (group.isEmpty()) {
877                     logger.debug("{}: Unsupported event class: {}", thingName, type);
878                     return false;
879                 }
880
881                 // map some of the events to system defined button triggers
882                 String channel = "";
883                 String onoff = "";
884                 String payload = "";
885                 String parmType = getString(parameters.get("type"));
886                 String event = !parmType.isEmpty() ? parmType : type;
887                 boolean isButton = profile.inButtonMode(idx - 1) || "button".equals(type);
888                 switch (event) {
889                     case SHELLY_EVENT_SHORTPUSH:
890                     case SHELLY_EVENT_DOUBLE_SHORTPUSH:
891                     case SHELLY_EVENT_TRIPLE_SHORTPUSH:
892                     case SHELLY_EVENT_LONGPUSH:
893                         if (isButton) {
894                             triggerButton(group, idx, mapButtonEvent(event));
895                             channel = CHANNEL_BUTTON_TRIGGER + profile.getInputSuffix(idx);
896                             payload = Shelly1ApiJsonDTO.mapButtonEvent(event);
897                         } else {
898                             logger.debug("{}: Relay button is not in memontary or detached mode, ignore SHORT/LONGPUSH",
899                                     thingName);
900                         }
901                         break;
902                     case SHELLY_EVENT_BTN_ON:
903                     case SHELLY_EVENT_BTN_OFF:
904                         if (profile.isRGBW2) {
905                             // RGBW2 has only one input, so not per channel
906                             group = CHANNEL_GROUP_LIGHT_CONTROL;
907                         }
908                         onoff = CHANNEL_INPUT;
909                         break;
910                     case SHELLY_EVENT_BTN1_ON:
911                     case SHELLY_EVENT_BTN1_OFF:
912                         onoff = CHANNEL_INPUT1;
913                         break;
914                     case SHELLY_EVENT_BTN2_ON:
915                     case SHELLY_EVENT_BTN2_OFF:
916                         onoff = CHANNEL_INPUT2;
917                         break;
918                     case SHELLY_EVENT_OUT_ON:
919                     case SHELLY_EVENT_OUT_OFF:
920                         onoff = CHANNEL_OUTPUT;
921                         break;
922                     case SHELLY_EVENT_ROLLER_OPEN:
923                     case SHELLY_EVENT_ROLLER_CLOSE:
924                     case SHELLY_EVENT_ROLLER_STOP:
925                         channel = CHANNEL_EVENT_TRIGGER;
926                         payload = event;
927                         break;
928                     case SHELLY_EVENT_SENSORREPORT:
929                         // process sensor with next refresh
930                         break;
931                     case SHELLY_EVENT_TEMP_OVER: // DW2
932                     case SHELLY_EVENT_TEMP_UNDER:
933                         channel = CHANNEL_EVENT_TRIGGER;
934                         payload = event;
935                         break;
936                     case SHELLY_EVENT_FLOOD_DETECTED:
937                     case SHELLY_EVENT_FLOOD_GONE:
938                         updateChannel(group, CHANNEL_SENSOR_FLOOD,
939                                 event.equalsIgnoreCase(SHELLY_EVENT_FLOOD_DETECTED) ? OnOffType.ON : OnOffType.OFF);
940                         break;
941
942                     case SHELLY_EVENT_CLOSE: // DW 1.7
943                     case SHELLY_EVENT_OPEN: // DW 1.7
944                         updateChannel(group, CHANNEL_SENSOR_STATE,
945                                 event.equalsIgnoreCase(SHELLY_API_DWSTATE_OPEN) ? OpenClosedType.OPEN
946                                         : OpenClosedType.CLOSED);
947                         break;
948
949                     case SHELLY_EVENT_DARK: // DW 1.7
950                     case SHELLY_EVENT_TWILIGHT: // DW 1.7
951                     case SHELLY_EVENT_BRIGHT: // DW 1.7
952                         updateChannel(group, CHANNEL_SENSOR_ILLUM, getStringType(event));
953                         break;
954
955                     case SHELLY_EVENT_ALARM_MILD: // Shelly Gas
956                     case SHELLY_EVENT_ALARM_HEAVY:
957                     case SHELLY_EVENT_ALARM_OFF:
958                     case SHELLY_EVENT_VIBRATION: // DW2
959                         channel = CHANNEL_SENSOR_ALARM_STATE;
960                         payload = event.toUpperCase();
961                         break;
962
963                     default:
964                         // trigger will be provided by input/output channel or sensor channels
965                 }
966
967                 if (!onoff.isEmpty()) {
968                     updateChannel(group, onoff, event.toLowerCase().contains("_on") ? OnOffType.ON : OnOffType.OFF);
969                 }
970                 if (!payload.isEmpty()) {
971                     // Pass event to trigger channel
972                     payload = payload.toUpperCase();
973                     logger.debug("{}: Post event {}", thingName, payload);
974                     triggerChannel(mkChannelId(group, channel), payload);
975                 }
976             }
977
978             // request update on next interval (2x for non-battery devices)
979             restartWatchdog();
980             requestUpdates(scheduledUpdates >= 2 ? 0 : !profile.hasBattery ? 2 : 1, true);
981             return true;
982         }
983         return false;
984     }
985
986     /**
987      * Initialize the binding's thing configuration, calc update counts
988      */
989     protected void initializeThingConfig() {
990         thingType = getThing().getThingTypeUID().getId();
991         final Map<String, String> properties = getThing().getProperties();
992         thingName = getString(properties.get(PROPERTY_SERVICE_NAME));
993         if (thingName.isEmpty()) {
994             thingName = getString(thingType + "-" + getString(getThing().getUID().getId())).toLowerCase();
995         }
996
997         config = getConfigAs(ShellyThingConfiguration.class);
998         if (config.deviceAddress.isEmpty()) {
999             config.deviceAddress = config.deviceIp;
1000         }
1001         if (config.deviceAddress.isEmpty()) {
1002             logger.debug("{}: IP/MAC address for the device must not be empty", thingName); // may not set in .things
1003                                                                                             // file
1004             return;
1005         }
1006
1007         config.deviceAddress = config.deviceAddress.toLowerCase().replace(":", ""); // remove : from MAC address and
1008                                                                                     // convert to lower case
1009         if (!config.deviceIp.isEmpty()) {
1010             try {
1011                 InetAddress addr = InetAddress.getByName(config.deviceIp);
1012                 String saddr = addr.getHostAddress();
1013                 if (!config.deviceIp.equals(saddr)) {
1014                     logger.debug("{}: hostname {} resolved to IP address {}", thingName, config.deviceIp, saddr);
1015                     config.deviceIp = saddr;
1016                 }
1017             } catch (UnknownHostException e) {
1018                 logger.debug("{}: Unable to resolve hostname {}", thingName, config.deviceIp);
1019             }
1020         }
1021
1022         config.serviceName = getString(properties.get(PROPERTY_SERVICE_NAME));
1023         config.localIp = bindingConfig.localIP;
1024         config.localPort = String.valueOf(bindingConfig.httpPort);
1025         if (config.userId.isEmpty() && !bindingConfig.defaultUserId.isEmpty()) {
1026             config.userId = bindingConfig.defaultUserId;
1027             config.password = bindingConfig.defaultPassword;
1028             logger.debug("{}: Using userId {} from bindingConfig", thingName, config.userId);
1029         }
1030         if (config.updateInterval == 0) {
1031             config.updateInterval = UPDATE_STATUS_INTERVAL_SECONDS * UPDATE_SKIP_COUNT;
1032         }
1033         if (config.updateInterval < UPDATE_MIN_DELAY) {
1034             config.updateInterval = UPDATE_MIN_DELAY;
1035         }
1036
1037         // Try to get updatePeriod from properties
1038         // For battery devinities the REST call to get the settings will most likely fail, because the device is in
1039         // sleep mode. Therefore we use the last saved property value as default. Will be overwritten, when device is
1040         // initialized successfully by the REST call.
1041         String lastPeriod = getString(properties.get(PROPERTY_UPDATE_PERIOD));
1042         if (!lastPeriod.isEmpty()) {
1043             int period = Integer.parseInt(lastPeriod);
1044             if (period > 0) {
1045                 profile.updatePeriod = period;
1046             }
1047         }
1048
1049         skipCount = config.updateInterval / UPDATE_STATUS_INTERVAL_SECONDS;
1050         logger.trace("{}: updateInterval = {}s -> skipCount = {}", thingName, config.updateInterval, skipCount);
1051     }
1052
1053     private void checkVersion(ShellyDeviceProfile prf, ShellySettingsStatus status) {
1054         try {
1055             ShellyVersionDTO version = new ShellyVersionDTO();
1056             if (version.checkBeta(getString(prf.fwVersion))) {
1057                 logger.info("{}: {}", prf.hostname, messages.get("versioncheck.beta", prf.fwVersion, prf.fwDate));
1058             } else {
1059                 String minVersion = !gen2 ? SHELLY_API_MIN_FWVERSION : SHELLY2_API_MIN_FWVERSION;
1060                 if (version.compare(prf.fwVersion, minVersion) < 0) {
1061                     logger.warn("{}: {}", prf.hostname,
1062                             messages.get("versioncheck.tooold", prf.fwVersion, prf.fwDate, minVersion));
1063                 }
1064             }
1065             if (!gen2 && bindingConfig.autoCoIoT && ((version.compare(prf.fwVersion, SHELLY_API_MIN_FWCOIOT)) >= 0)
1066                     || ("production_test".equalsIgnoreCase(prf.fwVersion))) {
1067                 if (!config.eventsCoIoT) {
1068                     logger.info("{}: {}", thingName, messages.get("versioncheck.autocoiot"));
1069                 }
1070                 autoCoIoT = true;
1071             }
1072             if (status.update.hasUpdate && !version.checkBeta(getString(prf.fwVersion))) {
1073                 logger.info("{}: {}", thingName,
1074                         messages.get("versioncheck.update", status.update.oldVersion, status.update.newVersion));
1075             }
1076         } catch (NullPointerException e) { // could be inconsistant format of beta version
1077             logger.debug("{}: {}", thingName, messages.get("versioncheck.failed", prf.fwVersion));
1078         }
1079     }
1080
1081     public String checkForUpdate() {
1082         try {
1083             ShellyOtaCheckResult result = api.checkForUpdate();
1084             return result.status;
1085         } catch (ShellyApiException e) {
1086             return "";
1087         }
1088     }
1089
1090     public void startCoap(ShellyThingConfiguration config, ShellyDeviceProfile profile) throws ShellyApiException {
1091         if (coap == null || !config.eventsCoIoT) {
1092             return;
1093         }
1094         if (profile.settings.coiot != null && profile.settings.coiot.enabled != null) {
1095             String devpeer = getString(profile.settings.coiot.peer);
1096             String ourpeer = config.localIp + ":" + Shelly1CoapJSonDTO.COIOT_PORT;
1097             if (!profile.settings.coiot.enabled || (profile.isMotion && devpeer.isEmpty())) {
1098                 try {
1099                     api.setCoIoTPeer(ourpeer);
1100                     logger.info("{}: CoIoT peer updated to {}", thingName, ourpeer);
1101                 } catch (ShellyApiException e) {
1102                     logger.debug("{}: Unable to set CoIoT peer: {}", thingName, e.toString());
1103                 }
1104             } else if (!devpeer.isEmpty() && !devpeer.equals(ourpeer)) {
1105                 logger.warn("{}: CoIoT peer in device settings does not point this to this host", thingName);
1106             }
1107         }
1108         if (autoCoIoT) {
1109             logger.debug("{}: Auto-CoIoT is enabled, disabling action urls", thingName);
1110             config.eventsCoIoT = true;
1111             config.eventsSwitch = false;
1112             config.eventsButton = false;
1113             config.eventsPush = false;
1114             config.eventsRoller = false;
1115             config.eventsSensorReport = false;
1116             api.setConfig(thingName, config);
1117         }
1118
1119         logger.debug("{}: Starting CoIoT (autoCoIoT={}/{})", thingName, bindingConfig.autoCoIoT, autoCoIoT);
1120         if (coap != null) {
1121             coap.start(thingName, config);
1122         }
1123     }
1124
1125     /**
1126      * Checks the http response for authorization error.
1127      * If the authorization failed the binding can't access the device settings and determine the thing type. In this
1128      * case the thing type shelly-unknown is set.
1129      *
1130      * @param result exception details including the http respone
1131      * @return true if the authorization failed
1132      */
1133     protected boolean isAuthorizationFailed(ShellyApiResult result) {
1134         if (result.isHttpAccessUnauthorized()) {
1135             // If the device is password protected the API doesn't provide settings to the device settings
1136             setThingOffline(ThingStatusDetail.CONFIGURATION_ERROR, "offline.conf-error-access-denied");
1137             return true;
1138         }
1139         return false;
1140     }
1141
1142     /**
1143      * Change type of this thing.
1144      *
1145      * @param thingType thing type acc. to the xml definition
1146      * @param mode Device mode (e.g. relay, roller)
1147      */
1148     protected void changeThingType(String thingType, String mode) {
1149         String deviceType = substringBefore(thingType, "-");
1150         ThingTypeUID thingTypeUID = ShellyThingCreator.getThingTypeUID(thingType, deviceType, mode);
1151         if (!thingTypeUID.equals(THING_TYPE_SHELLYUNKNOWN)) {
1152             logger.debug("{}: Changing thing type to {}", getThing().getLabel(), thingTypeUID);
1153             Map<String, String> properties = editProperties();
1154             properties.replace(PROPERTY_DEV_TYPE, deviceType);
1155             properties.replace(PROPERTY_DEV_MODE, mode);
1156             updateProperties(properties);
1157             changeThingType(thingTypeUID, getConfig());
1158         }
1159     }
1160
1161     @Override
1162     public void thingUpdated(Thing thing) {
1163         logger.debug("{}: Channel definitions updated.", thingName);
1164         super.thingUpdated(thing);
1165     }
1166
1167     /**
1168      * Start the background updates
1169      */
1170     protected void startUpdateJob() {
1171         ScheduledFuture<?> statusJob = this.statusJob;
1172         if ((statusJob == null) || statusJob.isCancelled()) {
1173             this.statusJob = scheduler.scheduleWithFixedDelay(this::refreshStatus, 2, UPDATE_STATUS_INTERVAL_SECONDS,
1174                     TimeUnit.SECONDS);
1175             logger.debug("{}: Update status job started, interval={}*{}={}sec.", thingName, skipCount,
1176                     UPDATE_STATUS_INTERVAL_SECONDS, skipCount * UPDATE_STATUS_INTERVAL_SECONDS);
1177         }
1178     }
1179
1180     /**
1181      * Flag the status job to do an exceptional update (something happened) rather
1182      * than waiting until the next regular poll
1183      *
1184      * @param requestCount number of polls to execute
1185      * @param refreshSettings true=force a /settings query
1186      * @return true=Update schedule, false=skipped (too many updates already
1187      *         scheduled)
1188      */
1189     @Override
1190     public boolean requestUpdates(int requestCount, boolean refreshSettings) {
1191         this.refreshSettings |= refreshSettings;
1192         if (refreshSettings) {
1193             if (requestCount == 0) {
1194                 logger.debug("{}: Request settings refresh", thingName);
1195             }
1196             scheduledUpdates = 1;
1197             return true;
1198         }
1199         if (scheduledUpdates < 10) { // < 30s
1200             scheduledUpdates += requestCount;
1201             return true;
1202         }
1203         return false;
1204     }
1205
1206     /**
1207      * Map input states to channels
1208      *
1209      * @param status Shelly device status
1210      * @return true: one or more inputs were updated
1211      */
1212     @Override
1213     public boolean updateInputs(ShellySettingsStatus status) {
1214         boolean updated = false;
1215
1216         if (status.inputs != null) {
1217             if (!areChannelsCreated()) {
1218                 updateChannelDefinitions(ShellyChannelDefinitions.createInputChannels(thing, profile, status));
1219             }
1220
1221             int idx = 0;
1222             boolean multiInput = !profile.isIX && status.inputs.size() >= 2; // device has multiple SW (inputs)
1223             for (ShellyInputState input : status.inputs) {
1224                 String group = profile.getInputGroup(idx);
1225                 String suffix = multiInput ? profile.getInputSuffix(idx) : "";
1226                 updated |= updateChannel(group, CHANNEL_INPUT + suffix, getOnOff(input.input));
1227                 if (input.event != null) {
1228                     updated |= updateChannel(group, CHANNEL_STATUS_EVENTTYPE + suffix, getStringType(input.event));
1229                     updated |= updateChannel(group, CHANNEL_STATUS_EVENTCOUNT + suffix, getDecimal(input.eventCount));
1230                 }
1231                 idx++;
1232             }
1233         } else {
1234             if (status.input != null) {
1235                 // RGBW2: a single int rather than an array
1236                 return updateChannel(profile.getControlGroup(0), CHANNEL_INPUT,
1237                         getInteger(status.input) == 0 ? OnOffType.OFF : OnOffType.ON);
1238             }
1239         }
1240         return updated;
1241     }
1242
1243     @Override
1244     public boolean updateWakeupReason(@Nullable List<Object> valueArray) {
1245         boolean changed = false;
1246         if (valueArray != null && !valueArray.isEmpty()) {
1247             String reason = getString((String) valueArray.get(0));
1248             String newVal = valueArray.toString();
1249             changed = updateChannel(CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_WAKEUP, getStringType(reason));
1250             changed |= !lastWakeupReason.isEmpty() && !lastWakeupReason.equals(newVal);
1251             if (changed) {
1252                 postEvent(reason.toUpperCase(), true);
1253             }
1254             lastWakeupReason = newVal;
1255         }
1256         return changed;
1257     }
1258
1259     @Override
1260     public void triggerButton(String group, int idx, String value) {
1261         String trigger = mapButtonEvent(value);
1262         if (trigger.isEmpty()) {
1263             return;
1264         }
1265
1266         logger.debug("{}: Update button state with {}/{}", thingName, value, trigger);
1267         triggerChannel(group,
1268                 profile.isRoller ? CHANNEL_EVENT_TRIGGER : CHANNEL_BUTTON_TRIGGER + profile.getInputSuffix(idx),
1269                 trigger);
1270         updateChannel(group, CHANNEL_LAST_UPDATE, getTimestamp());
1271         if (profile.alwaysOn) {
1272             // refresh status of the input channel
1273             requestUpdates(1, false);
1274         }
1275     }
1276
1277     @Override
1278     public void publishState(String channelId, State value) {
1279         String id = channelId.contains("$") ? substringBefore(channelId, "$") : channelId;
1280         if (!stopping && isLinked(id)) {
1281             updateState(id, value);
1282             logger.debug("{}: Channel {} updated with {} (type {}).", thingName, channelId, value, value.getClass());
1283         }
1284     }
1285
1286     @Override
1287     public boolean updateChannel(String group, String channel, State value) {
1288         return updateChannel(mkChannelId(group, channel), value, false);
1289     }
1290
1291     @Override
1292     public boolean updateChannel(String channelId, State value, boolean force) {
1293         return !stopping && cache.updateChannel(channelId, value, force);
1294     }
1295
1296     @Override
1297     public State getChannelValue(String group, String channel) {
1298         return cache.getValue(group, channel);
1299     }
1300
1301     @Override
1302     public double getChannelDouble(String group, String channel) {
1303         State value = getChannelValue(group, channel);
1304         if (value != UnDefType.NULL) {
1305             if (value instanceof QuantityType quantityCommand) {
1306                 return quantityCommand.toBigDecimal().doubleValue();
1307             }
1308             if (value instanceof DecimalType decimalCommand) {
1309                 return decimalCommand.doubleValue();
1310             }
1311         }
1312         return -1;
1313     }
1314
1315     /**
1316      * Update Thing's channels according to available status information from the API
1317      *
1318      * @param dynChannels
1319      */
1320     @Override
1321     public void updateChannelDefinitions(Map<String, Channel> dynChannels) {
1322         if (channelsCreated) {
1323             return; // already done
1324         }
1325
1326         try {
1327             // Get subset of those channels that currently do not exist
1328             List<Channel> existingChannels = getThing().getChannels();
1329             for (Channel channel : existingChannels) {
1330                 String id = channel.getUID().getId();
1331                 if (dynChannels.containsKey(id)) {
1332                     dynChannels.remove(id);
1333                 }
1334             }
1335
1336             if (!dynChannels.isEmpty()) {
1337                 logger.debug("{}: Updating channel definitions, {} channels", thingName, dynChannels.size());
1338                 ThingBuilder thingBuilder = editThing();
1339                 for (Map.Entry<String, Channel> channel : dynChannels.entrySet()) {
1340                     Channel c = channel.getValue();
1341                     logger.debug("{}: Adding channel {}", thingName, c.getUID().getId());
1342                     thingBuilder.withChannel(c);
1343                 }
1344                 updateThing(thingBuilder.build());
1345                 logger.debug("{}: Channel definitions updated", thingName);
1346             }
1347         } catch (IllegalArgumentException e) {
1348             logger.debug("{}: Unable to update channel definitions", thingName, e);
1349         }
1350     }
1351
1352     @Override
1353     public boolean areChannelsCreated() {
1354         return channelsCreated;
1355     }
1356
1357     /**
1358      * Update thing properties with dynamic values
1359      *
1360      * @param profile The device profile
1361      * @param status the /status result
1362      */
1363     public void updateProperties(ShellyDeviceProfile profile, ShellySettingsStatus status) {
1364         Map<String, Object> properties = fillDeviceProperties(profile);
1365         properties.put(PROPERTY_SERVICE_NAME, config.serviceName);
1366         String deviceName = getString(profile.settings.name);
1367         properties.put(PROPERTY_SERVICE_NAME, config.serviceName);
1368         properties.put(PROPERTY_DEV_GEN, "1");
1369         if (!deviceName.isEmpty()) {
1370             properties.put(PROPERTY_DEV_NAME, deviceName);
1371         }
1372         properties.put(PROPERTY_DEV_GEN, !profile.isGen2 ? "1" : "2");
1373
1374         // add status properties
1375         if (status.wifiSta != null) {
1376             properties.put(PROPERTY_WIFI_NETW, getString(status.wifiSta.ssid));
1377         }
1378         if (status.update != null) {
1379             properties.put(PROPERTY_UPDATE_STATUS, getString(status.update.status));
1380             properties.put(PROPERTY_UPDATE_AVAILABLE, getBool(status.update.hasUpdate) ? "yes" : "no");
1381             properties.put(PROPERTY_UPDATE_CURR_VERS, getString(status.update.oldVersion));
1382             properties.put(PROPERTY_UPDATE_NEW_VERS, getString(status.update.newVersion));
1383         }
1384         properties.put(PROPERTY_COIOTAUTO, String.valueOf(autoCoIoT));
1385
1386         Map<String, String> thingProperties = new TreeMap<>();
1387         for (Map.Entry<String, Object> property : properties.entrySet()) {
1388             thingProperties.put(property.getKey(), (String) property.getValue());
1389         }
1390         flushProperties(thingProperties);
1391     }
1392
1393     /**
1394      * Add one property to the Thing Properties
1395      *
1396      * @param key Name of the property
1397      * @param value Value of the property
1398      */
1399     @Override
1400     public void updateProperties(String key, String value) {
1401         Map<String, String> thingProperties = editProperties();
1402         if (thingProperties.containsKey(key)) {
1403             thingProperties.replace(key, value);
1404         } else {
1405             thingProperties.put(key, value);
1406         }
1407         updateProperties(thingProperties);
1408         logger.trace("{}: Properties updated", thingName);
1409     }
1410
1411     public void flushProperties(Map<String, String> propertyUpdates) {
1412         Map<String, String> thingProperties = editProperties();
1413         for (Map.Entry<String, String> property : propertyUpdates.entrySet()) {
1414             if (thingProperties.containsKey(property.getKey())) {
1415                 thingProperties.replace(property.getKey(), property.getValue());
1416             } else {
1417                 thingProperties.put(property.getKey(), property.getValue());
1418             }
1419         }
1420         updateProperties(thingProperties);
1421     }
1422
1423     /**
1424      * Get one property from the Thing Properties
1425      *
1426      * @param key property name
1427      * @return property value or "" if property is not set
1428      */
1429     @Override
1430     public String getProperty(String key) {
1431         Map<String, String> thingProperties = getThing().getProperties();
1432         return getString(thingProperties.get(key));
1433     }
1434
1435     /**
1436      * Fill Thing Properties with device attributes
1437      *
1438      * @param profile Property Map to full
1439      * @return a full property map
1440      */
1441     public static Map<String, Object> fillDeviceProperties(ShellyDeviceProfile profile) {
1442         Map<String, Object> properties = new TreeMap<>();
1443         properties.put(PROPERTY_VENDOR, VENDOR);
1444         if (profile.isInitialized()) {
1445             properties.put(PROPERTY_MODEL_ID, getString(profile.settings.device.type));
1446             properties.put(PROPERTY_MAC_ADDRESS, profile.mac);
1447             properties.put(PROPERTY_FIRMWARE_VERSION, profile.fwVersion + "/" + profile.fwDate);
1448             properties.put(PROPERTY_DEV_MODE, profile.mode);
1449             if (profile.hasRelays) {
1450                 properties.put(PROPERTY_NUM_RELAYS, String.valueOf(profile.numRelays));
1451                 properties.put(PROPERTY_NUM_ROLLERS, String.valueOf(profile.numRollers));
1452                 properties.put(PROPERTY_NUM_METER, String.valueOf(profile.numMeters));
1453             }
1454             properties.put(PROPERTY_UPDATE_PERIOD, String.valueOf(profile.updatePeriod));
1455             if (!profile.hwRev.isEmpty()) {
1456                 properties.put(PROPERTY_HWREV, profile.hwRev);
1457                 properties.put(PROPERTY_HWBATCH, profile.hwBatchId);
1458             }
1459         }
1460         return properties;
1461     }
1462
1463     /**
1464      * Return device profile.
1465      *
1466      * @param forceRefresh true=force refresh before returning, false=return without
1467      *            refresh
1468      * @return ShellyDeviceProfile instance
1469      * @throws ShellyApiException
1470      */
1471     @Override
1472     public ShellyDeviceProfile getProfile(boolean forceRefresh) throws ShellyApiException {
1473         try {
1474             refreshSettings |= forceRefresh;
1475             if (refreshSettings) {
1476                 profile = api.getDeviceProfile(thingType);
1477                 if (!isThingOnline()) {
1478                     logger.debug("{}: Device profile re-initialized (thingType={})", thingName, thingType);
1479                 }
1480             }
1481         } finally {
1482             refreshSettings = false;
1483         }
1484         return profile;
1485     }
1486
1487     @Override
1488     public ShellyDeviceProfile getProfile() {
1489         return profile;
1490     }
1491
1492     @Override
1493     public @Nullable List<StateOption> getStateOptions(ChannelTypeUID uid) {
1494         List<StateOption> options = channelDefinitions.getStateOptions(uid);
1495         if (!options.isEmpty()) {
1496             logger.debug("{}: Return {} state options for channel uid {}", thingName, options.size(), uid.getId());
1497             return options;
1498         }
1499         return null;
1500     }
1501
1502     protected ShellyDeviceProfile getDeviceProfile() {
1503         return profile;
1504     }
1505
1506     @Override
1507     public void triggerChannel(String group, String channel, String payload) {
1508         String triggerCh = mkChannelId(group, channel);
1509         logger.debug("{}: Send event {} to channel {}", thingName, triggerCh, payload);
1510         if (EVENT_TYPE_VIBRATION.contentEquals(payload)) {
1511             if (vibrationFilter == 0) {
1512                 vibrationFilter = VIBRATION_FILTER_SEC / UPDATE_STATUS_INTERVAL_SECONDS + 1;
1513                 logger.debug("{}: Duplicate vibration events will be absorbed for the next {} sec", thingName,
1514                         vibrationFilter * UPDATE_STATUS_INTERVAL_SECONDS);
1515             } else {
1516                 logger.debug("{}: Vibration event absorbed, {} sec remaining", thingName,
1517                         vibrationFilter * UPDATE_STATUS_INTERVAL_SECONDS);
1518                 return;
1519             }
1520         }
1521
1522         triggerChannel(triggerCh, payload);
1523     }
1524
1525     public void stop() {
1526         logger.debug("{}: Shutting down", thingName);
1527         ScheduledFuture<?> job = this.initJob;
1528         if (job != null) {
1529             job.cancel(true);
1530             initJob = null;
1531         }
1532         job = this.statusJob;
1533         if (job != null) {
1534             job.cancel(true);
1535             statusJob = null;
1536             logger.debug("{}: Shelly statusJob stopped", thingName);
1537         }
1538         api.close();
1539         profile.initialized = false;
1540     }
1541
1542     /**
1543      * Shutdown thing, make sure background jobs are canceled
1544      */
1545     @Override
1546     public void dispose() {
1547         logger.debug("{}: Stopping Thing", thingName);
1548         stopping = true;
1549         stop();
1550         super.dispose();
1551     }
1552
1553     /**
1554      * Device specific command handlers are overriding this method to do additional stuff
1555      */
1556     public boolean handleDeviceCommand(ChannelUID channelUID, Command command) throws ShellyApiException {
1557         return false;
1558     }
1559
1560     public String getUID() {
1561         return getThing().getUID().getAsString();
1562     }
1563
1564     /**
1565      * Device specific handlers are overriding this method to do additional stuff
1566      */
1567     public boolean updateDeviceStatus(ShellySettingsStatus status) throws ShellyApiException {
1568         return false;
1569     }
1570
1571     @Override
1572     public String getThingName() {
1573         return thingName;
1574     }
1575
1576     @Override
1577     public void resetStats() {
1578         // reset statistics
1579         stats = new ShellyDeviceStats();
1580     }
1581
1582     @Override
1583     public ShellyDeviceStats getStats() {
1584         return stats;
1585     }
1586
1587     @Override
1588     public ShellyApiInterface getApi() {
1589         return api;
1590     }
1591
1592     @Override
1593     public long getScheduledUpdates() {
1594         return scheduledUpdates;
1595     }
1596
1597     public Map<String, String> getStatsProp() {
1598         return stats.asProperties();
1599     }
1600
1601     @Override
1602     public void triggerUpdateFromCoap() {
1603         if ((!autoCoIoT && (getScheduledUpdates() < 1)) || (autoCoIoT && !profile.isLight && !profile.hasBattery)) {
1604             requestUpdates(1, false);
1605         }
1606     }
1607 }