2 * Copyright (c) 2010-2022 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");
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);
269 properties.put(CHANNEL_SENSOR_BAT_LEVEL, "USB");
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);
278 if (profile.settings.sntp != null) {
279 properties.put(ATTRIBUTE_SNTP_SERVER,
280 getString(profile.settings.sntp.server) + ", enabled: " + getBool((profile.settings.sntp.enabled)));
283 boolean coiotEnabled = true;
284 if ((profile.settings.coiot != null) && (profile.settings.coiot.enabled != null)) {
285 coiotEnabled = profile.settings.coiot.enabled;
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
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"
299 properties.put(ATTRIBUTE_CLOUD_STATUS, "unknown");
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"
307 properties.put(ATTRIBUTE_MQTT_STATUS, "unknown");
310 String statusIcon = "";
311 ThingStatus ts = th.getThing().getStatus();
316 statusIcon = ICON_UNINITIALIZED;
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;
326 statusIcon = ts.toString();
328 properties.put(ATTRIBUTE_STATUS_ICON, statusIcon.toLowerCase());
333 private void addAttribute(Map<String, String> properties, ShellyManagerInterface thingHandler, String group,
335 State state = thingHandler.getChannelValue(group, attribute);
337 if (state != UnDefType.NULL) {
338 if (state instanceof DateTimeType) {
339 DateTimeType dt = (DateTimeType) state;
341 case ATTRIBUTE_LAST_ALARM:
342 value = dt.format(null).replace('T', ' ').replace('-', '/');
345 value = getTimestamp(dt);
346 value = dt.format(null).replace('T', ' ').replace('-', '/');
349 value = state.toString();
352 properties.put(attribute, value);
355 protected String fillAttributes(String template, Map<String, String> properties) {
356 if (!template.contains("${")) {
357 // no replacement necessary
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()));
367 if (result.contains("${")) {
368 return result.replaceAll("\\Q${\\E.*}", "");
374 protected String getValue(Map<String, String> properties, String attribute) {
375 String value = getString(properties.get(attribute));
376 if (!value.isEmpty()) {
378 case PROPERTY_FIRMWARE_VERSION:
379 value = substringBeforeLast(value, "-");
381 case PROPERTY_UPDATE_AVAILABLE:
382 value = value.replace(OnOffType.ON.toString(), "yes");
383 value = value.replace(OnOffType.OFF.toString(), "no");
385 case CHANNEL_DEVST_HEARTBEAT:
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);
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 + "}";
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"
411 fw = fromJson(gson, entry, FwRepoEntry.class);
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";
419 logger.debug("ShellyManager: Release Split-URL for device type {} is {}", deviceType, url);
421 url = substringBefore(fw.betaUrl, ".zip") + "-" + mode + ".zip";
424 logger.debug("ShellyManager: Beta Split-URL for device type {} is {}", deviceType, url);
428 firmwareRepo.put(deviceType, fw);
431 return fw != null ? fw : new FwRepoEntry();
434 protected FwArchList getFirmwareArchiveList(String deviceType) throws ShellyApiException {
438 if (firmwareArch.contains(deviceType)) {
439 list = firmwareArch.get(deviceType); // return from cache
446 if (!deviceType.isEmpty()) {
447 json = httpGet(FWREPO_ARCH_URL + "?type=" + deviceType);
449 } catch (ShellyApiException e) {
450 logger.debug("{}: Unable to get firmware list for device type {}: {}", LOG_PREFIX, deviceType,
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>();
459 // Create selection list
460 json = "{" + json.replace("[{", "\"versions\":[{") + "}"; // make it an named array
461 list = fromJson(gson, json, FwArchList.class);
464 // save list to cache
465 firmwareArch.put(deviceType, list);
469 protected boolean testUrl(String url) {
474 httpHeadl(url); // causes exception on 404
476 } catch (ShellyApiException e) {
481 protected String httpGet(String url) throws ShellyApiException {
482 return httpRequest(HttpMethod.GET, url);
485 protected String httpHeadl(String url) throws ShellyApiException {
486 return httpRequest(HttpMethod.HEAD, url);
489 protected String httpRequest(HttpMethod method, String url) throws ShellyApiException {
490 ShellyApiResult apiResult = new ShellyApiResult();
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);
502 // validate response, API errors are reported as Json
503 if (contentResponse.getStatus() != HttpStatus.OK_200) {
504 throw new ShellyApiException(apiResult);
507 } catch (ExecutionException | TimeoutException | InterruptedException | IllegalArgumentException e) {
508 throw new ShellyApiException("HTTP GET failed", e);
512 protected String getUrlParm(Map<String, String[]> parameters, String param) {
513 String[] p = parameters.get(param);
516 value = getString(p[0]);
521 protected String getMessage(String key, Object... arguments) {
522 return resources.get("manager." + key, arguments);
525 protected String getMessageP(String key, String msgClass, Object... arguments) {
526 return "<p class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</p>\n";
529 protected String getMessageS(String key, String msgClass, Object... arguments) {
530 return "<span class=\"" + msgClass + "\">" + getMessage(key, arguments) + "</span>\n";
533 protected static String getDeviceType(Map<String, String> properties) {
534 return getString(properties.get(PROPERTY_MODEL_ID));
537 protected static String getDeviceIp(Map<String, String> properties) {
538 return getString(properties.get("deviceIp"));
541 protected static String getDeviceName(Map<String, String> properties) {
542 return getString(properties.get(PROPERTY_DEV_NAME));
545 protected static String getOption(@Nullable Boolean option) {
546 if (option == null) {
549 return option ? "enabled" : "disabled";
552 protected static String getOption(@Nullable Integer option) {
553 if (option == null) {
556 return option.toString();
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));
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"));
577 protected void scheduleUpdate(ShellyManagerInterface th, String name, int delay) {
578 TimerTask task = new TimerTask() {
581 th.requestUpdates(1, true);
584 Timer timer = new Timer(name);
585 timer.schedule(task, delay * 1000);
588 protected Map<String, ShellyManagerInterface> getThingHandlers() {
589 return handlerFactory.getThingHandlers();
592 protected @Nullable ShellyManagerInterface getThingHandler(String uid) {
593 return getThingHandlers().get(uid);