]> git.basschouten.com Git - openhab-addons.git/blob
a1fad7dc33863e75f08a0484b7b70ea668e5aefa
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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 = TEMPLATE_PATH + 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, Object> p : thing.getConfiguration().getProperties().entrySet()) {
216             String key = p.getKey();
217             if (p.getValue() != null) {
218                 String value = p.getValue().toString();
219                 properties.put(key, value);
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
265         // Shelly H&T: When external power is connected the battery level is not valid
266         if (!profile.isHT || (getInteger(profile.settings.externalPower) == 0)) {
267             addAttribute(properties, th, CHANNEL_GROUP_BATTERY, CHANNEL_SENSOR_BAT_LEVEL);
268         } else {
269             properties.put(CHANNEL_SENSOR_BAT_LEVEL, "USB");
270         }
271
272         String wiFiSignal = getString(properties.get(CHANNEL_DEVST_RSSI));
273         if (!wiFiSignal.isEmpty()) {
274             properties.put("wifiSignalRssi", wiFiSignal + " / " + stats.wifiRssi + " dBm");
275             properties.put("imgWiFi", "imgWiFi" + wiFiSignal);
276         }
277
278         if (profile.settings.sntp != null) {
279             properties.put(ATTRIBUTE_SNTP_SERVER,
280                     getString(profile.settings.sntp.server) + ", enabled: " + getBool((profile.settings.sntp.enabled)));
281         }
282
283         boolean coiotEnabled = true;
284         if ((profile.settings.coiot != null) && (profile.settings.coiot.enabled != null)) {
285             coiotEnabled = profile.settings.coiot.enabled;
286         }
287         properties.put(ATTRIBUTE_COIOT_STATUS,
288                 !coiotEnabled ? "Disbaled in settings" : "Events are " + (config.eventsCoIoT ? "enabled" : "disabled"));
289         properties.put(ATTRIBUTE_COIOT_PEER,
290                 (profile.settings.coiot != null) && !getString(profile.settings.coiot.peer).isEmpty()
291                         ? profile.settings.coiot.peer
292                         : "Multicast");
293         if (profile.status.cloud != null) {
294             properties.put(ATTRIBUTE_CLOUD_STATUS,
295                     getBool(profile.settings.cloud.enabled)
296                             ? getBool(profile.status.cloud.connected) ? "connected" : "enabled"
297                             : "disabled");
298         } else {
299             properties.put(ATTRIBUTE_CLOUD_STATUS, "unknown");
300         }
301         if (profile.status.mqtt != null) {
302             properties.put(ATTRIBUTE_MQTT_STATUS,
303                     getBool(profile.settings.mqtt.enable)
304                             ? getBool(profile.status.mqtt.connected) ? "connected" : "enabled"
305                             : "disabled");
306         } else {
307             properties.put(ATTRIBUTE_MQTT_STATUS, "unknown");
308         }
309
310         String statusIcon = "";
311         ThingStatus ts = th.getThing().getStatus();
312         switch (ts) {
313             case UNINITIALIZED:
314             case REMOVED:
315             case REMOVING:
316                 statusIcon = ICON_UNINITIALIZED;
317                 break;
318             case OFFLINE:
319                 ThingStatusDetail sd = th.getThing().getStatusInfo().getStatusDetail();
320                 if (uid.contains(THING_TYPE_SHELLYUNKNOWN_STR) || (sd == ThingStatusDetail.CONFIGURATION_ERROR)
321                         || (sd == ThingStatusDetail.HANDLER_CONFIGURATION_PENDING)) {
322                     statusIcon = ICON_CONFIG;
323                     break;
324                 }
325             default:
326                 statusIcon = ts.toString();
327         }
328         properties.put(ATTRIBUTE_STATUS_ICON, statusIcon.toLowerCase());
329
330         return properties;
331     }
332
333     private void addAttribute(Map<String, String> properties, ShellyManagerInterface thingHandler, String group,
334             String attribute) {
335         State state = thingHandler.getChannelValue(group, attribute);
336         String value = "";
337         if (state != UnDefType.NULL) {
338             if (state instanceof DateTimeType) {
339                 DateTimeType dt = (DateTimeType) state;
340                 switch (attribute) {
341                     case ATTRIBUTE_LAST_ALARM:
342                         value = dt.format(null).replace('T', ' ').replace('-', '/');
343                         break;
344                     default:
345                         value = getTimestamp(dt);
346                         value = dt.format(null).replace('T', ' ').replace('-', '/');
347                 }
348             } else {
349                 value = state.toString();
350             }
351         }
352         properties.put(attribute, value);
353     }
354
355     protected String fillAttributes(String template, Map<String, String> properties) {
356         if (!template.contains("${")) {
357             // no replacement necessary
358             return template;
359         }
360
361         String result = template;
362         for (Map.Entry<String, String> var : properties.entrySet()) {
363             result = result.replaceAll(java.util.regex.Pattern.quote("${" + var.getKey() + "}"),
364                     getValue(properties, var.getKey()));
365         }
366
367         if (result.contains("${")) {
368             return result.replaceAll("\\Q${\\E.*}", "");
369         } else {
370             return result;
371         }
372     }
373
374     protected String getValue(Map<String, String> properties, String attribute) {
375         String value = getString(properties.get(attribute));
376         if (!value.isEmpty()) {
377             switch (attribute) {
378                 case PROPERTY_FIRMWARE_VERSION:
379                     value = substringBeforeLast(value, "-");
380                     break;
381                 case PROPERTY_UPDATE_AVAILABLE:
382                     value = value.replace(OnOffType.ON.toString(), "yes");
383                     value = value.replace(OnOffType.OFF.toString(), "no");
384                     break;
385                 case CHANNEL_DEVST_HEARTBEAT:
386                     break;
387             }
388         }
389         return value;
390     }
391
392     protected FwRepoEntry getFirmwareRepoEntry(String deviceType, String mode) throws ShellyApiException {
393         logger.debug("ShellyManager: Load firmware list from {}", FWREPO_PROD_URL);
394         FwRepoEntry fw = null;
395         if (firmwareRepo.containsKey(deviceType)) {
396             fw = firmwareRepo.get(deviceType);
397         }
398         String json = httpGet(FWREPO_PROD_URL); // returns a strange JSON format so we are parsing this manually
399         String entry = substringBetween(json, "\"" + deviceType + "\":{", "}");
400         if (!entry.isEmpty()) {
401             entry = "{" + entry + "}";
402             /*
403              * Example:
404              * "SHPLG-1":{
405              * "url":"http:\/\/repo.shelly.cloud\/firmware\/SHPLG-1.zip",
406              * "version":"20201228-092318\/v1.9.3@ad2bb4e3",
407              * "beta_url":"http:\/\/repo.shelly.cloud\/firmware\/rc\/SHPLG-1.zip",
408              * "beta_ver":"20201223-093703\/v1.9.3-rc5@3f583801"
409              * },
410              */
411             fw = fromJson(gson, entry, FwRepoEntry.class);
412
413             // Special case: RGW2 has a split firmware - xxx-white.zip vs. xxx-color.zip
414             if (!mode.isEmpty() && deviceType.equalsIgnoreCase(SHELLYDT_RGBW2)) {
415                 // check for spilt firmware
416                 String url = substringBefore(fw.url, ".zip") + "-" + mode + ".zip";
417                 if (testUrl(url)) {
418                     fw.url = url;
419                     logger.debug("ShellyManager: Release Split-URL for device type {} is {}", deviceType, url);
420                 }
421                 url = substringBefore(fw.betaUrl, ".zip") + "-" + mode + ".zip";
422                 if (testUrl(url)) {
423                     fw.betaUrl = url;
424                     logger.debug("ShellyManager: Beta Split-URL for device type {} is {}", deviceType, url);
425                 }
426             }
427
428             firmwareRepo.put(deviceType, fw);
429         }
430
431         return fw != null ? fw : new FwRepoEntry();
432     }
433
434     protected FwArchList getFirmwareArchiveList(String deviceType) throws ShellyApiException {
435         FwArchList list;
436         String json = "";
437
438         if (firmwareArch.contains(deviceType)) {
439             list = firmwareArch.get(deviceType); // return from cache
440             if (list != null) {
441                 return list;
442             }
443         }
444
445         try {
446             if (!deviceType.isEmpty()) {
447                 json = httpGet(FWREPO_ARCH_URL + "?type=" + deviceType);
448             }
449         } catch (ShellyApiException e) {
450             logger.debug("{}: Unable to get firmware list for device type {}: {}", LOG_PREFIX, deviceType,
451                     e.toString());
452         }
453         if (json.isEmpty() || json.startsWith("[]")) {
454             // no files available for this device type
455             logger.info("{}: No firmware files found for device type {}", LOG_PREFIX, deviceType);
456             list = new FwArchList();
457             list.versions = new ArrayList<FwArchEntry>();
458         } else {
459             // Create selection list
460             json = "{" + json.replace("[{", "\"versions\":[{") + "}"; // make it an named array
461             list = fromJson(gson, json, FwArchList.class);
462         }
463
464         // save list to cache
465         firmwareArch.put(deviceType, list);
466         return list;
467     }
468
469     protected boolean testUrl(String url) {
470         try {
471             if (url.isEmpty()) {
472                 return false;
473             }
474             httpHeadl(url); // causes exception on 404
475             return true;
476         } catch (ShellyApiException e) {
477         }
478         return false;
479     }
480
481     protected String httpGet(String url) throws ShellyApiException {
482         return httpRequest(HttpMethod.GET, url);
483     }
484
485     protected String httpHeadl(String url) throws ShellyApiException {
486         return httpRequest(HttpMethod.HEAD, url);
487     }
488
489     protected String httpRequest(HttpMethod method, String url) throws ShellyApiException {
490         ShellyApiResult apiResult = new ShellyApiResult();
491
492         try {
493             Request request = httpClient.newRequest(url).method(method).timeout(SHELLY_API_TIMEOUT_MS,
494                     TimeUnit.MILLISECONDS);
495             request.header(HttpHeader.ACCEPT, ShellyHttpClient.CONTENT_TYPE_JSON);
496             logger.trace("{}: HTTP {} {}", LOG_PREFIX, method, url);
497             ContentResponse contentResponse = request.send();
498             apiResult = new ShellyApiResult(contentResponse);
499             String response = contentResponse.getContentAsString().replace("\t", "").replace("\r\n", "").trim();
500             logger.trace("{}: HTTP Response {}: {}", LOG_PREFIX, contentResponse.getStatus(), response);
501
502             // validate response, API errors are reported as Json
503             if (contentResponse.getStatus() != HttpStatus.OK_200) {
504                 throw new ShellyApiException(apiResult);
505             }
506             return response;
507         } catch (ExecutionException | TimeoutException | InterruptedException | IllegalArgumentException e) {
508             throw new ShellyApiException("HTTP GET failed", e);
509         }
510     }
511
512     protected String getUrlParm(Map<String, String[]> parameters, String param) {
513         String[] p = parameters.get(param);
514         String value = "";
515         if (p != null) {
516             value = getString(p[0]);
517         }
518         return value;
519     }
520
521     protected String getMessage(String key, Object... arguments) {
522         return resources.get("manager." + key, arguments);
523     }
524
525     protected String getMessageP(String key, String msgClass, Object... arguments) {
526         return "<p class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</p>\n";
527     }
528
529     protected String getMessageS(String key, String msgClass, Object... arguments) {
530         return "<span class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</span>\n";
531     }
532
533     protected static String getDeviceType(Map<String, String> properties) {
534         return getString(properties.get(PROPERTY_MODEL_ID));
535     }
536
537     protected static String getDeviceIp(Map<String, String> properties) {
538         return getString(properties.get("deviceIp"));
539     }
540
541     protected static String getDeviceName(Map<String, String> properties) {
542         return getString(properties.get(PROPERTY_DEV_NAME));
543     }
544
545     protected static String getOption(@Nullable Boolean option) {
546         if (option == null) {
547             return "n/a";
548         }
549         return option ? "enabled" : "disabled";
550     }
551
552     protected static String getOption(@Nullable Integer option) {
553         if (option == null) {
554             return "n/a";
555         }
556         return option.toString();
557     }
558
559     protected static String getDisplayName(Map<String, String> properties) {
560         String name = getString(properties.get(PROPERTY_DEV_NAME));
561         if (name.isEmpty()) {
562             name = getString(properties.get(PROPERTY_SERVICE_NAME));
563         }
564         return name;
565     }
566
567     protected ShellyThingConfiguration getThingConfig(ShellyManagerInterface th, Map<String, String> properties) {
568         Thing thing = th.getThing();
569         ShellyThingConfiguration config = thing.getConfiguration().as(ShellyThingConfiguration.class);
570         if (config.userId.isEmpty()) {
571             config.userId = getString(properties.get("userId"));
572             config.password = getString(properties.get("password"));
573         }
574         return config;
575     }
576
577     protected void scheduleUpdate(ShellyManagerInterface th, String name, int delay) {
578         TimerTask task = new TimerTask() {
579             @Override
580             public void run() {
581                 th.requestUpdates(1, true);
582             }
583         };
584         Timer timer = new Timer(name);
585         timer.schedule(task, delay * 1000);
586     }
587
588     protected Map<String, ShellyManagerInterface> getThingHandlers() {
589         return handlerFactory.getThingHandlers();
590     }
591
592     protected @Nullable ShellyManagerInterface getThingHandler(String uid) {
593         return getThingHandlers().get(uid);
594     }
595 }