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.tapocontrol.internal.api;
15 import static org.openhab.binding.tapocontrol.internal.constants.TapoBindingSettings.*;
16 import static org.openhab.binding.tapocontrol.internal.constants.TapoErrorCode.*;
17 import static org.openhab.binding.tapocontrol.internal.helpers.TapoUtils.*;
19 import java.util.concurrent.TimeUnit;
20 import java.util.concurrent.TimeoutException;
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.eclipse.jetty.client.HttpResponse;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.eclipse.jetty.client.api.Result;
28 import org.eclipse.jetty.client.util.BufferingResponseListener;
29 import org.eclipse.jetty.client.util.StringContentProvider;
30 import org.eclipse.jetty.http.HttpMethod;
31 import org.openhab.binding.tapocontrol.internal.device.TapoBridgeHandler;
32 import org.openhab.binding.tapocontrol.internal.device.TapoDevice;
33 import org.openhab.binding.tapocontrol.internal.helpers.PayloadBuilder;
34 import org.openhab.binding.tapocontrol.internal.helpers.TapoCipher;
35 import org.openhab.binding.tapocontrol.internal.helpers.TapoErrorHandler;
36 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory;
39 import com.google.gson.FieldNamingPolicy;
40 import com.google.gson.Gson;
41 import com.google.gson.GsonBuilder;
42 import com.google.gson.JsonObject;
45 * Handler class for TAPO Smart Home device connections.
46 * This class uses synchronous HttpClient-Requests for login to device
48 * @author Christian Wild - Initial contribution
51 public class TapoDeviceHttpApi {
52 protected static final Gson GSON = new GsonBuilder()
53 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
55 private final Logger logger = LoggerFactory.getLogger(TapoDeviceHttpApi.class);
56 private final TapoCipher tapoCipher;
57 private final TapoBridgeHandler bridge;
58 protected final String uid;
59 protected final TapoDevice device;
61 private String token = "";
62 private String cookie = "";
63 protected String deviceURL = "";
64 protected String ipAddress = "";
69 * @param config TapoControlConfiguration class
71 public TapoDeviceHttpApi(TapoDevice device, TapoBridgeHandler bridgeThingHandler) {
72 this.bridge = bridgeThingHandler;
73 this.tapoCipher = new TapoCipher();
75 this.uid = device.getThingUID().getAsString();
76 setDeviceURL(device.getIpAddress());
79 /***********************************
81 * DELEGATING FUNCTIONS
82 * will normaly be delegated to extension-classes(TapoDeviceConnector)
84 ************************************/
86 * handle SuccessResponse (setDeviceInfo)
88 * @param responseBody String with responseBody from device
90 protected void handleSuccessResponse(String responseBody) {
94 * handle JsonResponse (getDeviceInfo)
96 * @param responseBody String with responseBody from device
98 protected void handleDeviceResult(String responseBody) {
102 * handle JsonResponse (getEnergyData)
104 * @param responseBody String with responseBody from device
106 protected void handleEnergyResult(String responseBody) {
110 * handle custom response
112 * @param responseBody String with responseBody from device
114 protected void handleCustomResponse(String responseBody) {
118 * handle JsonResponse (getChildDevices)
120 * @param responseBody String with responseBody from device
122 protected void handleChildDevices(String responseBody) {
128 * @param te TapoErrorHandler
130 protected void handleError(TapoErrorHandler tapoError) {
134 * refresh the list of child devices
137 protected void queryChildDevices() {
140 /***********************************
144 ************************************/
147 * Create Handshake and set cookie
149 * @return true if handshake (cookie) was created
151 protected String createHandshake() {
154 /* create payload for handshake */
155 PayloadBuilder plBuilder = new PayloadBuilder();
156 plBuilder.method = "handshake";
157 plBuilder.addParameter("key", bridge.getCredentials().getPublicKey()); // ?.decode("UTF-8")
158 String payload = plBuilder.getPayload();
160 /* send request (create ) */
161 logger.trace("({}) create handhsake with payload: {}", uid, payload.toString());
162 ContentResponse response = sendRequest(this.deviceURL, payload);
163 if (response != null && getErrorCode(response) == 0) {
164 String encryptedKey = getKeyFromResponse(response);
165 this.tapoCipher.setKey(encryptedKey, bridge.getCredentials());
166 cookie = getCookieFromResponse(response);
168 } catch (Exception e) {
169 logger.debug("({}) could not createHandshake: {}", uid, e.toString());
170 handleError(new TapoErrorHandler(ERR_API_HAND_SHAKE_FAILED, "could not createHandshake"));
176 * return encrypted key from 'handshake' request
178 * @param response ContentResponse from "handshake" method
181 private String getKeyFromResponse(ContentResponse response) {
182 String rBody = response.getContentAsString();
183 JsonObject jsonObj = GSON.fromJson(rBody, JsonObject.class);
184 if (jsonObj != null) {
185 logger.trace("({}) received awnser: {}", uid, rBody);
186 return jsonObjectToString(jsonObj.getAsJsonObject("result"), "key");
188 logger.warn("({}) could not getKeyFromResponse '{}'", uid, rBody);
189 handleError(new TapoErrorHandler(ERR_API_HAND_SHAKE_FAILED, "could not getKeyFromResponse"));
195 * return cookie from 'handshake' request
197 * @param response ContentResponse from "handshake" metho
200 private String getCookieFromResponse(ContentResponse response) {
203 cookie = response.getHeaders().get("Set-Cookie").split(";")[0];
204 logger.trace("({}) got cookie: '{}'", uid, cookie);
205 } catch (Exception e) {
206 logger.warn("({}) could not getCookieFromResponse", uid);
207 handleError(new TapoErrorHandler(ERR_API_HAND_SHAKE_FAILED, "could not getCookieFromResponse"));
213 * Query Token from device
215 * @return String with token returned from device
217 protected String queryToken() {
220 /* encrypt login credentials */
221 PayloadBuilder plBuilder = new PayloadBuilder();
222 plBuilder.method = "login_device";
223 plBuilder.addParameter("username", bridge.getCredentials().getEncodedEmail());
224 plBuilder.addParameter("password", bridge.getCredentials().getEncodedPassword());
225 String payload = plBuilder.getPayload();
226 String encryptedPayload = this.encryptPayload(payload);
228 /* create secured login informations */
229 plBuilder = new PayloadBuilder();
230 plBuilder.method = "securePassthrough";
231 plBuilder.addParameter("request", encryptedPayload);
232 String securePassthroughPayload = plBuilder.getPayload();
234 /* sendRequest and get Token */
235 ContentResponse response = sendRequest(deviceURL, securePassthroughPayload);
236 token = getTokenFromResponse(response);
237 } catch (Exception e) {
238 logger.debug("({}) error building login payload: {}", uid, e.toString());
239 handleError(new TapoErrorHandler(e, "error building login payload"));
245 * get Token from "login"-request
250 private String getTokenFromResponse(@Nullable ContentResponse response) {
252 TapoErrorHandler tapoError = new TapoErrorHandler();
253 if (response != null && response.getStatus() == 200) {
254 String rBody = response.getContentAsString();
255 String decryptedResponse = this.decryptResponse(rBody);
256 logger.trace("({}) received result: {}", uid, decryptedResponse);
258 /* get errocode (0=success) */
259 JsonObject jsonObject = GSON.fromJson(decryptedResponse, JsonObject.class);
260 if (jsonObject != null) {
261 Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_API_JSON_DECODE_FAIL.getCode());
262 if (errorCode == 0) {
263 /* return result if set / else request was successful */
264 result = jsonObjectToString(jsonObject.getAsJsonObject("result"), "token");
266 /* return errorcode from device */
267 tapoError.raiseError(errorCode, "could not get token");
268 logger.debug("({}) login recieved errorCode {} - {}", uid, errorCode, tapoError.getMessage());
271 logger.debug("({}) unexpected json-response '{}'", uid, decryptedResponse);
272 tapoError.raiseError(ERR_API_JSON_ENCODE_FAIL, "could not get token");
275 logger.debug("({}) invalid response while login", uid);
276 tapoError.raiseError(ERR_BINDING_HTTP_RESPONSE, "invalid response while login");
279 if (tapoError.hasError()) {
280 handleError(tapoError);
285 /***********************************
289 ************************************/
291 * SEND SYNCHRON HTTP-REQUEST
293 * @param url url request is sent to
294 * @param payload payload (String) to send
295 * @return ContentResponse of request
298 protected ContentResponse sendRequest(String url, String payload) {
299 logger.trace("({}) sendRequest to '{}' with cookie '{}'", uid, url, this.cookie);
301 Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
304 httpRequest = setHeaders(httpRequest);
305 httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
307 /* add request body */
308 httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
311 ContentResponse httpResponse = httpRequest.send();
313 } catch (InterruptedException e) {
314 logger.debug("({}) sending request interrupted: {}", uid, e.toString());
315 handleError(new TapoErrorHandler(e));
316 } catch (TimeoutException e) {
317 logger.debug("({}) sending request timeout: {}", uid, e.toString());
318 handleError(new TapoErrorHandler(ERR_BINDING_CONNECT_TIMEOUT, e.toString()));
319 } catch (Exception e) {
320 logger.debug("({}) sending request failed: {}", uid, e.toString());
321 handleError(new TapoErrorHandler(e));
327 * SEND ASYNCHRONOUS HTTP-REQUEST
328 * (don't wait for awnser with programm code)
330 * @param url string url request is sent to
331 * @param payload data-payload
332 * @param command command executed - this will handle RepsonseType
334 protected void sendAsyncRequest(String url, String payload, String command) {
335 logger.trace("({}) sendAsncRequest to '{}' with cookie '{}'", uid, url, this.cookie);
336 logger.trace("({}) command/payload: '{}''{}'", uid, command, payload);
339 Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
342 httpRequest = setHeaders(httpRequest);
344 /* add request body */
345 httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
347 httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
348 @NonNullByDefault({})
350 public void onComplete(Result result) {
351 final HttpResponse response = (HttpResponse) result.getResponse();
352 if (result.getFailure() != null) {
353 /* handle result errors */
354 Throwable e = result.getFailure();
355 String errorMessage = getValueOrDefault(e.getMessage(), "");
356 if (e instanceof TimeoutException) {
357 logger.debug("({}) sendAsyncRequest timeout'{}'", uid, errorMessage);
358 handleError(new TapoErrorHandler(ERR_BINDING_CONNECT_TIMEOUT, errorMessage));
360 logger.debug("({}) sendAsyncRequest failed'{}'", uid, errorMessage);
361 handleError(new TapoErrorHandler(new Exception(e), errorMessage));
363 } else if (response.getStatus() != 200) {
364 logger.debug("({}) sendAsyncRequest response error'{}'", uid, response.getStatus());
365 handleError(new TapoErrorHandler(ERR_BINDING_HTTP_RESPONSE, getContentAsString()));
367 /* request successful */
368 String rBody = getContentAsString();
369 logger.trace("({}) receivedRespose '{}'", uid, rBody);
370 if (!hasErrorCode(rBody)) {
371 rBody = decryptResponse(rBody);
372 logger.trace("({}) decryptedResponse '{}'", uid, rBody);
375 case DEVICE_CMD_SETINFO:
376 handleSuccessResponse(rBody);
378 case DEVICE_CMD_GETINFO:
379 handleDeviceResult(rBody);
381 case DEVICE_CMD_GETENERGY:
382 handleEnergyResult(rBody);
384 case DEVICE_CMD_CUSTOM:
385 handleCustomResponse(rBody);
387 case DEVICE_CMD_CHILD_DEVICE_LIST:
388 handleChildDevices(rBody);
397 } catch (Exception e) {
398 handleError(new TapoErrorHandler(e));
403 * return error code from response
406 * @return 0 if request was successfull
408 protected Integer getErrorCode(@Nullable ContentResponse response) {
410 if (response != null) {
411 String responseBody = response.getContentAsString();
412 return getErrorCode(responseBody);
414 return ERR_BINDING_HTTP_RESPONSE.getCode();
416 } catch (Exception e) {
417 return ERR_BINDING_HTTP_RESPONSE.getCode();
422 * return error code from responseBody
424 * @param responseBody
425 * @return 0 if request was successfull
427 protected Integer getErrorCode(String responseBody) {
429 JsonObject jsonObject = GSON.fromJson(responseBody, JsonObject.class);
430 /* get errocode (0=success) */
431 Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_API_JSON_DECODE_FAIL.getCode());
432 if (errorCode == 0) {
435 logger.debug("({}) device returns errorcode '{}'", uid, errorCode);
436 handleError(new TapoErrorHandler(errorCode));
439 } catch (Exception e) {
440 return ERR_BINDING_HTTP_RESPONSE.getCode();
445 * Check for JsonObject "errorcode" and if this is > 0 (no Error)
447 * @param responseBody
448 * @return true if is js errorcode > 0; false if there is no "errorcode"
450 protected Boolean hasErrorCode(String responseBody) {
451 if (isValidJson(responseBody)) {
452 JsonObject jsonObject = GSON.fromJson(responseBody, JsonObject.class);
453 /* get errocode (0=success) */
454 Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_API_JSON_DECODE_FAIL.getCode());
465 private Request setHeaders(Request httpRequest) {
467 httpRequest.header("content-type", CONTENT_TYPE_JSON);
468 httpRequest.header("Accept", CONTENT_TYPE_JSON);
469 if (!this.cookie.isEmpty()) {
470 httpRequest.header(HTTP_AUTH_TYPE_COOKIE, this.cookie);
475 /***********************************
477 * ENCRYPTION / CODING
479 ************************************/
484 * @param responseBody encrypted string from response-body
485 * @return String decrypted responseBody
487 protected String decryptResponse(String responseBody) {
489 JsonObject jsonObject = GSON.fromJson(responseBody, JsonObject.class);
490 if (jsonObject != null) {
491 String encryptedResponse = jsonObjectToString(jsonObject.getAsJsonObject("result"), "response");
492 return tapoCipher.decode(encryptedResponse);
494 handleError(new TapoErrorHandler(ERR_API_JSON_DECODE_FAIL));
496 } catch (Exception ex) {
497 logger.debug("({}) exception '{}' decryptingResponse: '{}'", uid, ex.toString(), responseBody);
506 * @return encrypted payload
508 protected String encryptPayload(String payload) {
510 return tapoCipher.encode(payload);
511 } catch (Exception ex) {
512 logger.debug("({}) exception encoding Payload '{}'", uid, ex.toString());
518 * perform logout (dispose cookie)
520 public void logout() {
521 logger.trace("DeviceHttpApi_logout");
526 /***********************************
530 ************************************/
534 * @return true if logged in
536 public Boolean loggedIn() {
537 return loggedIn(false);
543 * @param raiseError if true
544 * @return true if logged in
546 public Boolean loggedIn(Boolean raiseError) {
547 if (!this.token.isBlank() && !this.cookie.isBlank()) {
550 logger.trace("({}) not logged in", uid);
552 handleError(new TapoErrorHandler(ERR_API_LOGIN));
558 /***********************************
562 ************************************/
567 * @param new ipAdress
569 public void setDeviceURL(String ipAddress) {
570 this.ipAddress = ipAddress;
571 this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
575 * Set new ipAdresss with token
577 * @param ipAddress ipAddres of device
578 * @param token token from login-ressult
580 public void setDeviceURL(String ipAddress, String token) {
581 this.ipAddress = ipAddress;
582 this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
591 protected void setToken(String token) {
592 if (!token.isBlank()) {
593 String url = this.deviceURL.replaceAll("\\?token=\\w*", "");
594 this.deviceURL = url + "?token=" + token;
600 * Unset Token (device logout)
602 protected void unsetToken() {
603 this.deviceURL = this.deviceURL.replaceAll("\\?token=\\w*", "");
612 protected void setCookie(String cookie) {
613 this.cookie = cookie;
617 * Unset Cookie (device logout)
619 protected void unsetCookie() {
620 bridge.getHttpClient().getCookieStore().removeAll();