]> git.basschouten.com Git - openhab-addons.git/blob
e1c06b3af797ba88a8b5e00b51f0373eb59b515a
[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.tapocontrol.internal.api;
14
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.*;
18
19 import java.util.concurrent.TimeUnit;
20 import java.util.concurrent.TimeoutException;
21
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;
38
39 import com.google.gson.FieldNamingPolicy;
40 import com.google.gson.Gson;
41 import com.google.gson.GsonBuilder;
42 import com.google.gson.JsonObject;
43
44 /**
45  * Handler class for TAPO Smart Home device connections.
46  * This class uses synchronous HttpClient-Requests for login to device
47  *
48  * @author Christian Wild - Initial contribution
49  */
50 @NonNullByDefault
51 public class TapoDeviceHttpApi {
52     protected static final Gson GSON = new GsonBuilder()
53             .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
54
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;
60
61     private String token = "";
62     private String cookie = "";
63     protected String deviceURL = "";
64     protected String ipAddress = "";
65
66     /**
67      * INIT CLASS
68      *
69      * @param config TapoControlConfiguration class
70      */
71     public TapoDeviceHttpApi(TapoDevice device, TapoBridgeHandler bridgeThingHandler) {
72         this.bridge = bridgeThingHandler;
73         this.tapoCipher = new TapoCipher();
74         this.device = device;
75         this.uid = device.getThingUID().getAsString();
76         setDeviceURL(device.getIpAddress());
77     }
78
79     /***********************************
80      *
81      * DELEGATING FUNCTIONS
82      * will normaly be delegated to extension-classes(TapoDeviceConnector)
83      *
84      ************************************/
85     /**
86      * handle SuccessResponse (setDeviceInfo)
87      *
88      * @param responseBody String with responseBody from device
89      */
90     protected void handleSuccessResponse(String responseBody) {
91     }
92
93     /**
94      * handle JsonResponse (getDeviceInfo)
95      *
96      * @param responseBody String with responseBody from device
97      */
98     protected void handleDeviceResult(String responseBody) {
99     }
100
101     /**
102      * handle JsonResponse (getEnergyData)
103      *
104      * @param responseBody String with responseBody from device
105      */
106     protected void handleEnergyResult(String responseBody) {
107     }
108
109     /**
110      * handle custom response
111      *
112      * @param responseBody String with responseBody from device
113      */
114     protected void handleCustomResponse(String responseBody) {
115     }
116
117     /**
118      * handle JsonResponse (getChildDevices)
119      *
120      * @param responseBody String with responseBody from device
121      */
122     protected void handleChildDevices(String responseBody) {
123     }
124
125     /**
126      * handle error
127      *
128      * @param te TapoErrorHandler
129      */
130     protected void handleError(TapoErrorHandler tapoError) {
131     }
132
133     /**
134      * refresh the list of child devices
135      *
136      */
137     protected void queryChildDevices() {
138     }
139
140     /***********************************
141      *
142      * LOGIN FUNCTIONS
143      *
144      ************************************/
145
146     /**
147      * Create Handshake and set cookie
148      *
149      * @return true if handshake (cookie) was created
150      */
151     protected String createHandshake() {
152         String cookie = "";
153         try {
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();
159
160             /* send request (create ) */
161             logger.trace("({}) create handhsake with payload: {}", uid, payload);
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);
167             }
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"));
171         }
172         return cookie;
173     }
174
175     /**
176      * return encrypted key from 'handshake' request
177      *
178      * @param response ContentResponse from "handshake" method
179      * @return
180      */
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");
187         } else {
188             logger.warn("({}) could not getKeyFromResponse '{}'", uid, rBody);
189             handleError(new TapoErrorHandler(ERR_API_HAND_SHAKE_FAILED, "could not getKeyFromResponse"));
190         }
191         return "";
192     }
193
194     /**
195      * return cookie from 'handshake' request
196      *
197      * @param response ContentResponse from "handshake" metho
198      * @return
199      */
200     private String getCookieFromResponse(ContentResponse response) {
201         String cookie = "";
202         try {
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"));
208         }
209         return cookie;
210     }
211
212     /**
213      * Query Token from device
214      *
215      * @return String with token returned from device
216      */
217     protected String queryToken() {
218         String token = "";
219         try {
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);
227
228             /* create secured login informations */
229             plBuilder = new PayloadBuilder();
230             plBuilder.method = "securePassthrough";
231             plBuilder.addParameter("request", encryptedPayload);
232             String securePassthroughPayload = plBuilder.getPayload();
233
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"));
240         }
241         return token;
242     }
243
244     /**
245      * get Token from "login"-request
246      *
247      * @param response
248      * @return
249      */
250     private String getTokenFromResponse(@Nullable ContentResponse response) {
251         String result = "";
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);
257
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");
265                 } else {
266                     /* return errorcode from device */
267                     tapoError.raiseError(errorCode, "could not get token");
268                     logger.debug("({}) login recieved errorCode {} - {}", uid, errorCode, tapoError.getMessage());
269                 }
270             } else {
271                 logger.debug("({}) unexpected json-response '{}'", uid, decryptedResponse);
272                 tapoError.raiseError(ERR_API_JSON_ENCODE_FAIL, "could not get token");
273             }
274         } else {
275             logger.debug("({}) invalid response while login", uid);
276             tapoError.raiseError(ERR_BINDING_HTTP_RESPONSE, "invalid response while login");
277         }
278         /* handle error */
279         if (tapoError.hasError()) {
280             handleError(tapoError);
281         }
282         return result;
283     }
284
285     /***********************************
286      *
287      * HTTP-ACTIONS
288      *
289      ************************************/
290     /**
291      * SEND SYNCHRON HTTP-REQUEST
292      *
293      * @param url url request is sent to
294      * @param payload payload (String) to send
295      * @return ContentResponse of request
296      */
297     @Nullable
298     protected ContentResponse sendRequest(String url, String payload) {
299         logger.trace("({}) sendRequest to '{}' with cookie '{}'", uid, url, this.cookie);
300
301         Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
302
303         /* set header */
304         httpRequest = setHeaders(httpRequest);
305         httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
306
307         /* add request body */
308         httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
309
310         try {
311             return httpRequest.send();
312         } catch (InterruptedException e) {
313             logger.debug("({}) sending request interrupted: {}", uid, e.toString());
314             handleError(new TapoErrorHandler(e));
315         } catch (TimeoutException e) {
316             logger.debug("({}) sending request timeout: {}", uid, e.toString());
317             handleError(new TapoErrorHandler(ERR_BINDING_CONNECT_TIMEOUT, e.toString()));
318         } catch (Exception e) {
319             logger.debug("({}) sending request failed: {}", uid, e.toString());
320             handleError(new TapoErrorHandler(e));
321         }
322         return null;
323     }
324
325     /**
326      * SEND ASYNCHRONOUS HTTP-REQUEST
327      * (don't wait for awnser with programm code)
328      *
329      * @param url string url request is sent to
330      * @param payload data-payload
331      * @param command command executed - this will handle RepsonseType
332      */
333     protected void sendAsyncRequest(String url, String payload, String command) {
334         logger.trace("({}) sendAsncRequest to '{}' with cookie '{}'", uid, url, this.cookie);
335         logger.trace("({}) command/payload: '{}''{}'", uid, command, payload);
336
337         try {
338             Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
339
340             /* set header */
341             httpRequest = setHeaders(httpRequest);
342
343             /* add request body */
344             httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
345
346             httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
347                 @NonNullByDefault({})
348                 @Override
349                 public void onComplete(Result result) {
350                     final HttpResponse response = (HttpResponse) result.getResponse();
351                     if (result.getFailure() != null) {
352                         /* handle result errors */
353                         Throwable e = result.getFailure();
354                         String errorMessage = getValueOrDefault(e.getMessage(), "");
355                         if (e instanceof TimeoutException) {
356                             logger.debug("({}) sendAsyncRequest timeout'{}'", uid, errorMessage);
357                             handleError(new TapoErrorHandler(ERR_BINDING_CONNECT_TIMEOUT, errorMessage));
358                         } else {
359                             logger.debug("({}) sendAsyncRequest failed'{}'", uid, errorMessage);
360                             handleError(new TapoErrorHandler(new Exception(e), errorMessage));
361                         }
362                     } else if (response.getStatus() != 200) {
363                         logger.debug("({}) sendAsyncRequest response error'{}'", uid, response.getStatus());
364                         handleError(new TapoErrorHandler(ERR_BINDING_HTTP_RESPONSE, getContentAsString()));
365                     } else {
366                         /* request successful */
367                         String rBody = getContentAsString();
368                         logger.trace("({}) receivedRespose '{}'", uid, rBody);
369                         if (!hasErrorCode(rBody)) {
370                             rBody = decryptResponse(rBody);
371                             logger.trace("({}) decryptedResponse '{}'", uid, rBody);
372                             /* handle result */
373                             switch (command) {
374                                 case DEVICE_CMD_SETINFO:
375                                     handleSuccessResponse(rBody);
376                                     break;
377                                 case DEVICE_CMD_GETINFO:
378                                     handleDeviceResult(rBody);
379                                     break;
380                                 case DEVICE_CMD_GETENERGY:
381                                     handleEnergyResult(rBody);
382                                     break;
383                                 case DEVICE_CMD_CUSTOM:
384                                     handleCustomResponse(rBody);
385                                     break;
386                                 case DEVICE_CMD_CHILD_DEVICE_LIST:
387                                     handleChildDevices(rBody);
388                                     break;
389                             }
390                         } else {
391                             getErrorCode(rBody);
392                         }
393                     }
394                 }
395             });
396         } catch (Exception e) {
397             handleError(new TapoErrorHandler(e));
398         }
399     }
400
401     /**
402      * return error code from response
403      *
404      * @param response
405      * @return 0 if request was successfull
406      */
407     protected Integer getErrorCode(@Nullable ContentResponse response) {
408         try {
409             if (response != null) {
410                 String responseBody = response.getContentAsString();
411                 return getErrorCode(responseBody);
412             } else {
413                 return ERR_BINDING_HTTP_RESPONSE.getCode();
414             }
415         } catch (Exception e) {
416             return ERR_BINDING_HTTP_RESPONSE.getCode();
417         }
418     }
419
420     /**
421      * return error code from responseBody
422      *
423      * @param responseBody
424      * @return 0 if request was successfull
425      */
426     protected Integer getErrorCode(String responseBody) {
427         try {
428             JsonObject jsonObject = GSON.fromJson(responseBody, JsonObject.class);
429             /* get errocode (0=success) */
430             Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_API_JSON_DECODE_FAIL.getCode());
431             if (errorCode == 0) {
432                 return 0;
433             } else {
434                 logger.debug("({}) device returns errorcode '{}'", uid, errorCode);
435                 handleError(new TapoErrorHandler(errorCode));
436                 return errorCode;
437             }
438         } catch (Exception e) {
439             return ERR_BINDING_HTTP_RESPONSE.getCode();
440         }
441     }
442
443     /**
444      * Check for JsonObject "errorcode" and if this is > 0 (no Error)
445      *
446      * @param responseBody
447      * @return true if is js errorcode > 0; false if there is no "errorcode"
448      */
449     protected Boolean hasErrorCode(String responseBody) {
450         if (isValidJson(responseBody)) {
451             JsonObject jsonObject = GSON.fromJson(responseBody, JsonObject.class);
452             /* get errocode (0=success) */
453             Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_API_JSON_DECODE_FAIL.getCode());
454             if (errorCode > 0) {
455                 return true;
456             }
457         }
458         return false;
459     }
460
461     /**
462      * SET HTTP-HEADERS
463      */
464     private Request setHeaders(Request httpRequest) {
465         /* set header */
466         httpRequest.header("content-type", CONTENT_TYPE_JSON);
467         httpRequest.header("Accept", CONTENT_TYPE_JSON);
468         if (!this.cookie.isEmpty()) {
469             httpRequest.header(HTTP_AUTH_TYPE_COOKIE, this.cookie);
470         }
471         return httpRequest;
472     }
473
474     /***********************************
475      *
476      * ENCRYPTION / CODING
477      *
478      ************************************/
479
480     /**
481      * Decrypt Response
482      *
483      * @param responseBody encrypted string from response-body
484      * @return String decrypted responseBody
485      */
486     protected String decryptResponse(String responseBody) {
487         try {
488             JsonObject jsonObject = GSON.fromJson(responseBody, JsonObject.class);
489             if (jsonObject != null) {
490                 String encryptedResponse = jsonObjectToString(jsonObject.getAsJsonObject("result"), "response");
491                 return tapoCipher.decode(encryptedResponse);
492             } else {
493                 handleError(new TapoErrorHandler(ERR_API_JSON_DECODE_FAIL));
494             }
495         } catch (Exception ex) {
496             logger.debug("({}) exception '{}' decryptingResponse: '{}'", uid, ex.toString(), responseBody);
497         }
498         return responseBody;
499     }
500
501     /**
502      * encrypt payload
503      *
504      * @param payload
505      * @return encrypted payload
506      */
507     protected String encryptPayload(String payload) {
508         try {
509             return tapoCipher.encode(payload);
510         } catch (Exception ex) {
511             logger.debug("({}) exception encoding Payload '{}'", uid, ex.toString());
512             return "";
513         }
514     }
515
516     /**
517      * perform logout (dispose cookie)
518      */
519     public void logout() {
520         logger.trace("DeviceHttpApi_logout");
521         unsetToken();
522         unsetCookie();
523     }
524
525     /***********************************
526      *
527      * GET RESULTS
528      *
529      ************************************/
530     /**
531      * Logged In
532      *
533      * @return true if logged in
534      */
535     public Boolean loggedIn() {
536         return loggedIn(false);
537     }
538
539     /**
540      * Logged In
541      *
542      * @param raiseError if true
543      * @return true if logged in
544      */
545     public Boolean loggedIn(Boolean raiseError) {
546         if (!this.token.isBlank() && !this.cookie.isBlank()) {
547             return true;
548         } else {
549             logger.trace("({}) not logged in", uid);
550             if (raiseError) {
551                 handleError(new TapoErrorHandler(ERR_API_LOGIN));
552             }
553             return false;
554         }
555     }
556
557     /***********************************
558      *
559      * SET VALUES
560      *
561      ************************************/
562
563     /**
564      * Set new ipAddress
565      *
566      * @param new ipAdress
567      */
568     public void setDeviceURL(String ipAddress) {
569         this.ipAddress = ipAddress;
570         this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
571     }
572
573     /**
574      * Set new ipAdresss with token
575      *
576      * @param ipAddress ipAddres of device
577      * @param token token from login-ressult
578      */
579     public void setDeviceURL(String ipAddress, String token) {
580         this.ipAddress = ipAddress;
581         this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
582     }
583
584     /**
585      * Set new token
586      *
587      * @param deviceURL
588      * @param token
589      */
590     protected void setToken(String token) {
591         if (!token.isBlank()) {
592             String url = this.deviceURL.replaceAll("\\?token=\\w*", "");
593             this.deviceURL = url + "?token=" + token;
594         }
595         this.token = token;
596     }
597
598     /**
599      * Unset Token (device logout)
600      */
601     protected void unsetToken() {
602         this.deviceURL = this.deviceURL.replaceAll("\\?token=\\w*", "");
603         this.token = "";
604     }
605
606     /**
607      * Set new cookie
608      *
609      * @param cookie
610      */
611     protected void setCookie(String cookie) {
612         this.cookie = cookie;
613     }
614
615     /**
616      * Unset Cookie (device logout)
617      */
618     protected void unsetCookie() {
619         bridge.getHttpClient().getCookieStore().removeAll();
620         this.cookie = "";
621     }
622 }