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