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