2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.shelly.internal.api;
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.*;
20 import java.nio.charset.StandardCharsets;
21 import java.text.MessageFormat;
22 import java.util.Base64;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
28 import javax.ws.rs.core.HttpHeaders;
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;
48 import com.google.gson.Gson;
51 * {@link ShellyHttpClient} implements basic HTTP access
53 * @author Markus Michels - Initial contribution
56 public class ShellyHttpClient {
57 private final Logger logger = LoggerFactory.getLogger(ShellyHttpClient.class);
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";
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;
73 public ShellyHttpClient(String thingName, ShellyThingInterface thing) {
74 this(thingName, thing.getThingConfig(), thing.getHttpClient());
75 this.profile = thing.getProfile();
78 public ShellyHttpClient(String thingName, ShellyThingConfiguration config, HttpClient httpClient) {
79 profile = new ShellyDeviceProfile();
80 this.thingName = thingName;
81 setConfig(thingName, config);
82 this.httpClient = httpClient;
83 this.httpClient.setConnectTimeout(SHELLY_API_TIMEOUT_MS);
86 public void initialize() throws ShellyApiException {
89 public void setConfig(String thingName, ShellyThingConfiguration config) {
90 this.thingName = thingName;
95 * Submit GET request and return response, check for invalid responses
97 * @param uri: URI (e.g. "/settings")
99 public <T> T callApi(String uri, Class<T> classOfT) throws ShellyApiException {
100 String json = httpRequest(uri);
101 return fromJson(gson, json, classOfT);
104 public <T> T postApi(String uri, String data, Class<T> classOfT) throws ShellyApiException {
105 String json = httpPost(uri, data);
106 return fromJson(gson, json, classOfT);
109 protected String httpRequest(String uri) throws ShellyApiException {
110 ShellyApiResult apiResult = new ShellyApiResult();
112 boolean timeout = false;
113 while (retries > 0) {
115 apiResult = innerRequest(HttpMethod.GET, uri, null, "");
117 // If call doesn't throw an exception the device is reachable == no timeout
119 logger.debug("{}: API timeout #{}/{} recovered ({})", thingName, timeoutErrors, timeoutsRecovered,
123 return apiResult.response; // successful
124 } catch (ShellyApiException e) {
125 if (e.isConnectionError()
126 || (!e.isTimeout() && !apiResult.isHttpServerError()) && !apiResult.isNotFound()
127 || profile.hasBattery || (retries == 0)) {
128 // Sensor in sleep mode or API exception for non-battery device or retry counter expired
129 throw e; // non-timeout exception
133 timeoutErrors++; // count the retries
134 logger.debug("{}: API Timeout, retry #{} ({})", thingName, timeoutErrors, e.toString());
139 throw new ShellyApiException("API Timeout or inconsistent result"); // successful
142 public String httpPost(String uri, String data) throws ShellyApiException {
143 return innerRequest(HttpMethod.POST, uri, null, data).response;
146 public String httpPost(@Nullable Shelly2AuthChallenge auth, String data) throws ShellyApiException {
147 return innerRequest(HttpMethod.POST, SHELLYRPC_ENDPOINT, auth, data).response;
150 private ShellyApiResult innerRequest(HttpMethod method, String uri, @Nullable Shelly2AuthChallenge auth,
151 String data) throws ShellyApiException {
152 Request request = null;
153 String url = "http://" + config.deviceIp + uri;
154 ShellyApiResult apiResult = new ShellyApiResult(method.toString(), url);
157 request = httpClient.newRequest(url).method(method.toString()).timeout(SHELLY_API_TIMEOUT_MS,
158 TimeUnit.MILLISECONDS);
160 if (!uri.equals(SHELLY_URL_DEVINFO) && !config.password.isEmpty()) { // not for /shelly or no password
164 // Gen 2: Digest Auth
165 String authHeader = "";
166 if (auth != null) { // only if we received an Auth challenge
167 authHeader = formatAuthResponse(uri,
168 buildAuthResponse(uri, auth, SHELLY2_AUTHDEF_USER, config.password));
170 if (!uri.equals(SHELLYRPC_ENDPOINT)) {
171 String bearer = config.userId + ":" + config.password;
172 authHeader = HTTP_AUTH_TYPE_BASIC + " " + Base64.getEncoder().encodeToString(bearer.getBytes());
175 if (!authHeader.isEmpty()) {
176 request.header(HTTP_HEADER_AUTH, authHeader);
179 fillPostData(request, data);
180 logger.trace("{}: HTTP {} {}\n{}\n{}", thingName, method, url, request.getHeaders(), data);
182 // Do request and get response
183 ContentResponse contentResponse = request.send();
184 apiResult = new ShellyApiResult(contentResponse);
185 apiResult.httpCode = contentResponse.getStatus();
186 String response = contentResponse.getContentAsString().replace("\t", "").replace("\r\n", "").trim();
187 logger.trace("{}: HTTP Response {}: {}\n{}", thingName, contentResponse.getStatus(), response,
188 contentResponse.getHeaders());
190 if (response.contains("\"error\":{")) { // Gen2
191 Shelly2RpcBaseMessage message = gson.fromJson(response, Shelly2RpcBaseMessage.class);
192 if (message != null && message.error != null) {
193 apiResult.httpCode = message.error.code;
194 apiResult.response = message.error.message;
195 if (getInteger(message.error.code) == HttpStatus.UNAUTHORIZED_401) {
196 apiResult.authChallenge = getString(message.error.message).replaceAll("\\\"", "\"");
200 HttpFields headers = contentResponse.getHeaders();
201 String authChallenge = headers.get(HttpHeader.WWW_AUTHENTICATE);
202 if (!getString(authChallenge).isEmpty()) {
203 apiResult.authChallenge = authChallenge;
206 // validate response, API errors are reported as Json
207 if (apiResult.httpCode != HttpStatus.OK_200) {
208 throw new ShellyApiException(apiResult);
211 if (response.isEmpty() || !response.startsWith("{") && !response.startsWith("[") && !url.contains("/debug/")
212 && !url.contains("/sta_cache_reset")) {
213 throw new ShellyApiException("Unexpected response: " + response);
215 } catch (ExecutionException | InterruptedException | TimeoutException | IllegalArgumentException e) {
216 ShellyApiException ex = new ShellyApiException(apiResult, e);
217 if (!ex.isConnectionError() && !ex.isTimeout()) { // will be handled by the caller
218 logger.trace("{}: API call returned exception", thingName, ex);
225 protected @Nullable Shelly2AuthRsp buildAuthResponse(String uri, @Nullable Shelly2AuthChallenge challenge,
226 String user, String password) throws ShellyApiException {
227 if (challenge == null) {
228 return null; // not required
230 if (!SHELLY2_AUTHTTYPE_DIGEST.equalsIgnoreCase(challenge.authType)
231 || !SHELLY2_AUTHALG_SHA256.equalsIgnoreCase(challenge.algorithm)) {
232 throw new IllegalArgumentException("Unsupported Auth type/algorithm requested by device");
234 Shelly2AuthRsp response = new Shelly2AuthRsp();
235 response.username = user;
236 response.realm = challenge.realm;
237 response.nonce = challenge.nonce;
238 response.cnonce = Long.toHexString((long) Math.floor(Math.random() * 10e8));
239 response.nc = "00000001";
240 response.authType = challenge.authType;
241 response.algorithm = challenge.algorithm;
242 String ha1 = sha256(response.username + ":" + response.realm + ":" + password);
243 String ha2 = sha256(HttpMethod.POST + ":" + uri);// SHELLY2_AUTH_NOISE;
244 response.response = sha256(
245 ha1 + ":" + response.nonce + ":" + response.nc + ":" + response.cnonce + ":" + "auth" + ":" + ha2);
249 protected String formatAuthResponse(String uri, @Nullable Shelly2AuthRsp rsp) {
250 return rsp != null ? MessageFormat.format(HTTP_AUTH_TYPE_DIGEST
251 + " username=\"{0}\", realm=\"{1}\", uri=\"{2}\", nonce=\"{3}\", cnonce=\"{4}\", nc=\"{5}\", qop=\"auth\",response=\"{6}\", algorithm=\"{7}\", ",
252 rsp.username, rsp.realm, uri, rsp.nonce, rsp.cnonce, rsp.nc, rsp.response, rsp.algorithm) : "";
256 * Fill in POST data, set http headers
258 * @param request HTTP request structure
259 * @param data POST data, might be empty
261 private void fillPostData(Request request, String data) {
262 boolean json = data.startsWith("{") || data.contains("\": {");
263 String type = json ? CONTENT_TYPE_JSON : CONTENT_TYPE_FORM_URLENC;
264 request.header(HttpHeader.CONTENT_TYPE, type);
265 if (!data.isEmpty()) {
266 StringContentProvider postData;
267 postData = new StringContentProvider(type, data, StandardCharsets.UTF_8);
268 request.content(postData);
269 // request.header(HttpHeader.CONTENT_LENGTH, Long.toString(postData.getLength()));
274 * Format POST body depending on content type (JSON or form encoded)
276 * @param dataMap Field list
277 * @param json true=JSON format, false=form encoded
278 * @return formatted body
280 public static String buildPostData(Map<String, String> dataMap, boolean json) {
282 for (Map.Entry<String, String> e : dataMap.entrySet()) {
283 data = data + (data.isEmpty() ? "" : json ? ", " : "&");
285 data = data + e.getKey() + "=" + e.getValue();
287 data = data + "\"" + e.getKey() + "\" : \"" + e.getValue() + "\"";
290 return json ? "{ " + data + " }" : data;
293 public String getControlUriPrefix(Integer id) {
295 if (profile.isLight || profile.isDimmer) {
296 if (profile.isDuo || profile.isDimmer) {
298 uri = SHELLY_URL_CONTROL_LIGHT;
301 uri = "/" + (profile.inColor ? SHELLY_MODE_COLOR : SHELLY_MODE_WHITE);
305 uri = SHELLY_URL_CONTROL_RELEAY;
307 uri = uri + "/" + id;
311 public int getTimeoutErrors() {
312 return timeoutErrors;
315 public int getTimeoutsRecovered() {
316 return timeoutsRecovered;
319 public void postEvent(String device, String index, String event, Map<String, String> parms)
320 throws ShellyApiException {