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.api1;
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.util.ShellyUtils.*;
19 import java.util.List;
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.binding.shelly.internal.api.ShellyApiInterface;
25 import org.openhab.binding.shelly.internal.api.ShellyDeviceProfile;
26 import org.openhab.binding.shelly.internal.api1.Shelly1CoapJSonDTO.CoIotDescrBlk;
27 import org.openhab.binding.shelly.internal.api1.Shelly1CoapJSonDTO.CoIotDescrSen;
28 import org.openhab.binding.shelly.internal.api1.Shelly1CoapJSonDTO.CoIotSensor;
29 import org.openhab.binding.shelly.internal.handler.ShellyColorUtils;
30 import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
31 import org.openhab.core.library.types.OnOffType;
32 import org.openhab.core.library.types.OpenClosedType;
33 import org.openhab.core.library.types.StringType;
34 import org.openhab.core.library.unit.Units;
35 import org.openhab.core.types.State;
36 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory;
39 import com.google.gson.Gson;
40 import com.google.gson.GsonBuilder;
41 import com.google.gson.JsonSyntaxException;
44 * The {@link Shelly1CoIoTProtocol} implements common functions for the CoIoT implementations
46 * @author Markus Michels - Initial contribution
49 public class Shelly1CoIoTProtocol {
50 private final Logger logger = LoggerFactory.getLogger(Shelly1CoIoTProtocol.class);
51 protected final String thingName;
52 protected final ShellyThingInterface thingHandler;
53 protected final ShellyDeviceProfile profile;
54 protected final ShellyApiInterface api;
55 protected final Map<String, CoIotDescrBlk> blkMap;
56 protected final Map<String, CoIotDescrSen> sensorMap;
57 private final Gson gson = new GsonBuilder().create();
59 // Due to the fact that the device reports only the current/last status, but no real events, we need to distinguish
60 // between a real update or just a repeated status on periodic updates
61 protected int[] lastEventCount = { -1, -1, -1, -1, -1, -1, -1, -1 }; // 4Pro has 4 relays, so 8 should be fine
62 protected String[] inputEvent = { "", "", "", "", "", "", "", "" };
63 protected String lastWakeup = "";
65 public Shelly1CoIoTProtocol(String thingName, ShellyThingInterface thingHandler, Map<String, CoIotDescrBlk> blkMap,
66 Map<String, CoIotDescrSen> sensorMap) {
67 this.thingName = thingName;
68 this.thingHandler = thingHandler;
70 this.sensorMap = sensorMap;
71 this.profile = thingHandler.getProfile();
72 this.api = thingHandler.getApi();
75 protected boolean handleStatusUpdate(List<CoIotSensor> sensorUpdates, CoIotDescrSen sen, CoIotSensor s,
76 Map<String, State> updates, ShellyColorUtils col) {
77 // Process status information and convert into channel updates
78 int rIndex = getIdFromBlk(sen);
79 String rGroup = getProfile().numRelays <= 1 ? CHANNEL_GROUP_RELAY_CONTROL
80 : CHANNEL_GROUP_RELAY_CONTROL + rIndex;
82 switch (sen.type.toLowerCase()) {
83 case "b": // BatteryLevel +
84 updateChannel(updates, CHANNEL_GROUP_BATTERY, CHANNEL_SENSOR_BAT_LEVEL,
85 toQuantityType(s.value, 0, Units.PERCENT));
87 case "h" /* Humidity */:
88 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_HUM,
89 toQuantityType(s.value, DIGITS_PERCENT, Units.PERCENT));
91 case "m" /* Motion */:
92 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_MOTION,
93 s.value == 1 ? OnOffType.ON : OnOffType.OFF);
95 case "l": // Luminosity +
96 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_LUX,
97 toQuantityType(s.value, DIGITS_LUX, Units.LUX));
100 switch (sen.desc.toLowerCase()) {
101 case "state": // Relay status +
103 updatePower(profile, updates, rIndex, sen, s, sensorUpdates);
106 handleInput(sen, s, rGroup, updates);
109 // already handled by state/output
111 case "overtemp": // ++
113 thingHandler.postEvent(ALARM_TYPE_OVERTEMP, true);
117 // work around: Roller reports 101% instead max 100
118 double pos = Math.max(SHELLY_MIN_ROLLER_POS, Math.min(s.value, SHELLY_MAX_ROLLER_POS));
119 updateChannel(updates, CHANNEL_GROUP_ROL_CONTROL, CHANNEL_ROL_CONTROL_CONTROL,
120 toQuantityType(SHELLY_MAX_ROLLER_POS - pos, Units.PERCENT));
121 updateChannel(updates, CHANNEL_GROUP_ROL_CONTROL, CHANNEL_ROL_CONTROL_POS,
122 toQuantityType(pos, Units.PERCENT));
125 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_FLOOD,
126 s.value == 1 ? OnOffType.ON : OnOffType.OFF);
128 case "vibration": // DW with FW1.6.5+
129 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_VIBRATION,
130 s.value == 1 ? OnOffType.ON : OnOffType.OFF);
132 thingHandler.triggerChannel(CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_ALARM_STATE,
133 EVENT_TYPE_VIBRATION);
136 case "luminositylevel": // +
137 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_ILLUM, getStringType(s.valueStr));
139 case "charger": // Sense
140 updateChannel(updates, CHANNEL_GROUP_DEV_STATUS, CHANNEL_DEVST_CHARGER,
141 s.value == 1 ? OnOffType.ON : OnOffType.OFF);
145 col.setRed((int) s.value);
146 updateChannel(updates, CHANNEL_GROUP_COLOR_CONTROL, CHANNEL_COLOR_RED,
147 ShellyColorUtils.toPercent((int) s.value));
150 col.setGreen((int) s.value);
151 updateChannel(updates, CHANNEL_GROUP_COLOR_CONTROL, CHANNEL_COLOR_GREEN,
152 ShellyColorUtils.toPercent((int) s.value));
155 col.setBlue((int) s.value);
156 updateChannel(updates, CHANNEL_GROUP_COLOR_CONTROL, CHANNEL_COLOR_BLUE,
157 ShellyColorUtils.toPercent((int) s.value));
160 col.setWhite((int) s.value);
161 updateChannel(updates, CHANNEL_GROUP_COLOR_CONTROL, CHANNEL_COLOR_WHITE,
162 ShellyColorUtils.toPercent((int) s.value));
165 col.setGain((int) s.value);
166 updateChannel(updates, CHANNEL_GROUP_COLOR_CONTROL, CHANNEL_COLOR_GAIN,
167 ShellyColorUtils.toPercent((int) s.value, SHELLY_MIN_GAIN, SHELLY_MAX_GAIN));
170 String sensorError = s.valueStr != null ? getString(s.valueStr) : "" + s.value;
171 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_ERROR, getStringType(sensorError));
187 public static boolean updateChannel(Map<String, State> updates, String group, String channel, State value) {
188 updates.put(mkChannelId(group, channel), value);
192 protected void handleInput(CoIotDescrSen sen, CoIotSensor s, String rGroup, Map<String, State> updates) {
193 int idx = getSensorNumber(sen.desc, sen.id) - 1;
194 String iGroup = profile.getInputGroup(idx);
195 String iChannel = CHANNEL_INPUT + profile.getInputSuffix(idx);
196 updateChannel(updates, iGroup, iChannel, s.value == 0 ? OnOffType.OFF : OnOffType.ON);
199 protected void handleInputEvent(CoIotDescrSen sen, String type, int count, int serial, Map<String, State> updates) {
200 int idx = getSensorNumber(sen.desc, sen.id) - 1;
201 String group = profile.getInputGroup(idx);
204 updateChannel(updates, group, CHANNEL_STATUS_EVENTTYPE + profile.getInputSuffix(idx), new StringType(type));
205 inputEvent[idx] = type;
208 updateChannel(updates, group, CHANNEL_STATUS_EVENTCOUNT + profile.getInputSuffix(idx), getDecimal(count));
210 "{}: Check button[{}] for event trigger (inButtonMode={}, isButton={}, hasBattery={}, serial={}, count={}, lastEventCount[{}]={}",
211 thingName, idx, profile.inButtonMode(idx), profile.isButton, profile.hasBattery, serial, count, idx,
212 lastEventCount[idx]);
213 if (profile.inButtonMode(idx) && ((profile.hasBattery && count == 1) || lastEventCount[idx] == -1
214 || count != lastEventCount[idx])) {
215 if (!profile.isButton || (profile.isButton && (serial != 0x200))) { // skip duplicate on wake-up
216 logger.debug("{}: Trigger event {}", thingName, inputEvent[idx]);
217 thingHandler.triggerButton(group, idx, inputEvent[idx]);
220 lastEventCount[idx] = count;
226 * Handles the combined updated of the brightness channel:
227 * brightness$Switch is the OnOffType (power state)
228 * brightness&Value is the brightness value
230 * @param profile Device profile, required to select the channel group and name
231 * @param updates List of updates. updatePower will add brightness$Switch and brightness&Value if changed
232 * @param id Sensor id from the update
233 * @param sen Sensor description from the update
234 * @param s New sensor value
235 * @param allUpdatesList of updates. This is required, because we need to update both values at the same time
237 protected void updatePower(ShellyDeviceProfile profile, Map<String, State> updates, int id, CoIotDescrSen sen,
238 CoIotSensor s, List<CoIotSensor> allUpdates) {
240 String channel = CHANNEL_BRIGHTNESS;
241 String checkL = ""; // RGBW-white uses 4 different Power, Brightness, VSwitch values
242 if (profile.isLight || profile.isDimmer) {
243 if (profile.isBulb || profile.inColor) {
244 group = CHANNEL_GROUP_LIGHT_CONTROL;
245 channel = CHANNEL_LIGHT_POWER;
246 } else if (profile.isDuo) {
247 group = CHANNEL_GROUP_WHITE_CONTROL;
248 } else if (profile.isDimmer) {
249 group = CHANNEL_GROUP_RELAY_CONTROL;
250 } else if (profile.isRGBW2) {
251 checkL = String.valueOf(id); // String.valueOf(id - 1); // id is 1-based, L is 0-based
252 group = CHANNEL_GROUP_LIGHT_CHANNEL + id;
253 logger.trace("{}: updatePower() for L={}", thingName, checkL);
256 // We need to update brightness and on/off state at the same time to avoid "flipping brightness slider" in
258 double brightness = -1.0;
260 for (CoIotSensor update : allUpdates) {
261 CoIotDescrSen d = fixDescription(sensorMap.get(update.id), blkMap);
262 if (!checkL.isEmpty() && !d.links.equals(checkL)) {
263 // continue until we find the correct one
266 if ("brightness".equalsIgnoreCase(d.desc)) {
267 brightness = update.value;
268 } else if ("output".equalsIgnoreCase(d.desc) || "state".equalsIgnoreCase(d.desc)) {
269 power = update.value;
273 updateChannel(updates, group, channel + "$Switch", power == 1 ? OnOffType.ON : OnOffType.OFF);
275 if (brightness != -1) {
276 updateChannel(updates, group, channel + "$Value",
277 toQuantityType(power == 1 ? brightness : 0, DIGITS_NONE, Units.PERCENT));
279 } else if (profile.hasRelays) {
280 group = profile.numRelays <= 1 ? CHANNEL_GROUP_RELAY_CONTROL : CHANNEL_GROUP_RELAY_CONTROL + id;
281 updateChannel(updates, group, CHANNEL_OUTPUT, s.value == 1 ? OnOffType.ON : OnOffType.OFF);
282 } else if (profile.isSensor) {
284 if (profile.isDW) { // Door Window has item type Contact
285 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_STATE,
286 s.value != 0 ? OpenClosedType.OPEN : OpenClosedType.CLOSED);
288 updateChannel(updates, CHANNEL_GROUP_SENSOR, CHANNEL_SENSOR_STATE,
289 s.value == 1 ? OnOffType.ON : OnOffType.OFF);
295 * Find index of Input id, which is required to map to channel name
297 * @parm sensorDesc D field from sensor update
298 * @param sensorId The id from the sensor update
299 * @return Index of found entry (+1 will be the suffix for the channel name) or null if sensorId is not found
301 protected int getSensorNumber(String sensorDesc, String sensorId) {
303 for (Map.Entry<String, CoIotDescrSen> se : sensorMap.entrySet()) {
304 CoIotDescrSen sen = se.getValue();
305 if (sen.desc.equalsIgnoreCase(sensorDesc)) {
306 idx++; // iterate from input1..2..n
308 if (sen.id.equalsIgnoreCase(sensorId) && blkMap.containsKey(sen.links)) {
309 int id = getIdFromBlk(sen);
314 if (sen.id.equalsIgnoreCase(sensorId)) {
318 logger.debug("{}: sensorId {} not found in sensorMap!", thingName, sensorId);
322 protected int getIdFromBlk(CoIotDescrSen sen) {
324 CoIotDescrBlk blk = blkMap.get(sen.links);
326 String desc = blk.desc.toLowerCase();
327 if (desc.startsWith(SHELLY_CLASS_RELAY) || desc.startsWith(SHELLY_CLASS_ROLLER)
328 || desc.startsWith(SHELLY_CLASS_LIGHT) || desc.startsWith(SHELLY_CLASS_EMETER)) {
329 if (desc.contains("_")) { // CoAP v2
330 idx = Integer.parseInt(substringAfter(desc, "_"));
332 if (desc.substring(0, 5).equalsIgnoreCase(SHELLY_CLASS_RELAY)) {
333 idx = Integer.parseInt(substringAfter(desc, SHELLY_CLASS_RELAY));
335 if (desc.substring(0, 6).equalsIgnoreCase(SHELLY_CLASS_ROLLER)) {
336 idx = Integer.parseInt(substringAfter(desc, SHELLY_CLASS_ROLLER));
338 if (desc.substring(0, SHELLY_CLASS_EMETER.length()).equalsIgnoreCase(SHELLY_CLASS_EMETER)) {
339 idx = Integer.parseInt(substringAfter(desc, SHELLY_CLASS_EMETER));
342 idx = idx + 1; // make it 1-based (sen.L is 0-based)
350 * Get matching sensorId for updates on "External Temperature" - there might be more than 1 sensor.
352 * @param sensorId sensorId to map into a channel index
353 * @return Index of the corresponding channel (e.g. 0 build temperature1, 1->temperagture2...)
355 protected int getExtTempId(String sensorId) {
357 for (Map.Entry<String, CoIotDescrSen> se : sensorMap.entrySet()) {
358 CoIotDescrSen sen = se.getValue();
359 if ("external_temperature".equalsIgnoreCase(sen.desc) || "external temperature c".equalsIgnoreCase(sen.desc)
360 || ("extTemp".equalsIgnoreCase(sen.desc) && !sen.unit.equalsIgnoreCase(SHELLY_TEMP_FAHRENHEIT))) {
361 idx++; // iterate from temperature1..2..n
363 if (sen.id.equalsIgnoreCase(sensorId)) {
367 logger.debug("{}: sensorId {} not found in sensorMap!", thingName, sensorId);
371 protected ShellyDeviceProfile getProfile() {
375 public CoIotDescrSen fixDescription(@Nullable CoIotDescrSen sen, Map<String, CoIotDescrBlk> blkMap) {
376 return sen != null ? sen : new CoIotDescrSen();
379 public void completeMissingSensorDefinition(Map<String, CoIotDescrSen> sensorMap) {
382 protected void addSensor(Map<String, CoIotDescrSen> sensorMap, String key, String json) {
384 if (!sensorMap.containsKey(key)) {
385 CoIotDescrSen sen = gson.fromJson(json, CoIotDescrSen.class);
387 sensorMap.put(key, sen);
390 } catch (JsonSyntaxException e) {
391 // should never happen
392 logger.trace("Unable to parse sensor definition: {}", json, e);
396 public String getLastWakeup() {