2 * Copyright (c) 2010-2024 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.api;
15 import static org.openhab.binding.shelly.internal.ShellyBindingConstants.*;
16 import static org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.*;
17 import static org.openhab.binding.shelly.internal.discovery.ShellyThingCreator.*;
18 import static org.openhab.binding.shelly.internal.util.ShellyUtils.*;
20 import java.util.HashMap;
22 import java.util.regex.Matcher;
23 import java.util.regex.Pattern;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsDevice;
28 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsDimmer;
29 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsGlobal;
30 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsInput;
31 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsRelay;
32 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsRgbwLight;
33 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsStatus;
34 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyThermnostat;
35 import org.openhab.binding.shelly.internal.util.ShellyVersionDTO;
36 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory;
39 import com.google.gson.Gson;
42 * The {@link ShellyDeviceProfile} creates a device profile based on the settings returned from the API's /settings
43 * call. This is used to be more dynamic in controlling the device, but also to overcome some issues in the API (e.g.
44 * RGBW2 returns "no meter" even it has one)
46 * @author Markus Michels - Initial contribution
49 public class ShellyDeviceProfile {
50 private final Logger logger = LoggerFactory.getLogger(ShellyDeviceProfile.class);
51 private static final Pattern GEN1_VERSION_PATTERN = Pattern.compile("v\\d+\\.\\d+\\.\\d+(-[a-z0-9]*)?");
52 private static final Pattern GEN2_VERSION_PATTERN = Pattern.compile("\\d+\\.\\d+\\.\\d+(-[a-fh-z0-9]*)?");
54 public boolean initialized = false; // true when initialized
56 public String thingName = "";
57 public boolean extFeatures = false;
59 public String settingsJson = "";
60 public ShellySettingsDevice device = new ShellySettingsDevice();
61 public ShellySettingsGlobal settings = new ShellySettingsGlobal();
62 public ShellySettingsStatus status = new ShellySettingsStatus();
64 public String name = "";
65 public boolean discoverable = true;
66 public boolean alwaysOn = true;
67 public boolean isGen2 = false;
68 public boolean isBlu = false;
69 public String gateway = "";
71 public String hwRev = "";
72 public String hwBatchId = "";
73 public String fwVersion = "";
74 public String fwDate = "";
76 public boolean hasRelays = false; // true if it has at least 1 power meter
77 public int numRelays = 0; // number of relays/outputs
78 public int numRollers = 0; // number of Rollers, usually 1
79 public boolean isRoller = false; // true for Shelly2 in roller mode
80 public boolean isDimmer = false; // true for a Shelly Dimmer (SHDM-1)
81 public int numInputs = 0; // number of inputs
83 public int numMeters = 0;
84 public boolean isEMeter = false; // true for ShellyEM/3EM
86 public boolean isLight = false; // true if it is a Shelly Bulb/RGBW2
87 public boolean isBulb = false; // true only if it is a Bulb
88 public boolean isDuo = false; // true only if it is a Duo
89 public boolean isRGBW2 = false; // true only if it a RGBW2
90 public boolean inColor = false; // true if bulb/rgbw2 is in color mode
92 public boolean isSensor = false; // true for HT & Smoke
93 public boolean hasBattery = false; // true if battery device
94 public boolean isSense = false; // true if thing is a Shelly Sense
95 public boolean isMotion = false; // true if thing is a Shelly Sense
96 public boolean isHT = false; // true for H&T
97 public boolean isDW = false; // true for Door Window sensor
98 public boolean isButton = false; // true for a Shelly Button 1
99 public boolean isIX = false; // true for a Shelly IX
100 public boolean isTRV = false; // true for a Shelly TRV
101 public boolean isSmoke = false; // true for Shelly Smoke
102 public boolean isWall = false; // true: Shelly Wall Display
103 public boolean is3EM = false; // true for Shelly 3EM and Pro 3EM
104 public boolean isEM50 = false; // true for Shelly Pro EM50
106 public int minTemp = 0; // Bulb/Duo: Min Light Temp
107 public int maxTemp = 0; // Bulb/Duo: Max Light Temp
109 public int updatePeriod = 2 * UPDATE_SETTINGS_INTERVAL_SECONDS + 10;
111 public String coiotEndpoint = "";
113 public Map<String, String> irCodes = new HashMap<>(); // Sense: list of stored IR codes
115 public ShellyDeviceProfile() {
118 public ShellyDeviceProfile initialize(String thingType, String jsonIn, @Nullable ShellySettingsDevice device)
119 throws ShellyApiException {
120 Gson gson = new Gson();
122 if (device != null) {
123 this.device = device;
126 initFromThingType(thingType);
128 String json = jsonIn;
129 // It is not guaranteed, that the array entries are in order. Check all
130 // possible variants. See openhab#15514.
131 if (json.contains("\"ext_temperature\":{\"0\":[{") || json.contains("\"ext_temperature\":{\"1\":[{")
132 || json.contains("\"ext_temperature\":{\"2\":[{")) {
133 // Shelly UNI uses ext_temperature array, reformat to avoid GSON exception
134 json = json.replace("ext_temperature", "ext_temperature_array");
136 if (json.contains("\"ext_humidity\":{\"0\":[{")) {
137 // Shelly UNI uses ext_humidity array, reformat to avoid GSON exception
138 json = json.replace("ext_humidity", "ext_humidity_array");
141 settings = fromJson(gson, json, ShellySettingsGlobal.class);
144 if (getString(device.hostname).isEmpty() && !getString(device.mac).isEmpty()) {
145 device.hostname = device.mac.length() >= 12 ? "shelly-" + device.mac.toUpperCase().substring(6, 11)
148 device.mode = getString(settings.mode).toLowerCase();
149 name = getString(settings.name);
150 hwRev = settings.hwinfo != null ? getString(settings.hwinfo.hwRevision) : "";
151 hwBatchId = settings.hwinfo != null ? getString(settings.hwinfo.batchId.toString()) : "";
152 fwDate = substringBefore(device.fw, "-");
153 fwVersion = extractFwVersion(device.fw);
154 ShellyVersionDTO version = new ShellyVersionDTO();
155 extFeatures = version.compare(fwVersion, SHELLY_API_FW_110) >= 0;
156 discoverable = (settings.discoverable == null) || settings.discoverable;
158 String mode = getString(device.mode);
159 isRoller = mode.equalsIgnoreCase(SHELLY_MODE_ROLLER);
160 inColor = isLight && mode.equalsIgnoreCase(SHELLY_MODE_COLOR);
162 numRelays = !isLight ? getInteger(device.numOutputs) : 0;
163 if ((numRelays > 0) && (settings.relays == null)) {
166 hasRelays = (numRelays > 0) || isDimmer;
167 numRollers = getInteger(device.numRollers);
168 numInputs = settings.inputs != null ? settings.inputs.size() : hasRelays ? isRoller ? 2 : 1 : 0;
170 isEMeter = settings.emeters != null;
171 numMeters = !isEMeter ? getInteger(device.numMeters) : getInteger(device.numEMeters);
172 if ((numMeters == 0) && isLight) {
173 // RGBW2 doesn't report, but has one
174 numMeters = inColor ? 1 : getInteger(device.numOutputs);
181 public boolean containsEventUrl(String eventType) {
182 return containsEventUrl(settingsJson, eventType);
185 public boolean containsEventUrl(String json, String eventType) {
186 String settings = json.toLowerCase();
187 return settings.contains((eventType + SHELLY_EVENTURL_SUFFIX).toLowerCase());
190 public boolean isInitialized() {
194 public void initFromThingType(String name) {
195 String thingType = (name.contains("-") ? substringBefore(name, "-") : name).toLowerCase().trim();
196 if (thingType.isEmpty()) {
200 isBlu = isBluSeries(thingType); // e.g. SBBT for BLU Button
201 isGen2 = isGeneration2(thingType);
203 String type = getString(device.type);
204 isDimmer = type.equalsIgnoreCase(SHELLYDT_DIMMER) || type.equalsIgnoreCase(SHELLYDT_DIMMER2)
205 || type.equalsIgnoreCase(SHELLYDT_PLUSDIMMERUS)
206 || thingType.equalsIgnoreCase(THING_TYPE_SHELLYPLUSDIMMERUS_STR)
207 || thingType.equalsIgnoreCase(THING_TYPE_SHELLYPLUSDIMMER10V_STR);
208 isBulb = thingType.equals(THING_TYPE_SHELLYBULB_STR);
209 isDuo = thingType.equals(THING_TYPE_SHELLYDUO_STR) || thingType.equals(THING_TYPE_SHELLYVINTAGE_STR)
210 || thingType.equals(THING_TYPE_SHELLYDUORGBW_STR);
211 isRGBW2 = thingType.startsWith(THING_TYPE_SHELLYRGBW2_PREFIX);
212 isLight = isBulb || isDuo || isRGBW2;
214 minTemp = isBulb ? MIN_COLOR_TEMP_BULB : MIN_COLOR_TEMP_DUO;
215 maxTemp = isBulb ? MAX_COLOR_TEMP_BULB : MAX_COLOR_TEMP_DUO;
218 boolean isFlood = thingType.equals(THING_TYPE_SHELLYFLOOD_STR);
219 isSmoke = thingType.equals(THING_TYPE_SHELLYSMOKE_STR) || thingType.equals(THING_TYPE_SHELLYPLUSSMOKE_STR);
220 boolean isGas = thingType.equals(THING_TYPE_SHELLYGAS_STR);
221 boolean isUNI = thingType.equals(THING_TYPE_SHELLYUNI_STR);
222 isHT = thingType.equals(THING_TYPE_SHELLYHT_STR) || thingType.equals(THING_TYPE_SHELLYPLUSHT_STR)
223 || thingType.equals(THING_TYPE_SHELLYPLUSHTG3_STR) || thingType.equals(THING_TYPE_SHELLYBLUHT_STR);
224 isDW = thingType.equals(THING_TYPE_SHELLYDOORWIN_STR) || thingType.equals(THING_TYPE_SHELLYDOORWIN2_STR)
225 || thingType.equals(THING_TYPE_SHELLYBLUDW_STR);
226 isMotion = thingType.startsWith(THING_TYPE_SHELLYMOTION_STR)
227 || thingType.equals(THING_TYPE_SHELLYBLUMOTION_STR);
228 isSense = thingType.equals(THING_TYPE_SHELLYSENSE_STR);
229 isIX = thingType.equals(THING_TYPE_SHELLYIX3_STR) || thingType.equals(THING_TYPE_SHELLYPLUSI4_STR)
230 || thingType.equals(THING_TYPE_SHELLYPLUSI4DC_STR);
231 isButton = thingType.equals(THING_TYPE_SHELLYBUTTON1_STR) || thingType.equals(THING_TYPE_SHELLYBUTTON2_STR)
232 || thingType.equals(THING_TYPE_SHELLYBLUBUTTON_STR);
233 isTRV = thingType.equals(THING_TYPE_SHELLYTRV_STR);
234 isWall = thingType.equals(THING_TYPE_SHELLYPLUSWALLDISPLAY_STR);
235 is3EM = thingType.equals(THING_TYPE_SHELLY3EM_STR) || thingType.startsWith(THING_TYPE_SHELLYPRO3EM_STR);
236 isEM50 = thingType.startsWith(THING_TYPE_SHELLYPROEM50_STR);
238 isSensor = isHT || isFlood || isDW || isSmoke || isGas || isButton || isUNI || isMotion || isSense || isTRV
240 hasBattery = isHT || isFlood || isDW || isSmoke || isButton || isMotion || isTRV || isBlu;
241 alwaysOn = !hasBattery || (isMotion && !isBlu) || isSense; // true means: device is reachable all the time (no
245 public void updateFromStatus(ShellySettingsStatus status) {
247 // Dimmer-2 doesn't report inputs under /settings, only on /status, we need to update that info after init
248 if (status.inputs != null) {
249 numInputs = status.inputs.size();
251 } else if (status.input != null) {
257 public String getControlGroup(int i) {
259 logger.debug("{}: Invalid index {} for getControlGroup()", thingName, i);
264 return CHANNEL_GROUP_DIMMER_CONTROL;
265 } else if (isRoller) {
266 return numRollers <= 1 ? CHANNEL_GROUP_ROL_CONTROL : CHANNEL_GROUP_ROL_CONTROL + idx;
267 } else if (isDimmer) {
268 return CHANNEL_GROUP_RELAY_CONTROL;
269 } else if (hasRelays) {
270 return numRelays <= 1 ? CHANNEL_GROUP_RELAY_CONTROL : CHANNEL_GROUP_RELAY_CONTROL + idx;
271 } else if (isRGBW2) {
272 return settings.lights == null || settings.lights != null && settings.lights.size() <= 1
273 ? CHANNEL_GROUP_LIGHT_CONTROL
274 : CHANNEL_GROUP_LIGHT_CHANNEL + idx;
275 } else if (isLight) {
276 return CHANNEL_GROUP_LIGHT_CONTROL;
277 } else if (isButton) {
278 return CHANNEL_GROUP_STATUS;
279 } else if (isSensor) {
280 return CHANNEL_GROUP_SENSOR;
284 return numRelays == 1 ? CHANNEL_GROUP_STATUS : CHANNEL_GROUP_STATUS + idx;
287 public String getMeterGroup(int idx) {
288 return numMeters > 1 ? CHANNEL_GROUP_METER + (idx + 1) : CHANNEL_GROUP_METER;
291 public String getInputGroup(int i) {
292 int idx = i + 1; // group names are 1-based
294 return CHANNEL_GROUP_LIGHT_CONTROL;
296 return CHANNEL_GROUP_STATUS + idx;
297 } else if (isButton) {
298 return CHANNEL_GROUP_STATUS;
299 } else if (isRoller) {
300 return numRelays <= 2 ? CHANNEL_GROUP_ROL_CONTROL : CHANNEL_GROUP_ROL_CONTROL + idx;
302 // Device has 1 input per relay: 0=off, 1+2 depend on switch mode
303 return numRelays <= 1 ? CHANNEL_GROUP_RELAY_CONTROL : CHANNEL_GROUP_RELAY_CONTROL + idx;
307 public String getInputSuffix(int i) {
308 int idx = i + 1; // channel names are 1-based
309 if (isRGBW2 || isIX) {
310 return ""; // RGBW2 has only 1 channel
311 } else if (isRoller || isDimmer) {
312 // Roller has 2 relays, but it will be mapped to 1 roller with 2 inputs
313 return String.valueOf(idx);
314 } else if (hasRelays) {
315 return numRelays == 1 && numInputs >= 2 ? String.valueOf(idx) : "";
320 @SuppressWarnings("null")
321 public boolean inButtonMode(int idx) {
323 logger.debug("{}: Invalid index {} for inButtonMode()", thingName, idx);
329 } else if (isIX && settings.inputs != null && idx < settings.inputs.size()) {
330 ShellySettingsInput input = settings.inputs.get(idx);
331 btnType = getString(input.btnType);
332 } else if (isDimmer) {
333 if (settings.dimmers != null) {
334 ShellySettingsDimmer dimmer = settings.dimmers.get(0);
335 btnType = getString(dimmer.btnType);
337 } else if (settings.relays != null) {
338 if (numRelays == 1) {
339 ShellySettingsRelay relay = settings.relays.get(0);
340 if (relay.btnType != null) {
341 btnType = getString(relay.btnType);
343 // Shelly 1L has 2 inputs
344 btnType = idx == 0 ? getString(relay.btnType1) : getString(relay.btnType2);
346 } else if (idx < settings.relays.size()) {
347 // only one input channel
348 ShellySettingsRelay relay = settings.relays.get(idx);
349 btnType = getString(relay.btnType);
351 } else if (isRGBW2 && (settings.lights != null) && (idx < settings.lights.size())) {
352 ShellySettingsRgbwLight light = settings.lights.get(idx);
353 btnType = getString(light.btnType);
356 return btnType.equalsIgnoreCase(SHELLY_BTNT_MOMENTARY) || btnType.equalsIgnoreCase(SHELLY_BTNT_MOM_ON_RELEASE)
357 || btnType.equalsIgnoreCase(SHELLY_BTNT_ONE_BUTTON) || btnType.equalsIgnoreCase(SHELLY_BTNT_TWO_BUTTON)
358 || btnType.equalsIgnoreCase(SHELLY_BTNT_DETACHED);
361 public int getRollerFav(int id) {
362 if (id >= 0 && getBool(settings.favoritesEnabled) && settings.favorites != null
363 && id < settings.favorites.size()) {
364 return settings.favorites.get(id).pos;
369 public String[] getValveProfileList(int valveId) {
370 if (isTRV && settings.thermostats != null) {
371 int sz = settings.thermostats.size();
373 if (settings.thermostats != null) {
374 ShellyThermnostat t = settings.thermostats.get(valveId);
375 return t.profileNames;
379 return new String[0];
382 public String getValueProfile(int valveId, int profileId) {
384 if (id <= 0 && settings.thermostats != null) {
385 id = settings.thermostats.get(0).profile;
390 public static String extractFwVersion(@Nullable String version) {
391 if (version != null) {
393 // 20210319-122304/v.1.10-Dimmer1-gfd4cc10 (with v.1. instead of v1.)
394 // 20220809-125346/v1.12-g99f7e0b (.0 in 1.12.0 missing)
395 String vers = version.replace("/v.1.10-", "/v1.10.0-") //
396 .replace("/v1.12-", "/v1.12.0");
398 // Extract version from string, e.g. 20210226-091047/v1.10.0-rc2-89-g623b41ec0-master
399 Matcher matcher = version.startsWith("v") ? GEN1_VERSION_PATTERN.matcher(vers)
400 : GEN2_VERSION_PATTERN.matcher(vers);
401 if (matcher.find()) {
402 return matcher.group(0);
408 public static boolean isGeneration2(String thingType) {
409 return thingType.startsWith("shellyplus") || thingType.startsWith("shellypro") || thingType.contains("mini")
410 || thingType.startsWith("shellywall") || (thingType.startsWith("shelly") && thingType.contains("g3"))
411 || isBluSeries(thingType) || thingType.startsWith(THING_TYPE_SHELLYBLUGW_STR);
414 public static boolean isBluSeries(String thingType) {
415 return thingType.startsWith("shellyblu") && !thingType.startsWith(THING_TYPE_SHELLYBLUGW_STR);
418 public boolean coiotEnabled() {
419 if ((settings.coiot != null) && (settings.coiot.enabled != null)) {
420 return settings.coiot.enabled;
423 // If device is not yet intialized or the enabled property is missing we assume that CoIoT is enabled
427 public static String buildBluServiceName(String name, String mac) throws IllegalArgumentException {
428 String model = name.contains("-") ? substringBefore(name, "-") : name; // e.g. SBBT-02C or just SBDW
430 case SHELLYDT_BLUBUTTON:
431 return (THING_TYPE_SHELLYBLUBUTTON_STR + "-" + mac).toLowerCase();
433 return (THING_TYPE_SHELLYBLUDW_STR + "-" + mac).toLowerCase();
434 case SHELLYDT_BLUMOTION:
435 return (THING_TYPE_SHELLYBLUMOTION_STR + "-" + mac).toLowerCase();
437 return (THING_TYPE_SHELLYBLUHT_STR + "-" + mac).toLowerCase();
439 throw new IllegalArgumentException("Unsupported BLU device model " + model);