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