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