]> git.basschouten.com Git - openhab-addons.git/blob
4382b0d1fd46e8d01b02e21729ea25c9d1583b4b
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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 device
70      * @param bridgeThingHandler
71      */
72     public TapoDeviceHttpApi(TapoDevice device, TapoBridgeHandler bridgeThingHandler) {
73         this.bridge = bridgeThingHandler;
74         this.tapoCipher = new TapoCipher();
75         this.device = device;
76         this.uid = device.getThingUID().getAsString();
77         setDeviceURL(device.getIpAddress());
78     }
79
80     /***********************************
81      *
82      * DELEGATING FUNCTIONS
83      * will normaly be delegated to extension-classes(TapoDeviceConnector)
84      *
85      ************************************/
86     /**
87      * handle SuccessResponse (setDeviceInfo)
88      *
89      * @param responseBody String with responseBody from device
90      */
91     protected void handleSuccessResponse(String responseBody) {
92     }
93
94     /**
95      * handle JsonResponse (getDeviceInfo)
96      *
97      * @param responseBody String with responseBody from device
98      */
99     protected void handleDeviceResult(String responseBody) {
100     }
101
102     /**
103      * handle JsonResponse (getEnergyData)
104      *
105      * @param responseBody String with responseBody from device
106      */
107     protected void handleEnergyResult(String responseBody) {
108     }
109
110     /**
111      * handle custom response
112      *
113      * @param responseBody String with responseBody from device
114      */
115     protected void handleCustomResponse(String responseBody) {
116     }
117
118     /**
119      * handle JsonResponse (getChildDevices)
120      *
121      * @param responseBody String with responseBody from device
122      */
123     protected void handleChildDevices(String responseBody) {
124     }
125
126     /**
127      * handle error
128      *
129      * @param tapoError TapoErrorHandler
130      */
131     protected void handleError(TapoErrorHandler tapoError) {
132     }
133
134     /**
135      * refresh the list of child devices
136      *
137      */
138     protected void queryChildDevices() {
139     }
140
141     /***********************************
142      *
143      * LOGIN FUNCTIONS
144      *
145      ************************************/
146
147     /**
148      * Create Handshake and set cookie
149      *
150      * @return true if handshake (cookie) was created
151      */
152     protected String createHandshake() {
153         String cookie = "";
154         try {
155             /* create payload for handshake */
156             PayloadBuilder plBuilder = new PayloadBuilder();
157             plBuilder.method = "handshake";
158             plBuilder.addParameter("key", bridge.getCredentials().getPublicKey()); // ?.decode("UTF-8")
159             String payload = plBuilder.getPayload();
160
161             /* send request (create ) */
162             logger.trace("({}) create handhsake with payload: {}", uid, payload);
163             ContentResponse response = sendRequest(this.deviceURL, payload);
164             if (response != null && getErrorCode(response) == 0) {
165                 String encryptedKey = getKeyFromResponse(response);
166                 this.tapoCipher.setKey(encryptedKey, bridge.getCredentials());
167                 cookie = getCookieFromResponse(response);
168             }
169         } catch (Exception e) {
170             logger.debug("({}) could not createHandshake: {}", uid, e.toString());
171             handleError(new TapoErrorHandler(ERR_API_HAND_SHAKE_FAILED, "could not createHandshake"));
172         }
173         return cookie;
174     }
175
176     /**
177      * return encrypted key from 'handshake' request
178      *
179      * @param response ContentResponse from "handshake" method
180      * @return
181      */
182     private String getKeyFromResponse(ContentResponse response) {
183         String rBody = response.getContentAsString();
184         JsonObject jsonObj = GSON.fromJson(rBody, JsonObject.class);
185         if (jsonObj != null) {
186             logger.trace("({}) received awnser: {}", uid, rBody);
187             return jsonObjectToString(jsonObj.getAsJsonObject("result"), "key");
188         } else {
189             logger.warn("({}) could not getKeyFromResponse '{}'", uid, rBody);
190             handleError(new TapoErrorHandler(ERR_API_HAND_SHAKE_FAILED, "could not getKeyFromResponse"));
191         }
192         return "";
193     }
194
195     /**
196      * return cookie from 'handshake' request
197      *
198      * @param response ContentResponse from "handshake" metho
199      * @return
200      */
201     private String getCookieFromResponse(ContentResponse response) {
202         String cookie = "";
203         try {
204             cookie = response.getHeaders().get("Set-Cookie").split(";")[0];
205             logger.trace("({}) got cookie: '{}'", uid, cookie);
206         } catch (Exception e) {
207             logger.warn("({}) could not getCookieFromResponse", uid);
208             handleError(new TapoErrorHandler(ERR_API_HAND_SHAKE_FAILED, "could not getCookieFromResponse"));
209         }
210         return cookie;
211     }
212
213     /**
214      * Query Token from device
215      *
216      * @return String with token returned from device
217      */
218     protected String queryToken() {
219         String token = "";
220         try {
221             /* encrypt login credentials */
222             PayloadBuilder plBuilder = new PayloadBuilder();
223             plBuilder.method = "login_device";
224             plBuilder.addParameter("username", bridge.getCredentials().getEncodedEmail());
225             plBuilder.addParameter("password", bridge.getCredentials().getEncodedPassword());
226             String payload = plBuilder.getPayload();
227             String encryptedPayload = this.encryptPayload(payload);
228
229             /* create secured login informations */
230             plBuilder = new PayloadBuilder();
231             plBuilder.method = "securePassthrough";
232             plBuilder.addParameter("request", encryptedPayload);
233             String securePassthroughPayload = plBuilder.getPayload();
234
235             /* sendRequest and get Token */
236             ContentResponse response = sendRequest(deviceURL, securePassthroughPayload);
237             token = getTokenFromResponse(response);
238         } catch (Exception e) {
239             logger.debug("({}) error building login payload: {}", uid, e.toString());
240             handleError(new TapoErrorHandler(e, "error building login payload"));
241         }
242         return token;
243     }
244
245     /**
246      * get Token from "login"-request
247      *
248      * @param response
249      * @return
250      */
251     private String getTokenFromResponse(@Nullable ContentResponse response) {
252         String result = "";
253         TapoErrorHandler tapoError = new TapoErrorHandler();
254         if (response != null && response.getStatus() == 200) {
255             String rBody = response.getContentAsString();
256             String decryptedResponse = this.decryptResponse(rBody);
257             logger.trace("({}) received result: {}", uid, decryptedResponse);
258
259             /* get errocode (0=success) */
260             JsonObject jsonObject = GSON.fromJson(decryptedResponse, JsonObject.class);
261             if (jsonObject != null) {
262                 Integer errorCode = jsonObjectToInt(jsonObject, "error_code", ERR_API_JSON_DECODE_FAIL.getCode());
263                 if (errorCode == 0) {
264                     /* return result if set / else request was successful */
265                     result = jsonObjectToString(jsonObject.getAsJsonObject("result"), "token");
266                 } else {
267                     /* return errorcode from device */
268                     tapoError.raiseError(errorCode, "could not get token");
269                     logger.debug("({}) login recieved errorCode {} - {}", uid, errorCode, tapoError.getMessage());
270                 }
271             } else {
272                 logger.debug("({}) unexpected json-response '{}'", uid, decryptedResponse);
273                 tapoError.raiseError(ERR_API_JSON_ENCODE_FAIL, "could not get token");
274             }
275         } else {
276             logger.debug("({}) invalid response while login", uid);
277             tapoError.raiseError(ERR_BINDING_HTTP_RESPONSE, "invalid response while login");
278         }
279         /* handle error */
280         if (tapoError.hasError()) {
281             handleError(tapoError);
282         }
283         return result;
284     }
285
286     /***********************************
287      *
288      * HTTP-ACTIONS
289      *
290      ************************************/
291     /**
292      * SEND SYNCHRON HTTP-REQUEST
293      *
294      * @param url url request is sent to
295      * @param payload payload (String) to send
296      * @return ContentResponse of request
297      */
298     @Nullable
299     protected ContentResponse sendRequest(String url, String payload) {
300         logger.trace("({}) sendRequest to '{}' with cookie '{}'", uid, url, this.cookie);
301
302         Request httpRequest = bridge.getHttpClient().newRequest(url).method(HttpMethod.POST.toString());
303
304         /* set header */
305         httpRequest = setHeaders(httpRequest);
306         httpRequest.timeout(TAPO_HTTP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
307
308         /* add request body */
309         httpRequest.content(new StringContentProvider(payload, CONTENT_CHARSET), CONTENT_TYPE_JSON);
310
311         try {
312             return 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));
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 ipAddress 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 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 }