2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.shelly.internal.manager;
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.*;
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;
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;
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;
66 import com.google.gson.Gson;
67 import com.google.gson.annotations.SerializedName;
70 * {@link ShellyManagerOtaPage} implements the Shelly Manager's page template
72 * @author Markus Michels - Initial contribution
75 public class ShellyManagerPage {
76 private final Logger logger = LoggerFactory.getLogger(ShellyManagerPage.class);
77 protected final ShellyTranslationProvider resources;
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;
86 protected final Map<String, String> htmlTemplates = new HashMap<>();
87 protected final Gson gson = new Gson();
89 protected final ShellyManagerCache<String, FwRepoEntry> firmwareRepo = new ShellyManagerCache<>(15 * 60 * 1000);
90 protected final ShellyManagerCache<String, FwArchList> firmwareArch = new ShellyManagerCache<>(15 * 60 * 1000);
92 public static class ShellyMgrResponse {
93 public @Nullable Object data = "";
94 public String mimeType = "";
95 public String redirectUrl = "";
97 public Map<String, String> headers = new HashMap<>();
99 public ShellyMgrResponse() {
100 init("", HttpStatus.OK_200, "text/html", null);
103 public ShellyMgrResponse(Object data, int code) {
104 init(data, code, "text/html", null);
107 public ShellyMgrResponse(Object data, int code, String mimeType) {
108 init(data, code, mimeType, null);
111 public ShellyMgrResponse(Object data, int code, String mimeType, Map<String, String> headers) {
112 init(data, code, mimeType, headers);
115 private void init(Object message, int code, String mimeType, @Nullable Map<String, String> headers) {
118 this.mimeType = mimeType;
119 this.headers = headers != null ? headers : new TreeMap<>();
122 public void setRedirect(String redirectUrl) {
123 this.redirectUrl = redirectUrl;
127 public static class FwArchEntry {
128 // {"version":"v1.5.10","file":"SHSW-1.zip"}
129 public @Nullable String version;
130 public @Nullable String file;
133 public static class FwArchList {
134 public @Nullable ArrayList<FwArchEntry> versions;
137 public static class FwRepoEntry {
138 public @Nullable String url; // prod
139 public @Nullable String version;
141 @SerializedName("beta_url")
142 public @Nullable String betaUrl; // beta version if avilable
143 @SerializedName("beta_ver")
144 public @Nullable String betaVer;
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;
157 public ShellyMgrResponse generateContent(String path, Map<String, String[]> parameters) throws ShellyApiException {
158 return new ShellyMgrResponse("Invalid Request", HttpStatus.BAD_REQUEST_400);
161 protected String loadHTML(String template) throws ShellyApiException {
162 if (htmlTemplates.containsKey(template)) {
163 return getString(htmlTemplates.get(template));
167 String file = TEMPLATE_PATH + template;
168 logger.debug("Read HTML from {}", file);
169 ClassLoader cl = ShellyManagerInterface.class.getClassLoader();
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);
177 } catch (IOException e) {
178 throw new ShellyApiException("Unable to read " + file + " from bundle resources!", e);
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);
190 protected Map<String, String> fillProperties(Map<String, String> properties, String uid,
191 ShellyManagerInterface th) {
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");
199 properties.putAll(th.getThing().getProperties());
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);
210 ShellyDeviceProfile profile = th.getProfile();
211 ShellyThingConfiguration config = thing.getConfiguration().as(ShellyThingConfiguration.class);
212 ShellyDeviceStats stats = th.getStats();
213 properties.putAll(stats.asProperties());
215 for (Map.Entry<String, @Nullable Object> p : thing.getConfiguration().getProperties().entrySet()) {
216 String key = p.getKey();
217 Object o = p.getValue();
219 properties.put(key, o.toString());
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);
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)));
233 if (config.userId.isEmpty()) {
234 // Get defauls from Binding Config
235 properties.put("userId", bindingConfig.defaultUserId);
236 properties.put("password", bindingConfig.defaultPassword);
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);
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");
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);
272 properties.put(CHANNEL_SENSOR_BAT_LEVEL, "USB");
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);
281 if (profile.settings.sntp != null) {
282 properties.put(ATTRIBUTE_SNTP_SERVER,
283 getString(profile.settings.sntp.server) + ", enabled: " + getBool((profile.settings.sntp.enabled)));
286 boolean coiotEnabled = true;
287 if ((profile.settings.coiot != null) && (profile.settings.coiot.enabled != null)) {
288 coiotEnabled = profile.settings.coiot.enabled;
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
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"
302 properties.put(ATTRIBUTE_CLOUD_STATUS, "unknown");
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"
310 properties.put(ATTRIBUTE_MQTT_STATUS, "unknown");
313 String statusIcon = "";
314 ThingStatus ts = th.getThing().getStatus();
319 statusIcon = ICON_UNINITIALIZED;
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;
329 statusIcon = ts.toString();
331 properties.put(ATTRIBUTE_STATUS_ICON, statusIcon.toLowerCase());
336 private void addAttribute(Map<String, String> properties, ShellyManagerInterface thingHandler, String group,
338 State state = thingHandler.getChannelValue(group, attribute);
340 if (state != UnDefType.NULL) {
341 if (state instanceof DateTimeType) {
342 DateTimeType dt = (DateTimeType) state;
344 case ATTRIBUTE_LAST_ALARM:
345 value = dt.format(null).replace('T', ' ').replace('-', '/');
348 value = getTimestamp(dt);
349 value = dt.format(null).replace('T', ' ').replace('-', '/');
352 value = state.toString();
355 properties.put(attribute, value);
358 protected String fillAttributes(String template, Map<String, String> properties) {
359 if (!template.contains("${")) {
360 // no replacement necessary
364 String result = template;
365 for (Map.Entry<String, String> var : properties.entrySet()) {
366 result = result.replaceAll(java.util.regex.Pattern.quote("${" + var.getKey() + "}"),
367 getValue(properties, var.getKey()));
370 if (result.contains("${")) {
371 return result.replaceAll("\\Q${\\E.*}", "");
377 protected String getValue(Map<String, String> properties, String attribute) {
378 String value = getString(properties.get(attribute));
379 if (!value.isEmpty()) {
381 case PROPERTY_FIRMWARE_VERSION:
382 value = substringBeforeLast(value, "-");
384 case PROPERTY_UPDATE_AVAILABLE:
385 value = value.replace(OnOffType.ON.toString(), "yes");
386 value = value.replace(OnOffType.OFF.toString(), "no");
388 case CHANNEL_DEVST_HEARTBEAT:
395 protected FwRepoEntry getFirmwareRepoEntry(String deviceType, String mode) throws ShellyApiException {
396 logger.debug("ShellyManager: Load firmware list from {}", FWREPO_PROD_URL);
397 FwRepoEntry fw = null;
398 if (firmwareRepo.containsKey(deviceType)) {
399 fw = firmwareRepo.get(deviceType);
401 String json = httpGet(FWREPO_PROD_URL); // returns a strange JSON format so we are parsing this manually
402 String entry = substringBetween(json, "\"" + deviceType + "\":{", "}");
403 if (!entry.isEmpty()) {
404 entry = "{" + entry + "}";
408 * "url":"http:\/\/repo.shelly.cloud\/firmware\/SHPLG-1.zip",
409 * "version":"20201228-092318\/v1.9.3@ad2bb4e3",
410 * "beta_url":"http:\/\/repo.shelly.cloud\/firmware\/rc\/SHPLG-1.zip",
411 * "beta_ver":"20201223-093703\/v1.9.3-rc5@3f583801"
414 fw = fromJson(gson, entry, FwRepoEntry.class);
416 // Special case: RGW2 has a split firmware - xxx-white.zip vs. xxx-color.zip
417 if (!mode.isEmpty() && deviceType.equalsIgnoreCase(SHELLYDT_RGBW2)) {
418 // check for spilt firmware
419 String url = substringBefore(fw.url, ".zip") + "-" + mode + ".zip";
422 logger.debug("ShellyManager: Release Split-URL for device type {} is {}", deviceType, url);
424 url = substringBefore(fw.betaUrl, ".zip") + "-" + mode + ".zip";
427 logger.debug("ShellyManager: Beta Split-URL for device type {} is {}", deviceType, url);
431 firmwareRepo.put(deviceType, fw);
434 return fw != null ? fw : new FwRepoEntry();
437 protected FwArchList getFirmwareArchiveList(String deviceType) throws ShellyApiException {
441 if (firmwareArch.contains(deviceType)) {
442 list = firmwareArch.get(deviceType); // return from cache
449 if (!deviceType.isEmpty()) {
450 json = httpGet(FWREPO_ARCH_URL + "?type=" + deviceType);
452 } catch (ShellyApiException e) {
453 logger.debug("{}: Unable to get firmware list for device type {}: {}", LOG_PREFIX, deviceType,
456 if (json.isEmpty() || json.startsWith("[]")) {
457 // no files available for this device type
458 logger.info("{}: No firmware files found for device type {}", LOG_PREFIX, deviceType);
459 list = new FwArchList();
460 list.versions = new ArrayList<FwArchEntry>();
462 // Create selection list
463 json = "{" + json.replace("[{", "\"versions\":[{") + "}"; // make it a named array
464 list = fromJson(gson, json, FwArchList.class);
467 // save list to cache
468 firmwareArch.put(deviceType, list);
472 protected boolean testUrl(String url) {
477 httpHeadl(url); // causes exception on 404
479 } catch (ShellyApiException e) {
484 protected String httpGet(String url) throws ShellyApiException {
485 return httpRequest(HttpMethod.GET, url);
488 protected String httpHeadl(String url) throws ShellyApiException {
489 return httpRequest(HttpMethod.HEAD, url);
492 protected String httpRequest(HttpMethod method, String url) throws ShellyApiException {
493 ShellyApiResult apiResult = new ShellyApiResult();
496 Request request = httpClient.newRequest(url).method(method).timeout(SHELLY_API_TIMEOUT_MS,
497 TimeUnit.MILLISECONDS);
498 request.header(HttpHeader.ACCEPT, ShellyHttpClient.CONTENT_TYPE_JSON);
499 logger.trace("{}: HTTP {} {}", LOG_PREFIX, method, url);
500 ContentResponse contentResponse = request.send();
501 apiResult = new ShellyApiResult(contentResponse);
502 String response = contentResponse.getContentAsString().replace("\t", "").replace("\r\n", "").trim();
503 logger.trace("{}: HTTP Response {}: {}", LOG_PREFIX, contentResponse.getStatus(), response);
505 // validate response, API errors are reported as Json
506 if (contentResponse.getStatus() != HttpStatus.OK_200) {
507 throw new ShellyApiException(apiResult);
510 } catch (ExecutionException | TimeoutException | InterruptedException | IllegalArgumentException e) {
511 throw new ShellyApiException("HTTP GET failed", e);
515 protected String getUrlParm(Map<String, String[]> parameters, String param) {
516 String[] p = parameters.get(param);
519 value = getString(p[0]);
524 protected String getMessage(String key, Object... arguments) {
525 return resources.get("manager." + key, arguments);
528 protected String getMessageP(String key, String msgClass, Object... arguments) {
529 return "<p class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</p>\n";
532 protected String getMessageS(String key, String msgClass, Object... arguments) {
533 return "<span class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</span>\n";
536 protected static String getDeviceType(Map<String, String> properties) {
537 return getString(properties.get(PROPERTY_MODEL_ID));
540 protected static String getDeviceIp(Map<String, String> properties) {
541 return getString(properties.get("deviceIp"));
544 protected static String getDeviceName(Map<String, String> properties) {
545 return getString(properties.get(PROPERTY_DEV_NAME));
548 protected static String getOption(@Nullable Boolean option) {
549 if (option == null) {
552 return option ? "enabled" : "disabled";
555 protected static String getOption(@Nullable Integer option) {
556 if (option == null) {
559 return option.toString();
562 protected static String getDisplayName(Map<String, String> properties) {
563 String name = getString(properties.get(PROPERTY_DEV_NAME));
564 if (name.isEmpty()) {
565 name = getString(properties.get(PROPERTY_SERVICE_NAME));
570 protected ShellyThingConfiguration getThingConfig(ShellyManagerInterface th, Map<String, String> properties) {
571 Thing thing = th.getThing();
572 ShellyThingConfiguration config = thing.getConfiguration().as(ShellyThingConfiguration.class);
573 if (config.userId.isEmpty()) {
574 config.userId = getString(properties.get("userId"));
575 config.password = getString(properties.get("password"));
580 protected void scheduleUpdate(ShellyManagerInterface th, String name, int delay) {
581 TimerTask task = new TimerTask() {
584 th.requestUpdates(1, true);
587 Timer timer = new Timer(name);
588 timer.schedule(task, delay * 1000);
591 protected Map<String, ShellyManagerInterface> getThingHandlers() {
592 return handlerFactory.getThingHandlers();
595 protected @Nullable ShellyManagerInterface getThingHandler(String uid) {
596 return getThingHandlers().get(uid);