]> git.basschouten.com Git - openhab-addons.git/blob
a70b4472d9f8df1c159373e3c826a759d39ffa49
[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.manager;
14
15 import static org.openhab.binding.shelly.internal.ShellyBindingConstants.*;
16 import static org.openhab.binding.shelly.internal.discovery.ShellyThingCreator.*;
17 import static org.openhab.binding.shelly.internal.manager.ShellyManagerConstants.*;
18 import static org.openhab.binding.shelly.internal.util.ShellyUtils.*;
19 import static org.openhab.core.thing.Thing.*;
20
21 import java.io.BufferedReader;
22 import java.io.IOException;
23 import java.io.InputStream;
24 import java.io.InputStreamReader;
25 import java.util.ArrayList;
26 import java.util.HashMap;
27 import java.util.Map;
28 import java.util.Timer;
29 import java.util.TimerTask;
30 import java.util.TreeMap;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
34 import java.util.stream.Collectors;
35
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.client.api.ContentResponse;
40 import org.eclipse.jetty.client.api.Request;
41 import org.eclipse.jetty.http.HttpHeader;
42 import org.eclipse.jetty.http.HttpMethod;
43 import org.eclipse.jetty.http.HttpStatus;
44 import org.openhab.binding.shelly.internal.ShellyHandlerFactory;
45 import org.openhab.binding.shelly.internal.api.ShellyApiException;
46 import org.openhab.binding.shelly.internal.api.ShellyApiResult;
47 import org.openhab.binding.shelly.internal.api.ShellyDeviceProfile;
48 import org.openhab.binding.shelly.internal.api.ShellyHttpClient;
49 import org.openhab.binding.shelly.internal.config.ShellyBindingConfiguration;
50 import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
51 import org.openhab.binding.shelly.internal.handler.ShellyDeviceStats;
52 import org.openhab.binding.shelly.internal.handler.ShellyManagerInterface;
53 import org.openhab.binding.shelly.internal.provider.ShellyTranslationProvider;
54 import org.openhab.core.library.types.DateTimeType;
55 import org.openhab.core.library.types.OnOffType;
56 import org.openhab.core.thing.Thing;
57 import org.openhab.core.thing.ThingStatus;
58 import org.openhab.core.thing.ThingStatusDetail;
59 import org.openhab.core.types.State;
60 import org.openhab.core.types.UnDefType;
61 import org.osgi.service.cm.Configuration;
62 import org.osgi.service.cm.ConfigurationAdmin;
63 import org.slf4j.Logger;
64 import org.slf4j.LoggerFactory;
65
66 import com.google.gson.Gson;
67 import com.google.gson.annotations.SerializedName;
68
69 /**
70  * {@link ShellyManagerOtaPage} implements the Shelly Manager's page template
71  *
72  * @author Markus Michels - Initial contribution
73  */
74 @NonNullByDefault
75 public class ShellyManagerPage {
76     private final Logger logger = LoggerFactory.getLogger(ShellyManagerPage.class);
77     protected final ShellyTranslationProvider resources;
78
79     private final ShellyHandlerFactory handlerFactory;
80     protected final HttpClient httpClient;
81     protected final ConfigurationAdmin configurationAdmin;
82     protected final ShellyBindingConfiguration bindingConfig = new ShellyBindingConfiguration();
83     protected final String localIp;
84     protected final int localPort;
85
86     protected final Map<String, String> htmlTemplates = new HashMap<>();
87     protected final Gson gson = new Gson();
88
89     protected final ShellyManagerCache<String, FwRepoEntry> firmwareRepo = new ShellyManagerCache<>(15 * 60 * 1000);
90     protected final ShellyManagerCache<String, FwArchList> firmwareArch = new ShellyManagerCache<>(15 * 60 * 1000);
91
92     public static class ShellyMgrResponse {
93         public @Nullable Object data = "";
94         public String mimeType = "";
95         public String redirectUrl = "";
96         public int code;
97         public Map<String, String> headers = new HashMap<>();
98
99         public ShellyMgrResponse() {
100             init("", HttpStatus.OK_200, "text/html", null);
101         }
102
103         public ShellyMgrResponse(Object data, int code) {
104             init(data, code, "text/html", null);
105         }
106
107         public ShellyMgrResponse(Object data, int code, String mimeType) {
108             init(data, code, mimeType, null);
109         }
110
111         public ShellyMgrResponse(Object data, int code, String mimeType, Map<String, String> headers) {
112             init(data, code, mimeType, headers);
113         }
114
115         private void init(Object message, int code, String mimeType, @Nullable Map<String, String> headers) {
116             this.data = message;
117             this.code = code;
118             this.mimeType = mimeType;
119             this.headers = headers != null ? headers : new TreeMap<>();
120         }
121
122         public void setRedirect(String redirectUrl) {
123             this.redirectUrl = redirectUrl;
124         }
125     }
126
127     public static class FwArchEntry {
128         // {"version":"v1.5.10","file":"SHSW-1.zip"}
129         public @Nullable String version;
130         public @Nullable String file;
131     }
132
133     public static class FwArchList {
134         public @Nullable ArrayList<FwArchEntry> versions;
135     }
136
137     public static class FwRepoEntry {
138         public @Nullable String url; // prod
139         public @Nullable String version;
140
141         @SerializedName("beta_url")
142         public @Nullable String betaUrl; // beta version if avilable
143         @SerializedName("beta_ver")
144         public @Nullable String betaVer;
145     }
146
147     public ShellyManagerPage(ConfigurationAdmin configurationAdmin, ShellyTranslationProvider translationProvider,
148             HttpClient httpClient, String localIp, int localPort, ShellyHandlerFactory handlerFactory) {
149         this.configurationAdmin = configurationAdmin;
150         this.resources = translationProvider;
151         this.handlerFactory = handlerFactory;
152         this.httpClient = httpClient;
153         this.localIp = localIp;
154         this.localPort = localPort;
155     }
156
157     public ShellyMgrResponse generateContent(String path, Map<String, String[]> parameters) throws ShellyApiException {
158         return new ShellyMgrResponse("Invalid Request", HttpStatus.BAD_REQUEST_400);
159     }
160
161     protected String loadHTML(String template) throws ShellyApiException {
162         if (htmlTemplates.containsKey(template)) {
163             return getString(htmlTemplates.get(template));
164         }
165
166         String html = "";
167         String file = BUNDLE_RESOURCE_SNIPLETS + "/" + template;
168         logger.debug("Read HTML from {}", file);
169         ClassLoader cl = ShellyManagerInterface.class.getClassLoader();
170         if (cl != null) {
171             try (InputStream inputStream = cl.getResourceAsStream(file)) {
172                 if (inputStream != null) {
173                     html = new BufferedReader(new InputStreamReader(inputStream)).lines()
174                             .collect(Collectors.joining("\n"));
175                     htmlTemplates.put(template, html);
176                 }
177             } catch (IOException e) {
178                 throw new ShellyApiException("Unable to read " + file + " from bundle resources!", e);
179             }
180         }
181         return html;
182     }
183
184     protected String loadHTML(String template, Map<String, String> properties) throws ShellyApiException {
185         properties.put(ATTRIBUTE_URI, SHELLY_MANAGER_URI);
186         String html = loadHTML(template);
187         return fillAttributes(html, properties);
188     }
189
190     protected Map<String, String> fillProperties(Map<String, String> properties, String uid,
191             ShellyManagerInterface th) {
192         try {
193             Configuration serviceConfig = configurationAdmin.getConfiguration("binding." + BINDING_ID);
194             bindingConfig.updateFromProperties(serviceConfig.getProperties());
195         } catch (IOException e) {
196             logger.debug("ShellyManager: Unable to get bindingConfig");
197         }
198
199         properties.putAll(th.getThing().getProperties());
200
201         Thing thing = th.getThing();
202         ThingStatus status = thing.getStatus();
203         properties.put("thingName", getString(thing.getLabel()));
204         properties.put("thingStatus", status.toString());
205         ThingStatusDetail detail = thing.getStatusInfo().getStatusDetail();
206         properties.put("thingStatusDetail", detail.equals(ThingStatusDetail.NONE) ? "" : getString(detail.toString()));
207         properties.put("thingStatusDescr", getString(thing.getStatusInfo().getDescription()));
208         properties.put(ATTRIBUTE_UID, uid);
209
210         ShellyDeviceProfile profile = th.getProfile();
211         ShellyThingConfiguration config = thing.getConfiguration().as(ShellyThingConfiguration.class);
212         ShellyDeviceStats stats = th.getStats();
213         properties.putAll(stats.asProperties());
214
215         for (Map.Entry<String, @Nullable Object> p : thing.getConfiguration().getProperties().entrySet()) {
216             String key = p.getKey();
217             Object o = p.getValue();
218             if (o != null) {
219                 properties.put(key, o.toString());
220             }
221         }
222
223         State state = th.getChannelValue(CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_NAME);
224         if (state != UnDefType.NULL) {
225             addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_NAME);
226         } else {
227             // If the Shelly doesn't provide a device name (not configured) we use the service name
228             String deviceName = getDeviceName(properties);
229             properties.put(PROPERTY_DEV_NAME,
230                     !deviceName.isEmpty() ? deviceName : getString(properties.get(PROPERTY_SERVICE_NAME)));
231         }
232
233         if (config.userId.isEmpty()) {
234             // Get defauls from Binding Config
235             properties.put("userId", bindingConfig.defaultUserId);
236             properties.put("password", bindingConfig.defaultPassword);
237         }
238
239         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_RSSI);
240         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_UPTIME);
241         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_HEARTBEAT);
242         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_ITEMP);
243         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_WAKEUP);
244         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_CHARGER);
245         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_UPDATE);
246         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_ALARM);
247         addAttribute(properties, th, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_CHARGER);
248
249         properties.put(ATTRIBUTE_DEBUG_MODE, getOption(profile.settings.debugEnable));
250         properties.put(ATTRIBUTE_DISCOVERABLE, String.valueOf(getBool(profile.settings.discoverable)));
251         properties.put(ATTRIBUTE_WIFI_RECOVERY, String.valueOf(getBool(profile.settings.wifiRecoveryReboot)));
252         properties.put(ATTRIBUTE_APR_MODE,
253                 profile.settings.apRoaming != null ? getOption(profile.settings.apRoaming.enabled) : "n/a");
254         properties.put(ATTRIBUTE_APR_TRESHOLD,
255                 profile.settings.apRoaming != null ? getOption(profile.settings.apRoaming.threshold) : "n/a");
256         properties.put(ATTRIBUTE_PWD_PROTECT,
257                 profile.auth ? "enabled, user=" + getString(profile.settings.login.username) : "disabled");
258         String tz = getString(profile.settings.timezone);
259         properties.put(ATTRIBUTE_TIMEZONE,
260                 (tz.isEmpty() ? "n/a" : tz) + ", auto-detect: " + getBool(profile.settings.tzautodetect));
261         properties.put(ATTRIBUTE_ACTIONS_SKIPPED,
262                 profile.status.astats != null ? String.valueOf(profile.status.astats.skipped) : "n/a");
263         properties.put(ATTRIBUTE_MAX_ITEMP, stats.maxInternalTemp > 0 ? stats.maxInternalTemp + " °C" : "n/a");
264         if (stats.maxInternalTemp == 0) {
265             properties.replace(CHANNEL_DEVST_ITEMP, "n/a");
266         }
267
268         // Shelly H&T: When external power is connected the battery level is not valid
269         if (!profile.isHT || (getInteger(profile.settings.externalPower) == 0)) {
270             addAttribute(properties, th, CHANNEL_GROUP_BATTERY, CHANNEL_SENSOR_BAT_LEVEL);
271         } else {
272             properties.put(CHANNEL_SENSOR_BAT_LEVEL, "USB");
273         }
274
275         String wiFiSignal = getString(properties.get(CHANNEL_DEVST_RSSI));
276         if (!wiFiSignal.isEmpty()) {
277             properties.put("wifiSignalRssi", wiFiSignal + " / " + stats.wifiRssi + " dBm");
278             properties.put("imgWiFi", "imgWiFi" + wiFiSignal);
279         }
280
281         if (profile.settings.sntp != null) {
282             properties.put(ATTRIBUTE_SNTP_SERVER,
283                     getString(profile.settings.sntp.server) + ", enabled: " + getBool((profile.settings.sntp.enabled)));
284         }
285
286         boolean coiotEnabled = true;
287         if ((profile.settings.coiot != null) && (profile.settings.coiot.enabled != null)) {
288             coiotEnabled = profile.settings.coiot.enabled;
289         }
290         properties.put(ATTRIBUTE_COIOT_STATUS,
291                 !coiotEnabled ? "Disbaled in settings" : "Events are " + (config.eventsCoIoT ? "enabled" : "disabled"));
292         properties.put(ATTRIBUTE_COIOT_PEER,
293                 (profile.settings.coiot != null) && !getString(profile.settings.coiot.peer).isEmpty()
294                         ? profile.settings.coiot.peer
295                         : "Multicast");
296         if (profile.status.cloud != null) {
297             properties.put(ATTRIBUTE_CLOUD_STATUS,
298                     getBool(profile.settings.cloud.enabled)
299                             ? getBool(profile.status.cloud.connected) ? "connected" : "enabled"
300                             : "disabled");
301         } else {
302             properties.put(ATTRIBUTE_CLOUD_STATUS, "unknown");
303         }
304         if (profile.status.mqtt != null) {
305             properties.put(ATTRIBUTE_MQTT_STATUS,
306                     getBool(profile.settings.mqtt.enable)
307                             ? getBool(profile.status.mqtt.connected) ? "connected" : "enabled"
308                             : "disabled");
309         } else {
310             properties.put(ATTRIBUTE_MQTT_STATUS, "unknown");
311         }
312
313         String statusIcon = "";
314         ThingStatus ts = th.getThing().getStatus();
315         switch (ts) {
316             case UNINITIALIZED:
317             case REMOVED:
318             case REMOVING:
319                 statusIcon = ICON_UNINITIALIZED;
320                 break;
321             case OFFLINE:
322                 ThingStatusDetail sd = th.getThing().getStatusInfo().getStatusDetail();
323                 if (uid.contains(THING_TYPE_SHELLYUNKNOWN_STR) || (sd == ThingStatusDetail.CONFIGURATION_ERROR)
324                         || (sd == ThingStatusDetail.HANDLER_CONFIGURATION_PENDING)) {
325                     statusIcon = ICON_CONFIG;
326                     break;
327                 }
328             default:
329                 statusIcon = ts.toString();
330         }
331         properties.put(ATTRIBUTE_STATUS_ICON, statusIcon.toLowerCase());
332
333         return properties;
334     }
335
336     private void addAttribute(Map<String, String> properties, ShellyManagerInterface thingHandler, String group,
337             String attribute) {
338         State state = thingHandler.getChannelValue(group, attribute);
339         String value = "";
340         if (state != UnDefType.NULL) {
341             if (state instanceof DateTimeType dateTimeState) {
342                 switch (attribute) {
343                     case ATTRIBUTE_LAST_ALARM:
344                         value = dateTimeState.format(null).replace('T', ' ').replace('-', '/');
345                         break;
346                     default:
347                         value = getTimestamp(dateTimeState);
348                         value = dateTimeState.format(null).replace('T', ' ').replace('-', '/');
349                 }
350             } else {
351                 value = state.toString();
352             }
353         }
354         properties.put(attribute, value);
355     }
356
357     protected String fillAttributes(String template, Map<String, String> properties) {
358         if (!template.contains("${")) {
359             // no replacement necessary
360             return template;
361         }
362
363         String result = template;
364         for (Map.Entry<String, String> var : properties.entrySet()) {
365             result = result.replaceAll(java.util.regex.Pattern.quote("${" + var.getKey() + "}"),
366                     getValue(properties, var.getKey()));
367         }
368
369         if (result.contains("${")) {
370             return result.replaceAll("\\Q${\\E.*}", "");
371         } else {
372             return result;
373         }
374     }
375
376     protected String getValue(Map<String, String> properties, String attribute) {
377         String value = getString(properties.get(attribute));
378         if (!value.isEmpty()) {
379             switch (attribute) {
380                 case PROPERTY_FIRMWARE_VERSION:
381                     value = substringBeforeLast(value, "-");
382                     break;
383                 case PROPERTY_UPDATE_AVAILABLE:
384                     value = value.replace(OnOffType.ON.toString(), "yes");
385                     value = value.replace(OnOffType.OFF.toString(), "no");
386                     break;
387                 case CHANNEL_DEVST_HEARTBEAT:
388                     break;
389             }
390         }
391         return value;
392     }
393
394     protected FwRepoEntry getFirmwareRepoEntry(String deviceType, String mode) throws ShellyApiException {
395         logger.debug("ShellyManager: Load firmware list from {}", FWREPO_PROD_URL);
396         FwRepoEntry fw = null;
397         if (firmwareRepo.containsKey(deviceType)) {
398             fw = firmwareRepo.get(deviceType);
399         }
400         String json = httpGet(FWREPO_PROD_URL); // returns a strange JSON format so we are parsing this manually
401         String entry = substringBetween(json, "\"" + deviceType + "\":{", "}");
402         if (!entry.isEmpty()) {
403             entry = "{" + entry + "}";
404             /*
405              * Example:
406              * "SHPLG-1":{
407              * "url":"http:\/\/repo.shelly.cloud\/firmware\/SHPLG-1.zip",
408              * "version":"20201228-092318\/v1.9.3@ad2bb4e3",
409              * "beta_url":"http:\/\/repo.shelly.cloud\/firmware\/rc\/SHPLG-1.zip",
410              * "beta_ver":"20201223-093703\/v1.9.3-rc5@3f583801"
411              * },
412              */
413             fw = fromJson(gson, entry, FwRepoEntry.class);
414
415             // Special case: RGW2 has a split firmware - xxx-white.zip vs. xxx-color.zip
416             if (!mode.isEmpty() && deviceType.equalsIgnoreCase(SHELLYDT_RGBW2)) {
417                 // check for spilt firmware
418                 String url = substringBefore(fw.url, ".zip") + "-" + mode + ".zip";
419                 if (testUrl(url)) {
420                     fw.url = url;
421                     logger.debug("ShellyManager: Release Split-URL for device type {} is {}", deviceType, url);
422                 }
423                 url = substringBefore(fw.betaUrl, ".zip") + "-" + mode + ".zip";
424                 if (testUrl(url)) {
425                     fw.betaUrl = url;
426                     logger.debug("ShellyManager: Beta Split-URL for device type {} is {}", deviceType, url);
427                 }
428             }
429
430             firmwareRepo.put(deviceType, fw);
431         }
432
433         return fw != null ? fw : new FwRepoEntry();
434     }
435
436     protected FwArchList getFirmwareArchiveList(String deviceType) throws ShellyApiException {
437         FwArchList list;
438         String json = "";
439
440         if (firmwareArch.contains(deviceType)) {
441             list = firmwareArch.get(deviceType); // return from cache
442             if (list != null) {
443                 return list;
444             }
445         }
446
447         try {
448             if (!deviceType.isEmpty()) {
449                 json = httpGet(FWREPO_ARCH_URL + "?type=" + deviceType);
450             }
451         } catch (ShellyApiException e) {
452             logger.debug("{}: Unable to get firmware list for device type {}: {}", LOG_PREFIX, deviceType,
453                     e.toString());
454         }
455         if (json.isEmpty() || json.startsWith("[]")) {
456             // no files available for this device type
457             logger.info("{}: No firmware files found for device type {}", LOG_PREFIX, deviceType);
458             list = new FwArchList();
459             list.versions = new ArrayList<FwArchEntry>();
460         } else {
461             // Create selection list
462             json = "{" + json.replace("[{", "\"versions\":[{") + "}"; // make it a named array
463             list = fromJson(gson, json, FwArchList.class);
464         }
465
466         // save list to cache
467         firmwareArch.put(deviceType, list);
468         return list;
469     }
470
471     protected boolean testUrl(String url) {
472         try {
473             if (url.isEmpty()) {
474                 return false;
475             }
476             httpHeadl(url); // causes exception on 404
477             return true;
478         } catch (ShellyApiException e) {
479         }
480         return false;
481     }
482
483     protected String httpGet(String url) throws ShellyApiException {
484         return httpRequest(HttpMethod.GET, url);
485     }
486
487     protected String httpHeadl(String url) throws ShellyApiException {
488         return httpRequest(HttpMethod.HEAD, url);
489     }
490
491     protected String httpRequest(HttpMethod method, String url) throws ShellyApiException {
492         ShellyApiResult apiResult = new ShellyApiResult();
493
494         try {
495             Request request = httpClient.newRequest(url).method(method).timeout(SHELLY_API_TIMEOUT_MS,
496                     TimeUnit.MILLISECONDS);
497             request.header(HttpHeader.ACCEPT, ShellyHttpClient.CONTENT_TYPE_JSON);
498             logger.trace("{}: HTTP {} {}", LOG_PREFIX, method, url);
499             ContentResponse contentResponse = request.send();
500             apiResult = new ShellyApiResult(contentResponse);
501             String response = contentResponse.getContentAsString().replace("\t", "").replace("\r\n", "").trim();
502             logger.trace("{}: HTTP Response {}: {}", LOG_PREFIX, contentResponse.getStatus(), response);
503
504             // validate response, API errors are reported as Json
505             if (contentResponse.getStatus() != HttpStatus.OK_200) {
506                 throw new ShellyApiException(apiResult);
507             }
508             return response;
509         } catch (ExecutionException | TimeoutException | InterruptedException | IllegalArgumentException e) {
510             throw new ShellyApiException("HTTP GET failed", e);
511         }
512     }
513
514     protected String getUrlParm(Map<String, String[]> parameters, String param) {
515         String[] p = parameters.get(param);
516         String value = "";
517         if (p != null) {
518             value = getString(p[0]);
519         }
520         return value;
521     }
522
523     protected String getMessage(String key, Object... arguments) {
524         return resources.get("manager." + key, arguments);
525     }
526
527     protected String getMessageP(String key, String msgClass, Object... arguments) {
528         return "<p class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</p>\n";
529     }
530
531     protected String getMessageS(String key, String msgClass, Object... arguments) {
532         return "<span class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</span>\n";
533     }
534
535     protected static String getDeviceType(Map<String, String> properties) {
536         return getString(properties.get(PROPERTY_MODEL_ID));
537     }
538
539     protected static String getDeviceIp(Map<String, String> properties) {
540         return getString(properties.get("deviceIp"));
541     }
542
543     protected static String getDeviceName(Map<String, String> properties) {
544         return getString(properties.get(PROPERTY_DEV_NAME));
545     }
546
547     protected static String getOption(@Nullable Boolean option) {
548         if (option == null) {
549             return "n/a";
550         }
551         return option ? "enabled" : "disabled";
552     }
553
554     protected static String getOption(@Nullable Integer option) {
555         if (option == null) {
556             return "n/a";
557         }
558         return option.toString();
559     }
560
561     protected static String getDisplayName(Map<String, String> properties) {
562         String name = getString(properties.get(PROPERTY_DEV_NAME));
563         if (name.isEmpty()) {
564             name = getString(properties.get(PROPERTY_SERVICE_NAME));
565         }
566         return name;
567     }
568
569     protected ShellyThingConfiguration getThingConfig(ShellyManagerInterface th, Map<String, String> properties) {
570         Thing thing = th.getThing();
571         ShellyThingConfiguration config = thing.getConfiguration().as(ShellyThingConfiguration.class);
572         if (config.userId.isEmpty()) {
573             config.userId = getString(properties.get("userId"));
574             config.password = getString(properties.get("password"));
575         }
576         return config;
577     }
578
579     protected void scheduleUpdate(ShellyManagerInterface th, String name, int delay) {
580         TimerTask task = new TimerTask() {
581             @Override
582             public void run() {
583                 th.requestUpdates(1, true);
584             }
585         };
586         Timer timer = new Timer(name);
587         timer.schedule(task, delay * 1000);
588     }
589
590     protected Map<String, ShellyManagerInterface> getThingHandlers() {
591         return handlerFactory.getThingHandlers();
592     }
593
594     protected @Nullable ShellyManagerInterface getThingHandler(String uid) {
595         return getThingHandlers().get(uid);
596     }
597 }