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