]> git.basschouten.com Git - openhab-addons.git/blob
26ed25aebaee15cd6d074810940ba48a8b7cbc38
[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.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);
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             ContentResponse httpResponse = httpRequest.send();
312             return httpResponse;
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));
322         }
323         return null;
324     }
325
326     /**
327      * SEND ASYNCHRONOUS HTTP-REQUEST
328      * (don't wait for awnser with programm code)
329      *
330      * @param url string url request is sent to
331      * @param payload data-payload
332      * @param command command executed - this will handle RepsonseType
333      */
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);
337
338         try {
339             Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
340
341             /* set header */
342             httpRequest = setHeaders(httpRequest);
343
344             /* add request body */
345             httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
346
347             httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
348                 @NonNullByDefault({})
349                 @Override
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));
359                         } else {
360                             logger.debug("({}) sendAsyncRequest failed'{}'", uid, errorMessage);
361                             handleError(new TapoErrorHandler(new Exception(e), errorMessage));
362                         }
363                     } else if (response.getStatus() != 200) {
364                         logger.debug("({}) sendAsyncRequest response error'{}'", uid, response.getStatus());
365                         handleError(new TapoErrorHandler(ERR_BINDING_HTTP_RESPONSE, getContentAsString()));
366                     } else {
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);
373                             /* handle result */
374                             switch (command) {
375                                 case DEVICE_CMD_SETINFO:
376                                     handleSuccessResponse(rBody);
377                                     break;
378                                 case DEVICE_CMD_GETINFO:
379                                     handleDeviceResult(rBody);
380                                     break;
381                                 case DEVICE_CMD_GETENERGY:
382                                     handleEnergyResult(rBody);
383                                     break;
384                                 case DEVICE_CMD_CUSTOM:
385                                     handleCustomResponse(rBody);
386                                     break;
387                                 case DEVICE_CMD_CHILD_DEVICE_LIST:
388                                     handleChildDevices(rBody);
389                                     break;
390                             }
391                         } else {
392                             getErrorCode(rBody);
393                         }
394                     }
395                 }
396             });
397         } catch (Exception e) {
398             handleError(new TapoErrorHandler(e));
399         }
400     }
401
402     /**
403      * return error code from response
404      *
405      * @param response
406      * @return 0 if request was successfull
407      */
408     protected Integer getErrorCode(@Nullable ContentResponse response) {
409         try {
410             if (response != null) {
411                 String responseBody = response.getContentAsString();
412                 return getErrorCode(responseBody);
413             } else {
414                 return ERR_BINDING_HTTP_RESPONSE.getCode();
415             }
416         } catch (Exception e) {
417             return ERR_BINDING_HTTP_RESPONSE.getCode();
418         }
419     }
420
421     /**
422      * return error code from responseBody
423      *
424      * @param responseBody
425      * @return 0 if request was successfull
426      */
427     protected Integer getErrorCode(String responseBody) {
428         try {
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) {
433                 return 0;
434             } else {
435                 logger.debug("({}) device returns errorcode '{}'", uid, errorCode);
436                 handleError(new TapoErrorHandler(errorCode));
437                 return errorCode;
438             }
439         } catch (Exception e) {
440             return ERR_BINDING_HTTP_RESPONSE.getCode();
441         }
442     }
443
444     /**
445      * Check for JsonObject "errorcode" and if this is > 0 (no Error)
446      *
447      * @param responseBody
448      * @return true if is js errorcode > 0; false if there is no "errorcode"
449      */
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());
455             if (errorCode > 0) {
456                 return true;
457             }
458         }
459         return false;
460     }
461
462     /**
463      * SET HTTP-HEADERS
464      */
465     private Request setHeaders(Request httpRequest) {
466         /* set header */
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);
471         }
472         return httpRequest;
473     }
474
475     /***********************************
476      *
477      * ENCRYPTION / CODING
478      *
479      ************************************/
480
481     /**
482      * Decrypt Response
483      *
484      * @param responseBody encrypted string from response-body
485      * @return String decrypted responseBody
486      */
487     protected String decryptResponse(String responseBody) {
488         try {
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);
493             } else {
494                 handleError(new TapoErrorHandler(ERR_API_JSON_DECODE_FAIL));
495             }
496         } catch (Exception ex) {
497             logger.debug("({}) exception '{}' decryptingResponse: '{}'", uid, ex.toString(), responseBody);
498         }
499         return responseBody;
500     }
501
502     /**
503      * encrypt payload
504      *
505      * @param payload
506      * @return encrypted payload
507      */
508     protected String encryptPayload(String payload) {
509         try {
510             return tapoCipher.encode(payload);
511         } catch (Exception ex) {
512             logger.debug("({}) exception encoding Payload '{}'", uid, ex.toString());
513             return "";
514         }
515     }
516
517     /**
518      * perform logout (dispose cookie)
519      */
520     public void logout() {
521         logger.trace("DeviceHttpApi_logout");
522         unsetToken();
523         unsetCookie();
524     }
525
526     /***********************************
527      *
528      * GET RESULTS
529      *
530      ************************************/
531     /**
532      * Logged In
533      *
534      * @return true if logged in
535      */
536     public Boolean loggedIn() {
537         return loggedIn(false);
538     }
539
540     /**
541      * Logged In
542      *
543      * @param raiseError if true
544      * @return true if logged in
545      */
546     public Boolean loggedIn(Boolean raiseError) {
547         if (!this.token.isBlank() && !this.cookie.isBlank()) {
548             return true;
549         } else {
550             logger.trace("({}) not logged in", uid);
551             if (raiseError) {
552                 handleError(new TapoErrorHandler(ERR_API_LOGIN));
553             }
554             return false;
555         }
556     }
557
558     /***********************************
559      *
560      * SET VALUES
561      *
562      ************************************/
563
564     /**
565      * Set new ipAddress
566      *
567      * @param new ipAdress
568      */
569     public void setDeviceURL(String ipAddress) {
570         this.ipAddress = ipAddress;
571         this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
572     }
573
574     /**
575      * Set new ipAdresss with token
576      *
577      * @param ipAddress ipAddres of device
578      * @param token token from login-ressult
579      */
580     public void setDeviceURL(String ipAddress, String token) {
581         this.ipAddress = ipAddress;
582         this.deviceURL = String.format(TAPO_DEVICE_URL, ipAddress);
583     }
584
585     /**
586      * Set new token
587      *
588      * @param deviceURL
589      * @param token
590      */
591     protected void setToken(String token) {
592         if (!token.isBlank()) {
593             String url = this.deviceURL.replaceAll("\\?token=\\w*", "");
594             this.deviceURL = url + "?token=" + token;
595         }
596         this.token = token;
597     }
598
599     /**
600      * Unset Token (device logout)
601      */
602     protected void unsetToken() {
603         this.deviceURL = this.deviceURL.replaceAll("\\?token=\\w*", "");
604         this.token = "";
605     }
606
607     /**
608      * Set new cookie
609      *
610      * @param cookie
611      */
612     protected void setCookie(String cookie) {
613         this.cookie = cookie;
614     }
615
616     /**
617      * Unset Cookie (device logout)
618      */
619     protected void unsetCookie() {
620         bridge.getHttpClient().getCookieStore().removeAll();
621         this.cookie = "";
622     }
623 }