]> git.basschouten.com Git - openhab-addons.git/blob
d271e1c993b03d5fb4fb56f837762c4844d52e83
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.api1;
14
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.*;
18
19 import java.nio.charset.StandardCharsets;
20 import java.util.Base64;
21 import java.util.HashMap;
22 import java.util.Map;
23 import java.util.concurrent.ExecutionException;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.eclipse.jetty.client.api.ContentResponse;
30 import org.eclipse.jetty.client.api.Request;
31 import org.eclipse.jetty.http.HttpHeader;
32 import org.eclipse.jetty.http.HttpMethod;
33 import org.eclipse.jetty.http.HttpStatus;
34 import org.openhab.binding.shelly.internal.api.ShellyApiException;
35 import org.openhab.binding.shelly.internal.api.ShellyApiInterface;
36 import org.openhab.binding.shelly.internal.api.ShellyApiResult;
37 import org.openhab.binding.shelly.internal.api.ShellyDeviceProfile;
38 import org.openhab.binding.shelly.internal.api.ShellyHttpClient;
39 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyOtaCheckResult;
40 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyRollerStatus;
41 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySendKeyList;
42 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySenseKeyCode;
43 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsDevice;
44 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsLight;
45 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsLogin;
46 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsStatus;
47 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsUpdate;
48 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyShortLightStatus;
49 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusLight;
50 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusRelay;
51 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor;
52 import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyThermnostat;
53 import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
54 import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
55 import org.openhab.core.library.unit.ImperialUnits;
56 import org.openhab.core.library.unit.SIUnits;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 import com.google.gson.JsonSyntaxException;
61
62 /**
63  * {@link Shelly1HttpApi} wraps the Shelly REST API and provides various low level function to access the device api
64  * (not
65  * cloud api).
66  *
67  * @author Markus Michels - Initial contribution
68  */
69 @NonNullByDefault
70 public class Shelly1HttpApi extends ShellyHttpClient implements ShellyApiInterface {
71     private final Logger logger = LoggerFactory.getLogger(Shelly1HttpApi.class);
72     private final ShellyDeviceProfile profile;
73
74     public Shelly1HttpApi(String thingName, ShellyThingInterface thing) {
75         super(thingName, thing);
76         profile = thing.getProfile();
77     }
78
79     /**
80      * Simple initialization - called by discovery handler
81      *
82      * @param thingName Symbolic thing name
83      * @param config Thing Configuration
84      * @param httpClient HTTP Client to be passed to ShellyHttpClient
85      */
86     public Shelly1HttpApi(String thingName, ShellyThingConfiguration config, HttpClient httpClient) {
87         super(thingName, config, httpClient);
88         this.profile = new ShellyDeviceProfile();
89     }
90
91     @Override
92     public ShellySettingsDevice getDeviceInfo() throws ShellyApiException {
93         ShellySettingsDevice info = callApi(SHELLY_URL_DEVINFO, ShellySettingsDevice.class);
94         info.gen = 1;
95         return info;
96     }
97
98     @Override
99     public String setDebug(boolean enabled) throws ShellyApiException {
100         return callApi(SHELLY_URL_SETTINGS + "?debug_enable=" + Boolean.valueOf(enabled), String.class);
101     }
102
103     @Override
104     public String getDebugLog(String id) throws ShellyApiException {
105         return callApi("/debug/" + id, String.class);
106     }
107
108     /**
109      * Initialize the device profile
110      *
111      * @param thingType Type of DEVICE as returned from the thing properties (based on discovery)
112      * @return Initialized ShellyDeviceProfile
113      * @throws ShellyApiException
114      */
115     @Override
116     public ShellyDeviceProfile getDeviceProfile(String thingType) throws ShellyApiException {
117         String json = httpRequest(SHELLY_URL_SETTINGS);
118         if (json.contains("\"type\":\"SHDM-")) {
119             logger.trace("{}: Detected a Shelly Dimmer: fix Json (replace lights[] tag with dimmers[]", thingName);
120             json = fixDimmerJson(json);
121         }
122
123         // Map settings to device profile for Light and Sense
124         profile.initialize(thingType, json);
125
126         // 2nd level initialization
127         profile.thingName = profile.hostname;
128         if (profile.isLight && (profile.numMeters == 0)) {
129             logger.debug("{}: Get number of meters from light status", thingName);
130             ShellyStatusLight status = getLightStatus();
131             profile.numMeters = status.meters != null ? status.meters.size() : 0;
132         }
133         if (profile.isSense) {
134             profile.irCodes = getIRCodeList();
135             logger.debug("{}: Sense stored key list loaded, {} entries.", thingName, profile.irCodes.size());
136         }
137
138         return profile;
139     }
140
141     @Override
142     public boolean isInitialized() {
143         return profile.initialized;
144     }
145
146     /**
147      * Get generic device settings/status. Json returned from API will be mapped to a Gson object
148      *
149      * @return Device settings/status as ShellySettingsStatus object
150      * @throws ShellyApiException
151      */
152     @Override
153     public ShellySettingsStatus getStatus() throws ShellyApiException {
154         String json = "";
155         try {
156             json = httpRequest(SHELLY_URL_STATUS);
157
158             // Dimmer2 returns invalid json type for loaderror :-(
159             json = json.replace("\"loaderror\":0,", "\"loaderror\":false,")
160                     .replace("\"loaderror\":1,", "\"loaderror\":true,")
161                     .replace("\"tmp\":{\"value\": \"null\",", "\"tmp\":{\"value\": null,");
162             ShellySettingsStatus status = fromJson(gson, json, ShellySettingsStatus.class);
163             status.json = json;
164             return status;
165         } catch (JsonSyntaxException e) {
166             throw new ShellyApiException("Unable to parse JSON: " + json, e);
167         }
168     }
169
170     @Override
171     public ShellyStatusRelay getRelayStatus(int relayIndex) throws ShellyApiException {
172         return callApi(SHELLY_URL_STATUS_RELEAY + "/" + relayIndex, ShellyStatusRelay.class);
173     }
174
175     @Override
176     public void setRelayTurn(int id, String turnMode) throws ShellyApiException {
177         callApi(getControlUriPrefix(id) + "?" + SHELLY_LIGHT_TURN + "=" + turnMode.toLowerCase(), String.class);
178     }
179
180     @Override
181     public void resetMeterTotal(int id) throws ShellyApiException {
182         callApi(SHELLY_URL_STATUS_EMETER + "/" + id + "?reset_totals=true", ShellyStatusRelay.class);
183     }
184
185     @Override
186     public ShellyShortLightStatus setLightTurn(int id, String turnMode) throws ShellyApiException {
187         return callApi(getControlUriPrefix(id) + "?" + SHELLY_LIGHT_TURN + "=" + turnMode.toLowerCase(),
188                 ShellyShortLightStatus.class);
189     }
190
191     @Override
192     public void setBrightness(int id, int brightness, boolean autoOn) throws ShellyApiException {
193         String turn = autoOn ? SHELLY_LIGHT_TURN + "=" + SHELLY_API_ON + "&" : "";
194         httpRequest(getControlUriPrefix(id) + "?" + turn + "brightness=" + brightness);
195     }
196
197     @Override
198     public ShellyRollerStatus getRollerStatus(int rollerIndex) throws ShellyApiException {
199         String uri = SHELLY_URL_CONTROL_ROLLER + "/" + rollerIndex + "/pos";
200         return callApi(uri, ShellyRollerStatus.class);
201     }
202
203     @Override
204     public void setRollerTurn(int relayIndex, String turnMode) throws ShellyApiException {
205         httpRequest(SHELLY_URL_CONTROL_ROLLER + "/" + relayIndex + "?go=" + turnMode);
206     }
207
208     @Override
209     public void setRollerPos(int relayIndex, int position) throws ShellyApiException {
210         httpRequest(SHELLY_URL_CONTROL_ROLLER + "/" + relayIndex + "?go=to_pos&roller_pos=" + position);
211     }
212
213     @Override
214     public ShellyShortLightStatus getLightStatus(int index) throws ShellyApiException {
215         return callApi(getControlUriPrefix(index), ShellyShortLightStatus.class);
216     }
217
218     @Override
219     public ShellyStatusSensor getSensorStatus() throws ShellyApiException {
220         ShellyStatusSensor status = callApi(SHELLY_URL_STATUS, ShellyStatusSensor.class);
221         if (profile.isSense) {
222             // complete reported data, map C to F or vice versa: C=(F - 32) * 0.5556;
223             status.tmp.tC = status.tmp.units.equals(SHELLY_TEMP_CELSIUS) ? status.tmp.value
224                     : ImperialUnits.FAHRENHEIT.getConverterTo(SIUnits.CELSIUS).convert(getDouble(status.tmp.value))
225                             .doubleValue();
226             double f = (double) SIUnits.CELSIUS.getConverterTo(ImperialUnits.FAHRENHEIT)
227                     .convert(getDouble(status.tmp.value));
228             status.tmp.tF = status.tmp.units.equals(SHELLY_TEMP_FAHRENHEIT) ? status.tmp.value : f;
229         }
230         if ((status.charger == null) && (profile.settings.externalPower != null)) {
231             // SHelly H&T uses external_power, Sense uses charger
232             status.charger = profile.settings.externalPower != 0;
233         }
234         if (status.tmp != null && status.tmp.tC == null && status.tmp.value != null) { // Motion is is missing tC and tF
235             status.tmp.tC = getString(status.tmp.units).toUpperCase().equals(SHELLY_TEMP_FAHRENHEIT)
236                     ? ImperialUnits.FAHRENHEIT.getConverterTo(SIUnits.CELSIUS).convert(status.tmp.value).doubleValue()
237                     : status.tmp.value;
238         }
239         return status;
240     }
241
242     @Override
243     public void setAutoTimer(int index, String timerName, double value) throws ShellyApiException {
244         String type = SHELLY_CLASS_RELAY;
245         if (profile.isRoller) {
246             type = SHELLY_CLASS_ROLLER;
247         } else if (profile.isLight) {
248             type = SHELLY_CLASS_LIGHT;
249         }
250         String uri = SHELLY_URL_SETTINGS + "/" + type + "/" + index + "?" + timerName + "=" + value;
251         httpRequest(uri);
252     }
253
254     @Override
255     public void setSleepTime(int value) throws ShellyApiException {
256         httpRequest(SHELLY_URL_SETTINGS + "?sleep_time=" + value);
257     }
258
259     @Override
260     public void setValveTemperature(int valveId, int value) throws ShellyApiException {
261         request("/thermostat/" + valveId + "?target_t_enabled=1&target_t=" + value);
262     }
263
264     @Override
265     public void setValveMode(int valveId, boolean auto) throws ShellyApiException {
266         String uri = "/settings/thermostat/" + valveId + "?target_t_enabled=" + (auto ? "1" : "0");
267         if (auto && profile.settings.thermostats != null) {
268             uri = uri + "&target_t=" + getDouble(profile.settings.thermostats.get(0).targetTemp.value);
269         }
270         httpRequest(uri); // percentage to open the valve
271     }
272
273     @Override
274     public void setValveProfile(int valveId, int value) throws ShellyApiException {
275         String uri = "/settings/thermostat/" + valveId + "?";
276         request(uri + (value == 0 ? "schedule=0" : "schedule=1&schedule_profile=" + value));
277     }
278
279     @Override
280     public void setValvePosition(int valveId, double value) throws ShellyApiException {
281         request("/thermostat/" + valveId + "?pos=" + value); // percentage to open the valve
282     }
283
284     @Override
285     public void setValveBoostTime(int valveId, int value) throws ShellyApiException {
286         request("/settings/thermostat/" + valveId + "?boost_minutes=" + value);
287     }
288
289     @Override
290     public void startValveBoost(int valveId, int value) throws ShellyApiException {
291         if (profile.settings.thermostats != null) {
292             ShellyThermnostat t = profile.settings.thermostats.get(0);
293             int minutes = value != -1 ? value : getInteger(t.boostMinutes);
294             httpRequest("/thermostat/0?boost_minutes=" + minutes);
295         }
296     }
297
298     @Override
299     public void setLedStatus(String ledName, boolean value) throws ShellyApiException {
300         httpRequest(SHELLY_URL_SETTINGS + "?" + ledName + "=" + (value ? SHELLY_API_TRUE : SHELLY_API_FALSE));
301     }
302
303     public ShellySettingsLight getLightSettings() throws ShellyApiException {
304         return callApi(SHELLY_URL_SETTINGS_LIGHT, ShellySettingsLight.class);
305     }
306
307     @Override
308     public ShellyStatusLight getLightStatus() throws ShellyApiException {
309         return callApi(SHELLY_URL_STATUS, ShellyStatusLight.class);
310     }
311
312     public void setLightSetting(String parm, String value) throws ShellyApiException {
313         httpRequest(SHELLY_URL_SETTINGS + "?" + parm + "=" + value);
314     }
315
316     @Override
317     public ShellySettingsLogin getLoginSettings() throws ShellyApiException {
318         return callApi(SHELLY_URL_SETTINGS + "/login", ShellySettingsLogin.class);
319     }
320
321     @Override
322     public ShellySettingsLogin setLoginCredentials(String user, String password) throws ShellyApiException {
323         return callApi(SHELLY_URL_SETTINGS + "/login?enabled=yes&username=" + urlEncode(user) + "&password="
324                 + urlEncode(password), ShellySettingsLogin.class);
325     }
326
327     @Override
328     public String getCoIoTDescription() throws ShellyApiException {
329         try {
330             return callApi("/cit/d", String.class);
331         } catch (ShellyApiException e) {
332             if (e.getApiResult().isNotFound()) {
333                 return ""; // only supported by FW 1.10+
334             }
335             throw e;
336         }
337     }
338
339     @Override
340     public ShellySettingsLogin setCoIoTPeer(String peer) throws ShellyApiException {
341         return callApi(SHELLY_URL_SETTINGS + "?coiot_enable=true&coiot_peer=" + peer, ShellySettingsLogin.class);
342     }
343
344     @Override
345     public String deviceReboot() throws ShellyApiException {
346         return callApi(SHELLY_URL_RESTART, String.class);
347     }
348
349     @Override
350     public String factoryReset() throws ShellyApiException {
351         return callApi(SHELLY_URL_SETTINGS + "?reset=true", String.class);
352     }
353
354     @Override
355     public ShellyOtaCheckResult checkForUpdate() throws ShellyApiException {
356         return callApi("/ota/check", ShellyOtaCheckResult.class); // nw FW 1.10+: trigger update check
357     }
358
359     @Override
360     public String setWiFiRecovery(boolean enable) throws ShellyApiException {
361         return callApi(SHELLY_URL_SETTINGS + "?wifirecovery_reboot_enabled=" + (enable ? "true" : "false"),
362                 String.class); // FW 1.10+: Enable auto-restart on WiFi problems
363     }
364
365     @Override
366     public String setApRoaming(boolean enable) throws ShellyApiException { // FW 1.10+: Enable AP Roadming
367         return callApi(SHELLY_URL_SETTINGS + "?ap_roaming_enabled=" + (enable ? "true" : "false"), String.class);
368     }
369
370     @Override
371     public boolean setWiFiRangeExtender(boolean enable) throws ShellyApiException {
372         return false;
373     }
374
375     @Override
376     public boolean setEthernet(boolean enable) throws ShellyApiException {
377         return false;
378     }
379
380     @Override
381     public boolean setBluetooth(boolean enable) throws ShellyApiException {
382         return false;
383     }
384
385     @Override
386     public String resetStaCache() throws ShellyApiException { // FW 1.10+: Reset cached STA/AP list and to a rescan
387         return callApi("/sta_cache_reset", String.class);
388     }
389
390     @Override
391     public ShellySettingsUpdate firmwareUpdate(String uri) throws ShellyApiException {
392         return callApi("/ota?" + uri, ShellySettingsUpdate.class);
393     }
394
395     @Override
396     public String setCloud(boolean enabled) throws ShellyApiException {
397         return callApi("/settings/cloud/?enabled=" + (enabled ? "1" : "0"), String.class);
398     }
399
400     /**
401      * Change between White and Color Mode
402      *
403      * @param mode
404      * @throws ShellyApiException
405      */
406     @Override
407     public void setLightMode(String mode) throws ShellyApiException {
408         if (!mode.isEmpty() && !profile.mode.equals(mode)) {
409             setLightSetting(SHELLY_API_MODE, mode);
410             profile.mode = mode;
411             profile.inColor = profile.isLight && profile.mode.equalsIgnoreCase(SHELLY_MODE_COLOR);
412         }
413     }
414
415     /**
416      * Set a single light parameter
417      *
418      * @param lightIndex Index of the light, usually 0 for Bulb and 0..3 for RGBW2.
419      * @param parm Name of the parameter (see API spec)
420      * @param value The value
421      * @throws ShellyApiException
422      */
423     @Override
424     public void setLightParm(int lightIndex, String parm, String value) throws ShellyApiException {
425         // Bulb, RGW2: /<color mode>/<light id>?parm?value
426         // Dimmer: /light/<light id>?parm=value
427         httpRequest(getControlUriPrefix(lightIndex) + "?" + parm + "=" + value);
428     }
429
430     @Override
431     public void setLightParms(int lightIndex, Map<String, String> parameters) throws ShellyApiException {
432         String url = getControlUriPrefix(lightIndex) + "?";
433         int i = 0;
434         for (String key : parameters.keySet()) {
435             if (i > 0) {
436                 url = url + "&";
437             }
438             url = url + key + "=" + parameters.get(key);
439             i++;
440         }
441         httpRequest(url);
442     }
443
444     /**
445      * Retrieve the IR Code list from the Shelly Sense device. The list could be customized by the user. It defines the
446      * symbolic key code, which gets
447      * map into a PRONTO code
448      *
449      * @return Map of key codes
450      * @throws ShellyApiException
451      */
452     public Map<String, String> getIRCodeList() throws ShellyApiException {
453         String result = httpRequest(SHELLY_URL_LIST_IR);
454         // take pragmatic approach to make the returned JSon into named arrays for Gson parsing
455         String keyList = substringAfter(result, "[");
456         keyList = substringBeforeLast(keyList, "]");
457         keyList = keyList.replaceAll(java.util.regex.Pattern.quote("\",\""), "\", \"name\": \"");
458         keyList = keyList.replaceAll(java.util.regex.Pattern.quote("["), "{ \"id\":");
459         keyList = keyList.replaceAll(java.util.regex.Pattern.quote("]"), "} ");
460         String json = "{\"key_codes\" : [" + keyList + "] }";
461         ShellySendKeyList codes = fromJson(gson, json, ShellySendKeyList.class);
462         Map<String, String> list = new HashMap<>();
463         for (ShellySenseKeyCode key : codes.keyCodes) {
464             if (key != null) {
465                 list.put(key.id, key.name);
466             }
467         }
468         return list;
469     }
470
471     /**
472      * Sends an IR key code to the Shelly Sense.
473      *
474      * @param keyCode A keyCoud could be a symbolic name (as defined in the key map on the device) or a PRONTO Code in
475      *            plain or hex64 format
476      *
477      * @throws ShellyApiException
478      * @throws IllegalArgumentException
479      */
480     @Override
481     public void sendIRKey(String keyCode) throws ShellyApiException, IllegalArgumentException {
482         String type = "";
483         if (profile.irCodes.containsKey(keyCode)) {
484             type = SHELLY_IR_CODET_STORED;
485         } else if ((keyCode.length() > 4) && keyCode.contains(" ")) {
486             type = SHELLY_IR_CODET_PRONTO;
487         } else {
488             type = SHELLY_IR_CODET_PRONTO_HEX;
489         }
490         String url = SHELLY_URL_SEND_IR + "?type=" + type;
491         if (type.equals(SHELLY_IR_CODET_STORED)) {
492             url = url + "&" + "id=" + keyCode;
493         } else if (type.equals(SHELLY_IR_CODET_PRONTO)) {
494             String code = Base64.getEncoder().encodeToString(keyCode.getBytes(StandardCharsets.UTF_8));
495             url = url + "&" + SHELLY_IR_CODET_PRONTO + "=" + code;
496         } else if (type.equals(SHELLY_IR_CODET_PRONTO_HEX)) {
497             url = url + "&" + SHELLY_IR_CODET_PRONTO_HEX + "=" + keyCode;
498         }
499         httpRequest(url);
500     }
501
502     public void setSenseSetting(String setting, String value) throws ShellyApiException {
503         httpRequest(SHELLY_URL_SETTINGS + "?" + setting + "=" + value);
504     }
505
506     /**
507      * Set event callback URLs. Depending on the device different event types are supported. In fact all of them will be
508      * redirected to the binding's servlet and act as a trigger to schedule a status update
509      *
510      * @param ShellyApiException
511      * @throws ShellyApiException
512      */
513     @Override
514     public void setActionURLs() throws ShellyApiException {
515         setRelayEvents();
516         setDimmerEvents();
517         setSensorEventUrls();
518     }
519
520     private void setRelayEvents() throws ShellyApiException {
521         if (profile.settings.relays != null) {
522             int num = profile.isRoller ? profile.numRollers : profile.numRelays;
523             for (int i = 0; i < num; i++) {
524                 setEventUrls(i);
525             }
526         }
527     }
528
529     private void setDimmerEvents() throws ShellyApiException {
530         if (profile.settings.dimmers != null) {
531             int sz = profile.settings.dimmers.size();
532             for (int i = 0; i < sz; i++) {
533                 setEventUrls(i);
534             }
535         } else if (profile.isLight) {
536             setEventUrls(0);
537         }
538     }
539
540     @Override
541     public void muteSmokeAlarm(int id) throws ShellyApiException {
542         throw new ShellyApiException("Request not supported");
543     }
544
545     /**
546      * Set sensor Action URLs
547      *
548      * @throws ShellyApiException
549      */
550     private void setSensorEventUrls() throws ShellyApiException, ShellyApiException {
551         if (profile.isSensor) {
552             logger.debug("{}: Set Sensor Reporting URL", thingName);
553             setEventUrl(config.eventsSensorReport, SHELLY_EVENT_SENSORREPORT, SHELLY_EVENT_DARK, SHELLY_EVENT_TWILIGHT,
554                     SHELLY_EVENT_FLOOD_DETECTED, SHELLY_EVENT_FLOOD_GONE, SHELLY_EVENT_OPEN, SHELLY_EVENT_CLOSE,
555                     SHELLY_EVENT_VIBRATION, SHELLY_EVENT_ALARM_MILD, SHELLY_EVENT_ALARM_HEAVY, SHELLY_EVENT_ALARM_OFF,
556                     SHELLY_EVENT_TEMP_OVER, SHELLY_EVENT_TEMP_UNDER);
557         }
558     }
559
560     /**
561      * Set/delete Relay/Roller/Dimmer Action URLs
562      *
563      * @param index Device Index (0-based)
564      * @throws ShellyApiException
565      */
566     private void setEventUrls(Integer index) throws ShellyApiException {
567         if (profile.isRoller) {
568             setEventUrl(EVENT_TYPE_ROLLER, 0, config.eventsRoller, SHELLY_EVENT_ROLLER_OPEN, SHELLY_EVENT_ROLLER_CLOSE,
569                     SHELLY_EVENT_ROLLER_STOP);
570         } else if (profile.isDimmer) {
571             // 2 set of URLs
572             setEventUrl(EVENT_TYPE_LIGHT, index, config.eventsButton, SHELLY_EVENT_BTN1_ON, SHELLY_EVENT_BTN1_OFF,
573                     SHELLY_EVENT_BTN2_ON, SHELLY_EVENT_BTN2_OFF);
574             setEventUrl(EVENT_TYPE_LIGHT, index, config.eventsPush, SHELLY_EVENT_SHORTPUSH1, SHELLY_EVENT_LONGPUSH1,
575                     SHELLY_EVENT_SHORTPUSH2, SHELLY_EVENT_LONGPUSH2);
576
577             // Relay output
578             setEventUrl(EVENT_TYPE_LIGHT, index, config.eventsSwitch, SHELLY_EVENT_OUT_ON, SHELLY_EVENT_OUT_OFF);
579         } else if (profile.hasRelays) {
580             // Standard relays: btn_xxx, out_xxx, short/longpush URLs
581             setEventUrl(EVENT_TYPE_RELAY, index, config.eventsButton, SHELLY_EVENT_BTN_ON, SHELLY_EVENT_BTN_OFF);
582             setEventUrl(EVENT_TYPE_RELAY, index, config.eventsPush, SHELLY_EVENT_SHORTPUSH, SHELLY_EVENT_LONGPUSH);
583             setEventUrl(EVENT_TYPE_RELAY, index, config.eventsSwitch, SHELLY_EVENT_OUT_ON, SHELLY_EVENT_OUT_OFF);
584         } else if (profile.isLight) {
585             // Duo, Bulb
586             setEventUrl(EVENT_TYPE_LIGHT, index, config.eventsSwitch, SHELLY_EVENT_OUT_ON, SHELLY_EVENT_OUT_OFF);
587         }
588     }
589
590     private void setEventUrl(boolean enabled, String... eventTypes) throws ShellyApiException {
591         if (config.localIp.isEmpty()) {
592             throw new ShellyApiException(thingName + ": Local IP address was not detected, can't build Callback URL");
593         }
594         for (String eventType : eventTypes) {
595             if (profile.containsEventUrl(eventType)) {
596                 // H&T adds the type=xx to report_url itself, so we need to ommit here
597                 String eclass = profile.isSensor ? EVENT_TYPE_SENSORDATA : eventType;
598                 String urlParm = eventType.contains("temp") || profile.isHT ? "" : "?type=" + eventType;
599                 String callBackUrl = "http://" + config.localIp + ":" + config.localPort + SHELLY1_CALLBACK_URI + "/"
600                         + profile.thingName + "/" + eclass + urlParm;
601                 String newUrl = enabled ? callBackUrl : SHELLY_NULL_URL;
602                 String testUrl = "\"" + mkEventUrl(eventType) + "\":\"" + newUrl + "\"";
603                 if (!enabled && !profile.settingsJson.contains(testUrl)) {
604                     // Don't set URL to null when the current one doesn't point to this OH
605                     // Don't interfere with a 3rd party App
606                     continue;
607                 }
608                 if (!profile.settingsJson.contains(testUrl)) {
609                     // Current Action URL is != new URL
610                     logger.debug("{}: Set new url for event type {}: {}", thingName, eventType, newUrl);
611                     httpRequest(SHELLY_URL_SETTINGS + "?" + mkEventUrl(eventType) + "=" + urlEncode(newUrl));
612                 }
613             }
614         }
615     }
616
617     private void setEventUrl(String deviceClass, Integer index, boolean enabled, String... eventTypes)
618             throws ShellyApiException {
619         for (String eventType : eventTypes) {
620             if (profile.containsEventUrl(eventType)) {
621                 String callBackUrl = "http://" + config.localIp + ":" + config.localPort + SHELLY1_CALLBACK_URI + "/"
622                         + profile.thingName + "/" + deviceClass + "/" + index + "?type=" + eventType;
623                 String newUrl = enabled ? callBackUrl : SHELLY_NULL_URL;
624                 String test = "\"" + mkEventUrl(eventType) + "\":\"" + callBackUrl + "\"";
625                 if (!enabled && !profile.settingsJson.contains(test)) {
626                     // Don't set URL to null when the current one doesn't point to this OH
627                     // Don't interfere with a 3rd party App
628                     continue;
629                 }
630                 test = "\"" + mkEventUrl(eventType) + "\":\"" + newUrl + "\"";
631                 if (!profile.settingsJson.contains(test)) {
632                     // Current Action URL is != new URL
633                     logger.debug("{}: Set URL for type {} to {}", thingName, eventType, newUrl);
634                     httpRequest(SHELLY_URL_SETTINGS + "/" + deviceClass + "/" + index + "?" + mkEventUrl(eventType)
635                             + "=" + urlEncode(newUrl));
636                 }
637             }
638         }
639     }
640
641     private static String mkEventUrl(String eventType) {
642         return eventType + SHELLY_EVENTURL_SUFFIX;
643     }
644
645     /**
646      * Submit GET request and return response, check for invalid responses
647      *
648      * @param uri: URI (e.g. "/settings")
649      */
650     @Override
651     public <T> T callApi(String uri, Class<T> classOfT) throws ShellyApiException {
652         String json = request(uri);
653         return fromJson(gson, json, classOfT);
654     }
655
656     private String request(String uri) throws ShellyApiException {
657         ShellyApiResult apiResult = new ShellyApiResult();
658         int retries = 3;
659         boolean timeout = false;
660         while (retries > 0) {
661             try {
662                 apiResult = innerRequest(HttpMethod.GET, uri);
663                 if (timeout) {
664                     logger.debug("{}: API timeout #{}/{} recovered ({})", thingName, timeoutErrors, timeoutsRecovered,
665                             apiResult.getUrl());
666                     timeoutsRecovered++;
667                 }
668                 return apiResult.response; // successful
669             } catch (ShellyApiException e) {
670                 if ((!e.isTimeout() && !apiResult.isHttpServerError()) || profile.hasBattery || (retries == 0)) {
671                     // Sensor in sleep mode or API exception for non-battery device or retry counter expired
672                     throw e; // non-timeout exception
673                 }
674
675                 timeout = true;
676                 retries--;
677                 timeoutErrors++; // count the retries
678                 logger.debug("{}: API Timeout, retry #{} ({})", thingName, timeoutErrors, e.toString());
679             }
680         }
681         throw new ShellyApiException("API Timeout or inconsistent result"); // successful
682     }
683
684     private ShellyApiResult innerRequest(HttpMethod method, String uri) throws ShellyApiException {
685         Request request = null;
686         String url = "http://" + config.deviceIp + uri;
687         ShellyApiResult apiResult = new ShellyApiResult(method.toString(), url);
688
689         try {
690             request = httpClient.newRequest(url).method(method.toString()).timeout(SHELLY_API_TIMEOUT_MS,
691                     TimeUnit.MILLISECONDS);
692
693             if (!config.userId.isEmpty()) {
694                 String value = config.userId + ":" + config.password;
695                 request.header(HTTP_HEADER_AUTH,
696                         HTTP_AUTH_TYPE_BASIC + " " + Base64.getEncoder().encodeToString(value.getBytes()));
697             }
698             request.header(HttpHeader.ACCEPT, CONTENT_TYPE_JSON);
699             logger.trace("{}: HTTP {} for {}", thingName, method, url);
700
701             // Do request and get response
702             ContentResponse contentResponse = request.send();
703             apiResult = new ShellyApiResult(contentResponse);
704             String response = contentResponse.getContentAsString().replace("\t", "").replace("\r\n", "").trim();
705             logger.trace("{}: HTTP Response {}: {}", thingName, contentResponse.getStatus(), response);
706
707             // validate response, API errors are reported as Json
708             if (contentResponse.getStatus() != HttpStatus.OK_200) {
709                 throw new ShellyApiException(apiResult);
710             }
711             if (response.isEmpty() || !response.startsWith("{") && !response.startsWith("[") && !url.contains("/debug/")
712                     && !url.contains("/sta_cache_reset")) {
713                 throw new ShellyApiException("Unexpected response: " + response);
714             }
715         } catch (ExecutionException | InterruptedException | TimeoutException | IllegalArgumentException e) {
716             ShellyApiException ex = new ShellyApiException(apiResult, e);
717             if (!ex.isTimeout()) { // will be handled by the caller
718                 logger.trace("{}: API call returned exception", thingName, ex);
719             }
720             throw ex;
721         }
722         return apiResult;
723     }
724
725     @Override
726     public String getControlUriPrefix(Integer id) {
727         String uri = "";
728         if (profile.isLight || profile.isDimmer) {
729             if (profile.isDuo || profile.isDimmer) {
730                 // Duo + Dimmer
731                 uri = SHELLY_URL_CONTROL_LIGHT;
732             } else {
733                 // Bulb + RGBW2
734                 uri = "/" + (profile.inColor ? SHELLY_MODE_COLOR : SHELLY_MODE_WHITE);
735             }
736         } else {
737             // Roller, Relay
738             uri = SHELLY_URL_CONTROL_RELEAY;
739         }
740         uri = uri + "/" + id;
741         return uri;
742     }
743
744     @Override
745     public int getTimeoutErrors() {
746         return timeoutErrors;
747     }
748
749     @Override
750     public int getTimeoutsRecovered() {
751         return timeoutsRecovered;
752     }
753
754     @Override
755     public void close() {
756     }
757
758     @Override
759     public void startScan() {
760     }
761 }