]> git.basschouten.com Git - openhab-addons.git/blob
41fb0181342ebfb4578f64dcfa18acb580042c75
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.SHELLY_API_TIMEOUT_MS;
16 import static org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.*;
17 import static org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.*;
18 import static org.openhab.binding.shelly.internal.util.ShellyUtils.*;
19
20 import java.nio.charset.StandardCharsets;
21 import java.text.MessageFormat;
22 import java.util.Base64;
23 import java.util.Map;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
27
28 import javax.ws.rs.core.HttpHeaders;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.eclipse.jetty.client.api.Request;
35 import org.eclipse.jetty.client.util.StringContentProvider;
36 import org.eclipse.jetty.http.HttpFields;
37 import org.eclipse.jetty.http.HttpHeader;
38 import org.eclipse.jetty.http.HttpMethod;
39 import org.eclipse.jetty.http.HttpStatus;
40 import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthChallenge;
41 import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthRsp;
42 import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2RpcBaseMessage;
43 import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
44 import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
47
48 import com.google.gson.Gson;
49
50 /**
51  * {@link ShellyHttpClient} implements basic HTTP access
52  *
53  * @author Markus Michels - Initial contribution
54  */
55 @NonNullByDefault
56 public class ShellyHttpClient {
57     private final Logger logger = LoggerFactory.getLogger(ShellyHttpClient.class);
58
59     public static final String HTTP_HEADER_AUTH = HttpHeaders.AUTHORIZATION;
60     public static final String HTTP_AUTH_TYPE_BASIC = "Basic";
61     public static final String HTTP_AUTH_TYPE_DIGEST = "Digest";
62     public static final String CONTENT_TYPE_JSON = "application/json; charset=UTF-8";
63     public static final String CONTENT_TYPE_FORM_URLENC = "application/x-www-form-urlencoded";
64
65     protected final HttpClient httpClient;
66     protected ShellyThingConfiguration config = new ShellyThingConfiguration();
67     protected String thingName;
68     protected final Gson gson = new Gson();
69     protected int timeoutErrors = 0;
70     protected int timeoutsRecovered = 0;
71     private ShellyDeviceProfile profile;
72     protected boolean basicAuth = false;
73
74     public ShellyHttpClient(String thingName, ShellyThingInterface thing) {
75         this(thingName, thing.getThingConfig(), thing.getHttpClient());
76         this.profile = thing.getProfile();
77     }
78
79     public ShellyHttpClient(String thingName, ShellyThingConfiguration config, HttpClient httpClient) {
80         profile = new ShellyDeviceProfile();
81         this.thingName = thingName;
82         setConfig(thingName, config);
83         this.httpClient = httpClient;
84         this.httpClient.setConnectTimeout(SHELLY_API_TIMEOUT_MS);
85     }
86
87     public void setConfig(String thingName, ShellyThingConfiguration config) {
88         this.thingName = thingName;
89         this.config = config;
90     }
91
92     /**
93      * Submit GET request and return response, check for invalid responses
94      *
95      * @param uri: URI (e.g. "/settings")
96      */
97     public <T> T callApi(String uri, Class<T> classOfT) throws ShellyApiException {
98         String json = httpRequest(uri);
99         return fromJson(gson, json, classOfT);
100     }
101
102     public <T> T postApi(String uri, String data, Class<T> classOfT) throws ShellyApiException {
103         String json = httpPost(uri, data);
104         return fromJson(gson, json, classOfT);
105     }
106
107     protected String httpRequest(String uri) throws ShellyApiException {
108         ShellyApiResult apiResult = new ShellyApiResult();
109         int retries = 3;
110         boolean timeout = false;
111         while (retries > 0) {
112             try {
113                 apiResult = innerRequest(HttpMethod.GET, uri, null, "");
114
115                 // If call doesn't throw an exception the device is reachable == no timeout
116                 if (timeout) {
117                     logger.debug("{}: API timeout #{}/{} recovered ({})", thingName, timeoutErrors, timeoutsRecovered,
118                             apiResult.getUrl());
119                     timeoutsRecovered++;
120                 }
121                 return apiResult.response; // successful
122             } catch (ShellyApiException e) {
123                 if (e.isHttpAccessUnauthorized() && !profile.isGen2 && !basicAuth && !config.password.isEmpty()) {
124                     logger.debug("{}: Access is unauthorized, auto-activate basic auth", thingName);
125                     basicAuth = true;
126                     apiResult = innerRequest(HttpMethod.GET, uri, null, "");
127                 }
128
129                 if (e.isConnectionError()
130                         || (!e.isTimeout() && !apiResult.isHttpServerError()) && !apiResult.isNotFound()
131                         || profile.hasBattery || (retries == 0)) {
132                     // Sensor in sleep mode or API exception for non-battery device or retry counter expired
133                     throw e; // non-timeout exception
134                 }
135
136                 timeout = true;
137                 timeoutErrors++; // count the retries
138                 retries--;
139                 if (profile.alwaysOn) {
140                     logger.debug("{}: API Timeout, retry #{} ({})", thingName, timeoutErrors, e.toString());
141                 }
142             }
143         }
144         throw new ShellyApiException("API Timeout or inconsistent result"); // successful
145     }
146
147     public String httpPost(String uri, String data) throws ShellyApiException {
148         return innerRequest(HttpMethod.POST, uri, null, data).response;
149     }
150
151     public String httpPost(@Nullable Shelly2AuthChallenge auth, String data) throws ShellyApiException {
152         return innerRequest(HttpMethod.POST, SHELLYRPC_ENDPOINT, auth, data).response;
153     }
154
155     private ShellyApiResult innerRequest(HttpMethod method, String uri, @Nullable Shelly2AuthChallenge auth,
156             String data) throws ShellyApiException {
157         Request request = null;
158         String url = "http://" + config.deviceIp + uri;
159         ShellyApiResult apiResult = new ShellyApiResult(method.toString(), url);
160
161         try {
162             request = httpClient.newRequest(url).method(method.toString()).timeout(SHELLY_API_TIMEOUT_MS,
163                     TimeUnit.MILLISECONDS);
164
165             if (!uri.equals(SHELLY_URL_DEVINFO) && !config.password.isEmpty()) { // not for /shelly or no password
166                                                                                  // configured
167                 // Add Auth info
168                 // Gen 1: Basic Auth
169                 // Gen 2: Digest Auth
170                 String authHeader = "";
171                 if (auth != null) { // only if we received an Auth challenge
172                     authHeader = formatAuthResponse(uri,
173                             buildAuthResponse(uri, auth, SHELLY2_AUTHDEF_USER, config.password));
174                 } else {
175                     if (basicAuth) {
176                         String bearer = config.userId + ":" + config.password;
177                         authHeader = HTTP_AUTH_TYPE_BASIC + " " + Base64.getEncoder().encodeToString(bearer.getBytes());
178                     }
179                 }
180                 if (!authHeader.isEmpty()) {
181                     request.header(HTTP_HEADER_AUTH, authHeader);
182                 }
183             }
184             fillPostData(request, data);
185             logger.trace("{}: HTTP {} {}\n{}\n{}", thingName, method, url, request.getHeaders(), data);
186
187             // Do request and get response
188             ContentResponse contentResponse = request.send();
189             apiResult = new ShellyApiResult(contentResponse);
190             apiResult.httpCode = contentResponse.getStatus();
191             String response = contentResponse.getContentAsString().replace("\t", "").replace("\r\n", "").trim();
192             logger.trace("{}: HTTP Response {}: {}\n{}", thingName, contentResponse.getStatus(), response,
193                     contentResponse.getHeaders());
194
195             if (response.contains("\"error\":{")) { // Gen2
196                 Shelly2RpcBaseMessage message = gson.fromJson(response, Shelly2RpcBaseMessage.class);
197                 if (message != null && message.error != null) {
198                     apiResult.httpCode = message.error.code;
199                     apiResult.response = message.error.message;
200                     if (getInteger(message.error.code) == HttpStatus.UNAUTHORIZED_401) {
201                         apiResult.authChallenge = getString(message.error.message).replaceAll("\\\"", "\"");
202                     }
203                 }
204             }
205             HttpFields headers = contentResponse.getHeaders();
206             String authChallenge = headers.get(HttpHeader.WWW_AUTHENTICATE);
207             if (!getString(authChallenge).isEmpty()) {
208                 apiResult.authChallenge = authChallenge;
209             }
210
211             // validate response, API errors are reported as Json
212             if (apiResult.httpCode != HttpStatus.OK_200) {
213                 throw new ShellyApiException(apiResult);
214             }
215
216             if (response.isEmpty() || !response.startsWith("{") && !response.startsWith("[") && !url.contains("/debug/")
217                     && !url.contains("/sta_cache_reset")) {
218                 throw new ShellyApiException("Unexpected response: " + response);
219             }
220         } catch (ExecutionException | InterruptedException | TimeoutException | IllegalArgumentException e) {
221             ShellyApiException ex = new ShellyApiException(apiResult, e);
222             if (!ex.isConnectionError() && !ex.isTimeout()) { // will be handled by the caller
223                 logger.trace("{}: API call returned exception", thingName, ex);
224             }
225             throw ex;
226         }
227         return apiResult;
228     }
229
230     protected @Nullable Shelly2AuthRsp buildAuthResponse(String uri, @Nullable Shelly2AuthChallenge challenge,
231             String user, String password) throws ShellyApiException {
232         if (challenge == null) {
233             return null; // not required
234         }
235         if (!SHELLY2_AUTHTTYPE_DIGEST.equalsIgnoreCase(challenge.authType)
236                 || !SHELLY2_AUTHALG_SHA256.equalsIgnoreCase(challenge.algorithm)) {
237             throw new IllegalArgumentException("Unsupported Auth type/algorithm requested by device");
238         }
239         Shelly2AuthRsp response = new Shelly2AuthRsp();
240         response.username = user;
241         response.realm = challenge.realm;
242         response.nonce = challenge.nonce;
243         response.cnonce = Long.toHexString((long) Math.floor(Math.random() * 10e8));
244         response.nc = "00000001";
245         response.authType = challenge.authType;
246         response.algorithm = challenge.algorithm;
247         String ha1 = sha256(response.username + ":" + response.realm + ":" + password);
248         String ha2 = sha256(HttpMethod.POST + ":" + uri);// SHELLY2_AUTH_NOISE;
249         response.response = sha256(
250                 ha1 + ":" + response.nonce + ":" + response.nc + ":" + response.cnonce + ":" + "auth" + ":" + ha2);
251         return response;
252     }
253
254     protected String formatAuthResponse(String uri, @Nullable Shelly2AuthRsp rsp) {
255         return rsp != null ? MessageFormat.format(HTTP_AUTH_TYPE_DIGEST
256                 + " username=\"{0}\", realm=\"{1}\", uri=\"{2}\", nonce=\"{3}\", cnonce=\"{4}\", nc=\"{5}\", qop=\"auth\",response=\"{6}\", algorithm=\"{7}\", ",
257                 rsp.username, rsp.realm, uri, rsp.nonce, rsp.cnonce, rsp.nc, rsp.response, rsp.algorithm) : "";
258     }
259
260     /**
261      * Fill in POST data, set http headers
262      *
263      * @param request HTTP request structure
264      * @param data POST data, might be empty
265      */
266     private void fillPostData(Request request, String data) {
267         boolean json = data.startsWith("{") || data.contains("\": {");
268         String type = json ? CONTENT_TYPE_JSON : CONTENT_TYPE_FORM_URLENC;
269         request.header(HttpHeader.CONTENT_TYPE, type);
270         if (!data.isEmpty()) {
271             StringContentProvider postData;
272             postData = new StringContentProvider(type, data, StandardCharsets.UTF_8);
273             request.content(postData);
274             // request.header(HttpHeader.CONTENT_LENGTH, Long.toString(postData.getLength()));
275         }
276     }
277
278     /**
279      * Format POST body depending on content type (JSON or form encoded)
280      *
281      * @param dataMap Field list
282      * @param json true=JSON format, false=form encoded
283      * @return formatted body
284      */
285     public static String buildPostData(Map<String, String> dataMap, boolean json) {
286         String data = "";
287         for (Map.Entry<String, String> e : dataMap.entrySet()) {
288             data = data + (data.isEmpty() ? "" : json ? ", " : "&");
289             if (!json) {
290                 data = data + e.getKey() + "=" + e.getValue();
291             } else {
292                 data = data + "\"" + e.getKey() + "\" : \"" + e.getValue() + "\"";
293             }
294         }
295         return json ? "{ " + data + " }" : data;
296     }
297
298     public String getControlUriPrefix(Integer id) {
299         String uri = "";
300         if (profile.isLight || profile.isDimmer) {
301             if (profile.isDuo || profile.isDimmer) {
302                 // Duo + Dimmer
303                 uri = SHELLY_URL_CONTROL_LIGHT;
304             } else {
305                 // Bulb + RGBW2
306                 uri = "/" + (profile.inColor ? SHELLY_MODE_COLOR : SHELLY_MODE_WHITE);
307             }
308         } else {
309             // Roller, Relay
310             uri = SHELLY_URL_CONTROL_RELEAY;
311         }
312         uri = uri + "/" + id;
313         return uri;
314     }
315
316     public int getTimeoutErrors() {
317         return timeoutErrors;
318     }
319
320     public int getTimeoutsRecovered() {
321         return timeoutsRecovered;
322     }
323
324     public void postEvent(String device, String index, String event, Map<String, String> parms)
325             throws ShellyApiException {
326     }
327 }