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