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.TapoErrorConstants.*;
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.Gson;
40 import com.google.gson.JsonObject;
43 * Handler class for TAPO Smart Home device connections.
44 * This class uses synchronous HttpClient-Requests for login to device
46 * @author Christian Wild - Initial contribution
49 public class TapoDeviceHttpApi {
50 private final Logger logger = LoggerFactory.getLogger(TapoDeviceHttpApi.class);
51 private final String uid;
52 private final TapoCipher tapoCipher;
53 private final TapoBridgeHandler bridge;
55 private String token = "";
56 private String cookie = "";
57 protected String deviceURL = "";
58 protected String ipAddress = "";
63 * @param config TapoControlConfiguration class
65 public TapoDeviceHttpApi(TapoDevice device, TapoBridgeHandler bridgeThingHandler) {
66 this.bridge = bridgeThingHandler;
67 this.tapoCipher = new TapoCipher();
68 this.gson = new Gson();
69 this.uid = device.getThingUID().getAsString();
70 String ipAddress = device.getIpAddress();
71 setDeviceURL(ipAddress);
74 /***********************************
76 * DELEGATING FUNCTIONS
77 * will normaly be delegated to extension-classes(TapoDeviceConnector)
79 ************************************/
81 * handle SuccessResponse (setDeviceInfo)
83 * @param responseBody String with responseBody from device
85 protected void handleSuccessResponse(String responseBody) {
89 * handle JsonResponse (getDeviceInfo)
91 * @param responseBody String with responseBody from device
93 protected void handleDeviceResult(String responseBody) {
97 * handle JsonResponse (getEnergyData)
99 * @param responseBody String with responseBody from device
101 protected void handleEnergyResult(String responseBody) {
105 * handle custom response
107 * @param responseBody String with responseBody from device
109 protected void handleCustomResponse(String responseBody) {
115 * @param te TapoErrorHandler
117 protected void handleError(TapoErrorHandler tapoError) {
120 /***********************************
124 ************************************/
127 * Create Handshake and set cookie
129 * @return true if handshake (cookie) was created
131 protected String createHandshake() {
134 /* create payload for handshake */
135 PayloadBuilder plBuilder = new PayloadBuilder();
136 plBuilder.method = "handshake";
137 plBuilder.addParameter("key", bridge.getCredentials().getPublicKey()); // ?.decode("UTF-8")
138 String payload = plBuilder.getPayload();
140 /* send request (create ) */
141 logger.trace("({}) create handhsake with payload: {}", uid, payload.toString());
142 ContentResponse response = sendRequest(this.deviceURL, payload);
143 if (response != null && getErrorCode(response) == 0) {
144 String encryptedKey = getKeyFromResponse(response);
145 this.tapoCipher.setKey(encryptedKey, bridge.getCredentials());
146 cookie = getCookieFromResponse(response);
148 } catch (Exception e) {
149 logger.debug("({}) could not createHandshake: {}", uid, e.toString());
150 handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not createHandshake"));
156 * return encrypted key from 'handshake' request
158 * @param response ContentResponse from "handshake" method
161 private String getKeyFromResponse(ContentResponse response) {
162 String rBody = response.getContentAsString();
163 JsonObject jsonObj = gson.fromJson(rBody, JsonObject.class);
164 if (jsonObj != null) {
165 logger.trace("({}) received awnser: {}", uid, rBody);
166 return jsonObjectToString(jsonObj.getAsJsonObject("result"), "key");
168 logger.warn("({}) could not getKeyFromResponse '{}'", uid, rBody);
169 handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not getKeyFromResponse"));
175 * return cookie from 'handshake' request
177 * @param response ContentResponse from "handshake" metho
180 private String getCookieFromResponse(ContentResponse response) {
183 cookie = response.getHeaders().get("Set-Cookie").split(";")[0];
184 logger.trace("({}) got cookie: '{}'", uid, cookie);
185 } catch (Exception e) {
186 logger.warn("({}) could not getCookieFromResponse", uid);
187 handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not getCookieFromResponse"));
193 * Query Token from device
195 * @return String with token returned from device
197 protected String queryToken() {
200 /* encrypt login credentials */
201 PayloadBuilder plBuilder = new PayloadBuilder();
202 plBuilder.method = "login_device";
203 plBuilder.addParameter("username", bridge.getCredentials().getEncodedEmail());
204 plBuilder.addParameter("password", bridge.getCredentials().getEncodedPassword());
205 String payload = plBuilder.getPayload();
206 String encryptedPayload = this.encryptPayload(payload);
208 /* create secured login informations */
209 plBuilder = new PayloadBuilder();
210 plBuilder.method = "securePassthrough";
211 plBuilder.addParameter("request", encryptedPayload);
212 String securePassthroughPayload = plBuilder.getPayload();
214 /* sendRequest and get Token */
215 ContentResponse response = sendRequest(deviceURL, securePassthroughPayload);
216 token = getTokenFromResponse(response);
217 } catch (Exception e) {
218 logger.debug("({}) error building login payload: {}", uid, e.toString());
219 handleError(new TapoErrorHandler(e, "error building login payload"));
225 * get Token from "login"-request
230 private String getTokenFromResponse(@Nullable ContentResponse response) {
232 TapoErrorHandler tapoError = new TapoErrorHandler();
233 if (response != null && response.getStatus() == 200) {
234 String rBody = response.getContentAsString();
235 String decryptedResponse = this.decryptResponse(rBody);
236 logger.trace("({}) received result: {}", uid, decryptedResponse);
238 /* get errocode (0=success) */
239 JsonObject jsonObject = gson.fromJson(decryptedResponse, JsonObject.class);
240 if (jsonObject != null) {
241 Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_JSON_DECODE_FAIL);
242 if (errorCode == 0) {
243 /* return result if set / else request was successful */
244 result = jsonObjectToString(jsonObject.getAsJsonObject("result"), "token");
246 /* return errorcode from device */
247 tapoError.raiseError(errorCode, "could not get token");
248 logger.debug("({}) login recieved errorCode {} - {}", uid, errorCode, tapoError.getMessage());
251 logger.debug("({}) unexpected json-response '{}'", uid, decryptedResponse);
252 tapoError.raiseError(ERR_JSON_ENCODE_FAIL, "could not get token");
255 logger.debug("({}) invalid response while login", uid);
256 tapoError.raiseError(ERR_HTTP_RESPONSE, "invalid response while login");
259 if (tapoError.hasError()) {
260 handleError(tapoError);
265 /***********************************
269 ************************************/
271 * SEND SYNCHRON HTTP-REQUEST
273 * @param url url request is sent to
274 * @param payload payload (String) to send
275 * @return ContentResponse of request
278 protected ContentResponse sendRequest(String url, String payload) {
279 logger.trace("({}) sendRequest to '{}' with cookie '{}'", uid, url, this.cookie);
281 Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
284 httpRequest = setHeaders(httpRequest);
285 httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
287 /* add request body */
288 httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
291 ContentResponse httpResponse = httpRequest.send();
293 } catch (InterruptedException e) {
294 logger.debug("({}) sending request interrupted: {}", uid, e.toString());
295 handleError(new TapoErrorHandler(e));
296 } catch (TimeoutException e) {
297 logger.debug("({}) sending request timeout: {}", uid, e.toString());
298 handleError(new TapoErrorHandler(ERR_CONNECT_TIMEOUT, e.toString()));
299 } catch (Exception e) {
300 logger.debug("({}) sending request failed: {}", uid, e.toString());
301 handleError(new TapoErrorHandler(e));
307 * SEND ASYNCHRONOUS HTTP-REQUEST
308 * (don't wait for awnser with programm code)
310 * @param url string url request is sent to
311 * @param payload data-payload
312 * @param command command executed - this will handle RepsonseType
314 protected void sendAsyncRequest(String url, String payload, String command) {
315 logger.trace("({}) sendAsncRequest to '{}' with cookie '{}'", uid, url, this.cookie);
316 logger.trace("({}) command/payload: '{}''{}'", uid, command, payload);
319 Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
322 httpRequest = setHeaders(httpRequest);
324 /* add request body */
325 httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
327 httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
328 @NonNullByDefault({})
330 public void onComplete(Result result) {
331 final HttpResponse response = (HttpResponse) result.getResponse();
332 if (result.getFailure() != null) {
333 /* handle result errors */
334 Throwable e = result.getFailure();
335 String errorMessage = getValueOrDefault(e.getMessage(), "");
336 if (e instanceof TimeoutException) {
337 logger.debug("({}) sendAsyncRequest timeout'{}'", uid, errorMessage);
338 handleError(new TapoErrorHandler(ERR_CONNECT_TIMEOUT, errorMessage));
340 logger.debug("({}) sendAsyncRequest failed'{}'", uid, errorMessage);
341 handleError(new TapoErrorHandler(new Exception(e), errorMessage));
343 } else if (response.getStatus() != 200) {
344 logger.debug("({}) sendAsyncRequest response error'{}'", uid, response.getStatus());
345 handleError(new TapoErrorHandler(ERR_HTTP_RESPONSE, getContentAsString()));
347 /* request successful */
348 String rBody = getContentAsString();
349 logger.trace("({}) receivedRespose '{}'", uid, rBody);
350 if (!hasErrorCode(rBody)) {
351 rBody = decryptResponse(rBody);
352 logger.trace("({}) decryptedResponse '{}'", uid, rBody);
355 case DEVICE_CMD_SETINFO:
356 handleSuccessResponse(rBody);
358 case DEVICE_CMD_GETINFO:
359 handleDeviceResult(rBody);
361 case DEVICE_CMD_GETENERGY:
362 handleEnergyResult(rBody);
364 case DEVICE_CMD_CUSTOM:
365 handleCustomResponse(rBody);
374 } catch (Exception e) {
375 handleError(new TapoErrorHandler(e));
380 * return error code from response
383 * @return 0 if request was successfull
385 protected Integer getErrorCode(@Nullable ContentResponse response) {
387 if (response != null) {
388 String responseBody = response.getContentAsString();
389 return getErrorCode(responseBody);
391 return ERR_HTTP_RESPONSE;
393 } catch (Exception e) {
394 return ERR_HTTP_RESPONSE;
399 * return error code from responseBody
401 * @param responseBody
402 * @return 0 if request was successfull
404 protected Integer getErrorCode(String responseBody) {
406 JsonObject jsonObject = gson.fromJson(responseBody, JsonObject.class);
407 /* get errocode (0=success) */
408 Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_JSON_DECODE_FAIL);
409 if (errorCode == 0) {
412 logger.debug("({}) device returns errorcode '{}'", uid, errorCode);
413 handleError(new TapoErrorHandler(errorCode));
416 } catch (Exception e) {
417 return ERR_HTTP_RESPONSE;
422 * Check for JsonObject "errorcode" and if this is > 0 (no Error)
424 * @param responseBody
425 * @return true if is js errorcode > 0; false if there is no "errorcode"
427 protected Boolean hasErrorCode(String responseBody) {
428 if (isValidJson(responseBody)) {
429 JsonObject jsonObject = gson.fromJson(responseBody, JsonObject.class);
430 /* get errocode (0=success) */
431 Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_JSON_DECODE_FAIL);
442 private Request setHeaders(Request httpRequest) {
444 httpRequest.header("content-type", CONTENT_TYPE_JSON);
445 httpRequest.header("Accept", CONTENT_TYPE_JSON);
446 if (!this.cookie.isEmpty()) {
447 httpRequest.header(HTTP_AUTH_TYPE_COOKIE, this.cookie);
452 /***********************************
454 * ENCRYPTION / CODING
456 ************************************/
461 * @param responseBody encrypted string from response-body
462 * @return String decrypted responseBody
464 protected String decryptResponse(String responseBody) {
466 JsonObject jsonObject = gson.fromJson(responseBody, JsonObject.class);
467 if (jsonObject != null) {
468 String encryptedResponse = jsonObjectToString(jsonObject.getAsJsonObject("result"), "response");
469 return tapoCipher.decode(encryptedResponse);
471 handleError(new TapoErrorHandler(ERR_JSON_DECODE_FAIL));
473 } catch (Exception ex) {
474 logger.debug("({}) exception '{}' decryptingResponse: '{}'", uid, ex.toString(), responseBody);
483 * @return encrypted payload
485 protected String encryptPayload(String payload) {
487 return tapoCipher.encode(payload);
488 } catch (Exception ex) {
489 logger.debug("({}) exception encoding Payload '{}'", uid, ex.toString());
495 * perform logout (dispose cookie)
497 public void logout() {
498 logger.trace("DeviceHttpApi_logout");
503 /***********************************
507 ************************************/
511 * @return true if logged in
513 public Boolean loggedIn() {
514 return loggedIn(false);
520 * @param raiseError if true
521 * @return true if logged in
523 public Boolean loggedIn(Boolean raiseError) {
524 if (!this.token.isBlank() && !this.cookie.isBlank()) {
527 logger.trace("({}) not logged in", uid);
529 handleError(new TapoErrorHandler(ERR_LOGIN));
535 /***********************************
539 ************************************/
544 * @param new ipAdress
546 public void setDeviceURL(String ipAddress) {
547 this.ipAddress = ipAddress;
548 this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
552 * Set new ipAdresss with token
554 * @param ipAddress ipAddres of device
555 * @param token token from login-ressult
557 public void setDeviceURL(String ipAddress, String token) {
558 this.ipAddress = ipAddress;
559 this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
568 protected void setToken(String token) {
569 if (!token.isBlank()) {
570 String url = this.deviceURL.replaceAll("\\?token=\\w*", "");
571 this.deviceURL = url + "?token=" + token;
577 * Unset Token (device logout)
579 protected void unsetToken() {
580 this.deviceURL = this.deviceURL.replaceAll("\\?token=\\w*", "");
589 protected void setCookie(String cookie) {
590 this.cookie = cookie;
594 * Unset Cookie (device logout)
596 protected void unsetCookie() {
597 bridge.getHttpClient().getCookieStore().removeAll();