]> git.basschouten.com Git - openhab-addons.git/blob
53ad49a378672c3d0083422627c7dab16357cc87
[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.TapoErrorConstants.*;
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.Gson;
40 import com.google.gson.JsonObject;
41
42 /**
43  * Handler class for TAPO Smart Home device connections.
44  * This class uses synchronous HttpClient-Requests for login to device
45  *
46  * @author Christian Wild - Initial contribution
47  */
48 @NonNullByDefault
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;
54     private Gson gson;
55     private String token = "";
56     private String cookie = "";
57     protected String deviceURL = "";
58     protected String ipAddress = "";
59
60     /**
61      * INIT CLASS
62      *
63      * @param config TapoControlConfiguration class
64      */
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);
72     }
73
74     /***********************************
75      *
76      * DELEGATING FUNCTIONS
77      * will normaly be delegated to extension-classes(TapoDeviceConnector)
78      *
79      ************************************/
80     /**
81      * handle SuccessResponse (setDeviceInfo)
82      * 
83      * @param responseBody String with responseBody from device
84      */
85     protected void handleSuccessResponse(String responseBody) {
86     }
87
88     /**
89      * handle JsonResponse (getDeviceInfo)
90      * 
91      * @param responseBody String with responseBody from device
92      */
93     protected void handleDeviceResult(String responseBody) {
94     }
95
96     /**
97      * handle JsonResponse (getEnergyData)
98      * 
99      * @param responseBody String with responseBody from device
100      */
101     protected void handleEnergyResult(String responseBody) {
102     }
103
104     /**
105      * handle custom response
106      * 
107      * @param responseBody String with responseBody from device
108      */
109     protected void handleCustomResponse(String responseBody) {
110     }
111
112     /**
113      * handle error
114      * 
115      * @param te TapoErrorHandler
116      */
117     protected void handleError(TapoErrorHandler tapoError) {
118     }
119
120     /***********************************
121      *
122      * LOGIN FUNCTIONS
123      *
124      ************************************/
125
126     /**
127      * Create Handshake and set cookie
128      *
129      * @return true if handshake (cookie) was created
130      */
131     protected String createHandshake() {
132         String cookie = "";
133         try {
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();
139
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);
147             }
148         } catch (Exception e) {
149             logger.debug("({}) could not createHandshake: {}", uid, e.toString());
150             handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not createHandshake"));
151         }
152         return cookie;
153     }
154
155     /**
156      * return encrypted key from 'handshake' request
157      * 
158      * @param response ContentResponse from "handshake" method
159      * @return
160      */
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");
167         } else {
168             logger.warn("({}) could not getKeyFromResponse '{}'", uid, rBody);
169             handleError(new TapoErrorHandler(ERR_HAND_SHAKE_FAILED, "could not getKeyFromResponse"));
170         }
171         return "";
172     }
173
174     /**
175      * return cookie from 'handshake' request
176      * 
177      * @param response ContentResponse from "handshake" metho
178      * @return
179      */
180     private String getCookieFromResponse(ContentResponse response) {
181         String cookie = "";
182         try {
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"));
188         }
189         return cookie;
190     }
191
192     /**
193      * Query Token from device
194      * 
195      * @return String with token returned from device
196      */
197     protected String queryToken() {
198         String token = "";
199         try {
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);
207
208             /* create secured login informations */
209             plBuilder = new PayloadBuilder();
210             plBuilder.method = "securePassthrough";
211             plBuilder.addParameter("request", encryptedPayload);
212             String securePassthroughPayload = plBuilder.getPayload();
213
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"));
220         }
221         return token;
222     }
223
224     /**
225      * get Token from "login"-request
226      * 
227      * @param response
228      * @return
229      */
230     private String getTokenFromResponse(@Nullable ContentResponse response) {
231         String result = "";
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);
237
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");
245                 } else {
246                     /* return errorcode from device */
247                     tapoError.raiseError(errorCode, "could not get token");
248                     logger.debug("({}) login recieved errorCode {} - {}", uid, errorCode, tapoError.getMessage());
249                 }
250             } else {
251                 logger.debug("({}) unexpected json-response '{}'", uid, decryptedResponse);
252                 tapoError.raiseError(ERR_JSON_ENCODE_FAIL, "could not get token");
253             }
254         } else {
255             logger.debug("({}) invalid response while login", uid);
256             tapoError.raiseError(ERR_HTTP_RESPONSE, "invalid response while login");
257         }
258         /* handle error */
259         if (tapoError.hasError()) {
260             handleError(tapoError);
261         }
262         return result;
263     }
264
265     /***********************************
266      *
267      * HTTP-ACTIONS
268      *
269      ************************************/
270     /**
271      * SEND SYNCHRON HTTP-REQUEST
272      * 
273      * @param url url request is sent to
274      * @param payload payload (String) to send
275      * @return ContentResponse of request
276      */
277     @Nullable
278     protected ContentResponse sendRequest(String url, String payload) {
279         logger.trace("({}) sendRequest to '{}' with cookie '{}'", uid, url, this.cookie);
280
281         Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
282
283         /* set header */
284         httpRequest = setHeaders(httpRequest);
285         httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
286
287         /* add request body */
288         httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
289
290         try {
291             ContentResponse httpResponse = httpRequest.send();
292             return httpResponse;
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));
302         }
303         return null;
304     }
305
306     /**
307      * SEND ASYNCHRONOUS HTTP-REQUEST
308      * (don't wait for awnser with programm code)
309      * 
310      * @param url string url request is sent to
311      * @param payload data-payload
312      * @param command command executed - this will handle RepsonseType
313      */
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);
317
318         try {
319             Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
320
321             /* set header */
322             httpRequest = setHeaders(httpRequest);
323
324             /* add request body */
325             httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
326
327             httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
328                 @NonNullByDefault({})
329                 @Override
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));
339                         } else {
340                             logger.debug("({}) sendAsyncRequest failed'{}'", uid, errorMessage);
341                             handleError(new TapoErrorHandler(new Exception(e), errorMessage));
342                         }
343                     } else if (response.getStatus() != 200) {
344                         logger.debug("({}) sendAsyncRequest response error'{}'", uid, response.getStatus());
345                         handleError(new TapoErrorHandler(ERR_HTTP_RESPONSE, getContentAsString()));
346                     } else {
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);
353                             /* handle result */
354                             switch (command) {
355                                 case DEVICE_CMD_SETINFO:
356                                     handleSuccessResponse(rBody);
357                                     break;
358                                 case DEVICE_CMD_GETINFO:
359                                     handleDeviceResult(rBody);
360                                     break;
361                                 case DEVICE_CMD_GETENERGY:
362                                     handleEnergyResult(rBody);
363                                     break;
364                                 case DEVICE_CMD_CUSTOM:
365                                     handleCustomResponse(rBody);
366                                     break;
367                             }
368                         } else {
369                             getErrorCode(rBody);
370                         }
371                     }
372                 }
373             });
374         } catch (Exception e) {
375             handleError(new TapoErrorHandler(e));
376         }
377     }
378
379     /**
380      * return error code from response
381      * 
382      * @param response
383      * @return 0 if request was successfull
384      */
385     protected Integer getErrorCode(@Nullable ContentResponse response) {
386         try {
387             if (response != null) {
388                 String responseBody = response.getContentAsString();
389                 return getErrorCode(responseBody);
390             } else {
391                 return ERR_HTTP_RESPONSE;
392             }
393         } catch (Exception e) {
394             return ERR_HTTP_RESPONSE;
395         }
396     }
397
398     /**
399      * return error code from responseBody
400      * 
401      * @param responseBody
402      * @return 0 if request was successfull
403      */
404     protected Integer getErrorCode(String responseBody) {
405         try {
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) {
410                 return 0;
411             } else {
412                 logger.debug("({}) device returns errorcode '{}'", uid, errorCode);
413                 handleError(new TapoErrorHandler(errorCode));
414                 return errorCode;
415             }
416         } catch (Exception e) {
417             return ERR_HTTP_RESPONSE;
418         }
419     }
420
421     /**
422      * Check for JsonObject "errorcode" and if this is > 0 (no Error)
423      * 
424      * @param responseBody
425      * @return true if is js errorcode > 0; false if there is no "errorcode"
426      */
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);
432             if (errorCode > 0) {
433                 return true;
434             }
435         }
436         return false;
437     }
438
439     /**
440      * SET HTTP-HEADERS
441      */
442     private Request setHeaders(Request httpRequest) {
443         /* set header */
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);
448         }
449         return httpRequest;
450     }
451
452     /***********************************
453      *
454      * ENCRYPTION / CODING
455      *
456      ************************************/
457
458     /**
459      * Decrypt Response
460      * 
461      * @param responseBody encrypted string from response-body
462      * @return String decrypted responseBody
463      */
464     protected String decryptResponse(String responseBody) {
465         try {
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);
470             } else {
471                 handleError(new TapoErrorHandler(ERR_JSON_DECODE_FAIL));
472             }
473         } catch (Exception ex) {
474             logger.debug("({}) exception '{}' decryptingResponse: '{}'", uid, ex.toString(), responseBody);
475         }
476         return responseBody;
477     }
478
479     /**
480      * encrypt payload
481      * 
482      * @param payload
483      * @return encrypted payload
484      */
485     protected String encryptPayload(String payload) {
486         try {
487             return tapoCipher.encode(payload);
488         } catch (Exception ex) {
489             logger.debug("({}) exception encoding Payload '{}'", uid, ex.toString());
490             return "";
491         }
492     }
493
494     /**
495      * perform logout (dispose cookie)
496      */
497     public void logout() {
498         logger.trace("DeviceHttpApi_logout");
499         unsetToken();
500         unsetCookie();
501     }
502
503     /***********************************
504      *
505      * GET RESULTS
506      *
507      ************************************/
508     /**
509      * Logged In
510      * 
511      * @return true if logged in
512      */
513     public Boolean loggedIn() {
514         return loggedIn(false);
515     }
516
517     /**
518      * Logged In
519      * 
520      * @param raiseError if true
521      * @return true if logged in
522      */
523     public Boolean loggedIn(Boolean raiseError) {
524         if (!this.token.isBlank() && !this.cookie.isBlank()) {
525             return true;
526         } else {
527             logger.trace("({}) not logged in", uid);
528             if (raiseError) {
529                 handleError(new TapoErrorHandler(ERR_LOGIN));
530             }
531             return false;
532         }
533     }
534
535     /***********************************
536      *
537      * SET VALUES
538      *
539      ************************************/
540
541     /**
542      * Set new ipAddress
543      * 
544      * @param new ipAdress
545      */
546     public void setDeviceURL(String ipAddress) {
547         this.ipAddress = ipAddress;
548         this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
549     }
550
551     /**
552      * Set new ipAdresss with token
553      * 
554      * @param ipAddress ipAddres of device
555      * @param token token from login-ressult
556      */
557     public void setDeviceURL(String ipAddress, String token) {
558         this.ipAddress = ipAddress;
559         this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
560     }
561
562     /**
563      * Set new token
564      * 
565      * @param deviceURL
566      * @param token
567      */
568     protected void setToken(String token) {
569         if (!token.isBlank()) {
570             String url = this.deviceURL.replaceAll("\\?token=\\w*", "");
571             this.deviceURL = url + "?token=" + token;
572         }
573         this.token = token;
574     }
575
576     /**
577      * Unset Token (device logout)
578      */
579     protected void unsetToken() {
580         this.deviceURL = this.deviceURL.replaceAll("\\?token=\\w*", "");
581         this.token = "";
582     }
583
584     /**
585      * Set new cookie
586      * 
587      * @param cookie
588      */
589     protected void setCookie(String cookie) {
590         this.cookie = cookie;
591     }
592
593     /**
594      * Unset Cookie (device logout)
595      */
596     protected void unsetCookie() {
597         bridge.getHttpClient().getCookieStore().removeAll();
598         this.cookie = "";
599     }
600 }