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