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