UNPKG

node-red-contrib-tapo-new-api

Version:

Unofficial node-RED node for connecting to TP-Link Tapo devices with new API. Currently limited to the P100 & P105 & P110 smart plugs and L510E smart bulbs.

959 lines 95.9 kB
'use strict'; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TapoDevice = exports.TapoDevicesType = exports.SnowflakeId = exports.Chiper = exports.Session = exports.PassthroughProtocol = exports.KlapChiper = exports.KlapSession = exports.KlapProtocol = exports.TapoError = exports.TapoChiper = exports.TapoSession = exports.TapoProtocol = exports.ColorParams = exports.Components = exports.ControlChildParams = exports.MultipleRequestParams = exports.PaginationParams = exports.SecurePassthroughParams = exports.LoginDeviceParamsV2 = exports.LoginDeviceParams = exports.HandshakeParams = exports.TapoResponse = exports.TapoRequest = exports.TapoClient = exports.AuthCredential = exports.TapoProtocolType = exports.ErrorMsg = exports.ErrorCode = exports.supportEnergyUsage = void 0; // Import modules const node_util_1 = require("node:util"); const node_crypto_1 = require("node:crypto"); const axios_1 = __importDefault(require("axios")); const node_http_1 = __importDefault(require("node:http")); const node_https_1 = __importDefault(require("node:https")); const uuid_1 = require("uuid"); const local_devices_1 = __importDefault(require("local-devices")); // Global constants for internal use const RSA_CIPHER_ALGORITHM = 'rsa'; const AES_CIPHER_ALGORITHM = 'aes-128-cbc'; const PASSPHRASE = "top secret"; const TP_SESSION_COOKIE_NAME = "TP_SESSIONID"; const TP_TEST_USER = "test@tp-link.net"; const TP_TEST_PASSWORD = "test"; const CLOUD_URL = "https://eu-wap.tplinkcloud.com"; const MAX_RETRIES_GUESS = 3; const AXIOS_TIMEOUT = 1000; // Constants for supported Energy devices exports.supportEnergyUsage = ["P110", "P115"]; // Constants for Error var ErrorCode; (function (ErrorCode) { // ----------GENERAL ERROR (1-99)------------------- ErrorCode[ErrorCode["ERROR_AXIOS_ERROR"] = 1] = "ERROR_AXIOS_ERROR"; ErrorCode[ErrorCode["ERROR_TAPRES_JSON_INVALID"] = 2] = "ERROR_TAPRES_JSON_INVALID"; ErrorCode[ErrorCode["ERROR_AXIOS_FORBID"] = 3] = "ERROR_AXIOS_FORBID"; ErrorCode[ErrorCode["GENERIC_ERROR"] = 99] = "GENERIC_ERROR"; // ----------ENCRIPTION ERROR (101-199)------------- ErrorCode[ErrorCode["ERROR_KL_ENCRYPT_FMT"] = 101] = "ERROR_KL_ENCRYPT_FMT"; ErrorCode[ErrorCode["ERROR_KL_ENCRYPT_IV_LENGTH"] = 102] = "ERROR_KL_ENCRYPT_IV_LENGTH"; ErrorCode[ErrorCode["ERROR_CH_UNABLE_KEYS"] = 103] = "ERROR_CH_UNABLE_KEYS"; ErrorCode[ErrorCode["ERROR_SNOW_WORKER_ID"] = 104] = "ERROR_SNOW_WORKER_ID"; ErrorCode[ErrorCode["ERROR_SNOW_DATA_CENTER_ID"] = 105] = "ERROR_SNOW_DATA_CENTER_ID"; ErrorCode[ErrorCode["ERROR_SNOW_INVALID_TIME_ID"] = 106] = "ERROR_SNOW_INVALID_TIME_ID"; // ----------SESSION POST ERROR (201-299)----------- ErrorCode[ErrorCode["ERROR_aSP_bAX_REQ_ERR"] = 201] = "ERROR_aSP_bAX_REQ_ERR"; ErrorCode[ErrorCode["ERROR_aSP_bAX_REQ_FORBID"] = 202] = "ERROR_aSP_bAX_REQ_FORBID"; ErrorCode[ErrorCode["ERROR_aSP_bAX_INVALID_URL"] = 203] = "ERROR_aSP_bAX_INVALID_URL"; // ----------SEND REQUEST ERROR (301-399)----------- ErrorCode[ErrorCode["ERROR_aSR_bSR_RET_ERR"] = 301] = "ERROR_aSR_bSR_RET_ERR"; ErrorCode[ErrorCode["ERROR_aSR_bSR_MAX_RET"] = 302] = "ERROR_aSR_bSR_MAX_RET"; ErrorCode[ErrorCode["ERROR_aSR_bSP_REQ_ERR"] = 303] = "ERROR_aSR_bSP_REQ_ERR"; ErrorCode[ErrorCode["ERROR_aSR_bSP_REJ"] = 304] = "ERROR_aSR_bSP_REJ"; ErrorCode[ErrorCode["ERROR_aSR_DEV_FORBID"] = 305] = "ERROR_aSR_DEV_FORBID"; ErrorCode[ErrorCode["ERROR_aSR_DEV_GENERAL"] = 399] = "ERROR_aSR_DEV_GENERAL"; // ----------HANDSHAKE ERROR (401-499)-------------- ErrorCode[ErrorCode["ERROR_aPH2_bSP_HSK_ERROR"] = 401] = "ERROR_aPH2_bSP_HSK_ERROR"; ErrorCode[ErrorCode["ERROR_aPH1_bSP_HSK_ERROR"] = 402] = "ERROR_aPH1_bSP_HSK_ERROR"; ErrorCode[ErrorCode["ERROR_aPH2_bSP_HSK_REJ"] = 411] = "ERROR_aPH2_bSP_HSK_REJ"; ErrorCode[ErrorCode["ERROR_aPH_bSP_HSK_REJ"] = 412] = "ERROR_aPH_bSP_HSK_REJ"; ErrorCode[ErrorCode["ERROR_aPH1_bSP_HSK_MISSMATCH"] = 421] = "ERROR_aPH1_bSP_HSK_MISSMATCH"; ErrorCode[ErrorCode["ERROR_aPH1_bSP_HSK_FORBID"] = 431] = "ERROR_aPH1_bSP_HSK_FORBID"; ErrorCode[ErrorCode["ERROR_aPH_bSP_HSK_FORBID"] = 432] = "ERROR_aPH_bSP_HSK_FORBID"; ErrorCode[ErrorCode["ERROR_aPH_bPH1_HSK_ERROR"] = 441] = "ERROR_aPH_bPH1_HSK_ERROR"; ErrorCode[ErrorCode["ERROR_aSR_bPH_HSK_ERROR"] = 442] = "ERROR_aSR_bPH_HSK_ERROR"; ErrorCode[ErrorCode["ERROR_aLG_bS_TOKEN_NOT_FOUND"] = 451] = "ERROR_aLG_bS_TOKEN_NOT_FOUND"; ErrorCode[ErrorCode["ERROR_aLG_bPH_HSK_TIMEOUT"] = 452] = "ERROR_aLG_bPH_HSK_TIMEOUT"; ErrorCode[ErrorCode["ERROR_aLG_bS_TOKEN_ERROR"] = 453] = "ERROR_aLG_bS_TOKEN_ERROR"; ErrorCode[ErrorCode["ERROR_aLG_bPH_HSK_ERROR"] = 454] = "ERROR_aLG_bPH_HSK_ERROR"; // ----------GUESS PROTOCOL (501-599)-------------- ErrorCode[ErrorCode["ERROR_aGP_INCOMPLETE"] = 501] = "ERROR_aGP_INCOMPLETE"; ErrorCode[ErrorCode["ERROR_aGP_GUESS"] = 502] = "ERROR_aGP_GUESS"; // ----------FUNCTIONAL ERROR---------------------- ErrorCode[ErrorCode["ERROR_FUNC_GENERAL"] = 601] = "ERROR_FUNC_GENERAL"; ErrorCode[ErrorCode["ERROR_CLOUD_CONN_REJ"] = 602] = "ERROR_CLOUD_CONN_REJ"; ErrorCode[ErrorCode["ERROR_DEVICE_INFO"] = 603] = "ERROR_DEVICE_INFO"; ErrorCode[ErrorCode["ERROR_CLOUD_NO_DEVICE_LIST"] = 604] = "ERROR_CLOUD_NO_DEVICE_LIST"; ErrorCode[ErrorCode["ERROR_ALIAS_NOT_FOUND"] = 605] = "ERROR_ALIAS_NOT_FOUND"; ErrorCode[ErrorCode["ERROR_FUNC_VALID_COLOR"] = 651] = "ERROR_FUNC_VALID_COLOR"; ErrorCode[ErrorCode["ERROR_FUNC_TEMP_COLOR"] = 652] = "ERROR_FUNC_TEMP_COLOR"; ErrorCode[ErrorCode["ERROR_FUNC_HEX_COLOR"] = 653] = "ERROR_FUNC_HEX_COLOR"; ErrorCode[ErrorCode["ERROR_FUNC_KEY_LENGTH"] = -1010] = "ERROR_FUNC_KEY_LENGTH"; ErrorCode[ErrorCode["ERROR_FUNC_BAD_CREDENTIALS"] = -1501] = "ERROR_FUNC_BAD_CREDENTIALS"; ErrorCode[ErrorCode["ERROR_FUNC_BAD_REQUEST"] = -1002] = "ERROR_FUNC_BAD_REQUEST"; ErrorCode[ErrorCode["ERROR_FUNC_BAD_JSON"] = -1003] = "ERROR_FUNC_BAD_JSON"; ErrorCode[ErrorCode["ERROR_FUNC_WRONG_EMAIL"] = -20601] = "ERROR_FUNC_WRONG_EMAIL"; ErrorCode[ErrorCode["ERROR_FUNC_CLOUD_TOKEN_EXPIRED"] = -20675] = "ERROR_FUNC_CLOUD_TOKEN_EXPIRED"; ErrorCode[ErrorCode["ERROR_FUNC_DEV_TOKEN_EXPIRED"] = 9999] = "ERROR_FUNC_DEV_TOKEN_EXPIRED"; ErrorCode[ErrorCode["ERROR_FUNC_UNEXPECTED"] = 19999] = "ERROR_FUNC_UNEXPECTED"; })(ErrorCode = exports.ErrorCode || (exports.ErrorCode = {})); var ErrorMsg; (function (ErrorMsg) { // ----------GENERAL ERROR (1-99)------------------- ErrorMsg["ERROR_AXIOS_ERROR"] = "Axios error: "; ErrorMsg["ERROR_TAPRES_JSON_INVALID"] = "Invalid JSON answer: "; ErrorMsg["ERROR_AXIOS_FORBID"] = "Negotiation error: "; ErrorMsg["GENERIC_ERROR"] = "General error: "; // ----------ENCRIPTION ERROR (101-199)------------- ErrorMsg["ERROR_KL_ENCRYPT_FMT"] = "Encryption error: "; ErrorMsg["ERROR_KL_ENCRYPT_IV_LENGTH"] = "Encryption error: "; ErrorMsg["ERROR_CH_UNABLE_KEYS"] = "Encryption error: "; ErrorMsg["ERROR_SNOW_WORKER_ID"] = "Encryption error: "; ErrorMsg["ERROR_SNOW_DATA_CENTER_ID"] = "Encryption error: "; ErrorMsg["ERROR_SNOW_INVALID_TIME_ID"] = "Encryption error: "; // ----------SESSION POST ERROR (201-299)----------- ErrorMsg["ERROR_aSP_bAX_REQ_ERR"] = "Device comm error: "; ErrorMsg["ERROR_aSP_bAX_REQ_FORBID"] = "Device comm rejected: "; ErrorMsg["ERROR_aSP_bAX_INVALID_URL"] = "URL not valid: "; // ----------SEND REQUEST ERROR (301-399)----------- ErrorMsg["ERROR_aSR_bSR_RET_ERR"] = "Instant retry error: "; ErrorMsg["ERROR_aSR_bSR_MAX_RET"] = "Max retries reached: "; ErrorMsg["ERROR_aSR_bSP_REQ_ERR"] = "Device request error: "; ErrorMsg["ERROR_aSR_bSP_REJ"] = "Device comm rejected: "; ErrorMsg["ERROR_aSR_DEV_FORBID"] = "Device comm rejected: "; ErrorMsg["ERROR_aSR_DEV_GENERAL"] = "Device comm error: "; // ----------HANDSHAKE ERROR (401-499)-------------- ErrorMsg["ERROR_aPH2_bSP_HSK_ERROR"] = "Handshake error: "; ErrorMsg["ERROR_aPH1_bSP_HSK_ERROR"] = "Handshake error: "; ErrorMsg["ERROR_aPH2_bSP_HSK_REJ"] = "Handshare rejected: "; ErrorMsg["ERROR_aPH_bSP_HSK_REJ"] = "Handshare rejected: "; ErrorMsg["ERROR_aPH1_bSP_HSK_MISSMATCH"] = "Handshake error: "; ErrorMsg["ERROR_aPH1_bSP_HSK_FORBID"] = "Handshare rejected: "; ErrorMsg["ERROR_aPH_bSP_HSK_FORBID"] = "Handshare rejected: "; ErrorMsg["ERROR_aPH_bPH1_HSK_ERROR"] = "Handshake error: "; ErrorMsg["ERROR_aSR_bPH_HSK_ERROR"] = "Handshake error: "; ErrorMsg["ERROR_aLG_bS_TOKEN_NOT_FOUND"] = "Handshake error: "; ErrorMsg["ERROR_aLG_bPH_HSK_TIMEOUT"] = "Handshake timeout: "; ErrorMsg["ERROR_aLG_bS_TOKEN_ERROR"] = "Handshake error: "; ErrorMsg["ERROR_aLG_bPH_HSK_ERROR"] = "Handshake error: "; // ----------GUESS PROTOCOL (501-599)-------------- ErrorMsg["ERROR_aGP_INCOMPLETE"] = "Protocol not detected: "; ErrorMsg["ERROR_aGP_GUESS"] = "Protocol not detected: "; // ----------FUNCTIONAL ERROR---------------------- ErrorMsg["ERROR_FUNC_GENERAL"] = "General functional error: "; ErrorMsg["ERROR_CLOUD_CONN_REJ"] = "Cloud connection rejected: "; ErrorMsg["ERROR_DEVICE_INFO"] = "Device comm error: "; ErrorMsg["ERROR_CLOUD_NO_DEVICE_LIST"] = "Cloud empty list: "; ErrorMsg["ERROR_ALIAS_NOT_FOUND"] = "Alias not found: "; ErrorMsg["ERROR_FUNC_VALID_COLOR"] = "Color not valid: "; ErrorMsg["ERROR_FUNC_TEMP_COLOR"] = "Color not valid: "; ErrorMsg["ERROR_FUNC_HEX_COLOR"] = "Color not valid: "; ErrorMsg["ERROR_FUNC_KEY_LENGTH"] = "Encryption error: "; ErrorMsg["ERROR_FUNC_BAD_CREDENTIALS"] = "Bad credentials: "; ErrorMsg["ERROR_FUNC_BAD_REQUEST"] = "Device request error: "; ErrorMsg["ERROR_FUNC_BAD_JSON"] = "Invalid JSON answer: "; ErrorMsg["ERROR_FUNC_WRONG_EMAIL"] = "Bad credentials: "; ErrorMsg["ERROR_FUNC_CLOUD_TOKEN_EXPIRED"] = "Token expired: "; ErrorMsg["ERROR_FUNC_DEV_TOKEN_EXPIRED"] = "Token expired: "; ErrorMsg["ERROR_FUNC_UNEXPECTED"] = "Unexpected error: "; })(ErrorMsg = exports.ErrorMsg || (exports.ErrorMsg = {})); // **************************************************** // *** TAPO CLASSES TO DEFINE TAPO CLIENT SESSIONS *** // **************************************************** // Class for TapoProtocol var TapoProtocolType; (function (TapoProtocolType) { TapoProtocolType[TapoProtocolType["PASSTHROUGH"] = 1] = "PASSTHROUGH"; TapoProtocolType[TapoProtocolType["KLAP"] = 2] = "KLAP"; TapoProtocolType[TapoProtocolType["AUTO"] = 3] = "AUTO"; })(TapoProtocolType = exports.TapoProtocolType || (exports.TapoProtocolType = {})); // Class for Authentication credentials class AuthCredential { constructor(user, passwd) { this.username = (typeof (user) == 'undefined' ? '' : user); this.password = (typeof (passwd) == 'undefined' ? '' : passwd); // Set the properties to false to avoid any change or access once created Object.defineProperty(this, 'username', { enumerable: false, writable: false, configurable: false }); Object.defineProperty(this, 'password', { enumerable: false, writable: false, configurable: false }); } } exports.AuthCredential = AuthCredential; // Class for TapoClient - connection to every Tapo device class TapoClient { // Constructor to initialize the class constructor(auth_credential, url, protocol, terminal_random, keep_alive, debug) { this._auth_credential = auth_credential; let myURL = new URL("http:\\example.com"); try { const myURL2 = new URL(url); myURL = myURL2; } catch (e) { myURL.protocol = 'http'; myURL.hostname = url; myURL.port = '80'; myURL.pathname = "/app"; } this._url = myURL.href; this._protocol_type = (typeof (protocol) === undefined ? TapoProtocolType.AUTO : protocol); this._protocol = null; this._terminal_random = ((typeof (terminal_random) == 'undefined') ? false : terminal_random); this._debug = (typeof (debug) == 'undefined' ? false : debug); this._keep_alive = (typeof (keep_alive) == 'undefined' ? true : keep_alive); // Create the list of functions this.actions = { 'list_methods': () => { return this.list_methods(); }, 'perform_handshake': (protocol) => { return this.perform_handshake(protocol); }, 'get_component_negotiation': (protocol) => { return this.get_component_negotiation(protocol); }, 'get_device_info': (protocol) => { return this.get_device_info(protocol); }, 'get_current_power': (protocol) => { return this.get_current_power(protocol); }, 'get_energy_usage': (protocol) => { return this.get_energy_usage(protocol); }, 'get_child_device_list': (params, protocol) => { return this.get_child_device_list(params, protocol); }, 'execute_raw_request': (request, protocol, retry) => { return this.execute_raw_request(request, protocol, retry); }, 'send_request': (request, protocol) => { return this.send_request(request, protocol); }, 'set_device_info': (params, protocol) => { return this.set_device_info(params, protocol); }, 'turn_onoff_device': (params, protocol) => { return this.turn_onoff_device(params, protocol); }, 'set_color_device': (params, protocol) => { return this.set_color_device(params, protocol); }, 'set_brightness_device': (params, protocol) => { return this.set_brightness_device(params, protocol); } }; // Set the properties to false to avoid any change or access once created if (!this._debug) { Object.defineProperty(this, '_auth_credential', { enumerable: false }); Object.defineProperty(this, '_protocol', { enumerable: false }); Object.defineProperty(this, '_protocol_type', { enumerable: false }); Object.defineProperty(this, '_terminal_random', { enumerable: false }); Object.defineProperty(this, '_debug', { enumerable: false }); Object.defineProperty(this, '_keep_alive', { enumerable: false }); } } // Private methods to setup the protocol async _initialize_protocol_if_needed(protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); if ((this._protocol == null) || ((this._protocol._protocol_type != proto) && (proto != TapoProtocolType.AUTO))) this._protocol = await this._guess_protocol(proto); } async _guess_protocol(protocol) { // Process parameters const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); // Process the ptotocol if it is not AUTO if (proto == TapoProtocolType.PASSTHROUGH) { // Try PASSTHROUGH protocol try { // Set PASSTHROGH and get the components if (this._debug) console.debug("Set protocol to PASSTHROUGH"); this._protocol = new PassthroughProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp = await this.get_component_negotiation(proto, MAX_RETRIES_GUESS); } catch (error) { // Close this protocol and try again without keep_alive this.close(); this._keep_alive = false; try { // Set PASSTHROGH and get the components if (this._debug) console.debug("Set protocol to PASSTHROUGH - Keep alive forced to false"); this._protocol = new PassthroughProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp = await this.get_component_negotiation(proto, MAX_RETRIES_GUESS); } catch (error) { this.close(); throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_aGP_INCOMPLETE]] + 'Negotiation not completed', error, ErrorCode.ERROR_aGP_INCOMPLETE, this._guess_protocol.name); } } // Return the final protocol return this._protocol; } else if (proto == TapoProtocolType.KLAP) { // Try KLAP protocol try { // Set KLAP and get the components if (this._debug) console.debug("Set protocol to KLAP"); this._protocol = new KlapProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp = await this.get_component_negotiation(proto, MAX_RETRIES_GUESS); } catch (error) { // Close this protocol and try again without keep_alive this.close(); this._keep_alive = false; try { // Set KLAP and get the components if (this._debug) console.debug("Set protocol to KLAP - Keep alive forced to false"); this._protocol = new KlapProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp = await this.get_component_negotiation(proto, MAX_RETRIES_GUESS); } catch (error) { this.close(); throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_aGP_INCOMPLETE]] + 'Negotiation not completed', error, ErrorCode.ERROR_aGP_INCOMPLETE, this._guess_protocol.name); } } // Return the final protocol return this._protocol; } else { // Try first with PassthroughProtocol const ka_bck = this._keep_alive; try { // Set Passthrough and get the components if (this._debug) console.debug("Trying first with PassthroughProtocol"); this._protocol = new PassthroughProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp = await this.get_component_negotiation(TapoProtocolType.PASSTHROUGH, MAX_RETRIES_GUESS); } catch (error) { // Close this protocol and try again without keep_alive this.close(); this._keep_alive = false; try { // Set Passthrough without keep_alive and get the components if (this._debug) console.debug("Trying again with PassthroughProtocol - Keep alive forced to false"); this._protocol = new PassthroughProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp = await this.get_component_negotiation(proto, MAX_RETRIES_GUESS); } catch (error) { // Try to fallback to KLAP this.close(); this._keep_alive = ka_bck; try { // Set KLAP and get the components if (this._debug) console.debug("Default protocol not working. Fallback to KLAP"); this._protocol = new KlapProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp_klap = await this.get_component_negotiation(TapoProtocolType.KLAP, MAX_RETRIES_GUESS); } catch (error) { // Set KLAP and try again without keep_alive this.close(); this._keep_alive = false; try { // Set KLAP and get the components if (this._debug) console.debug("Default protocol not working. Fallback to KLAP - Keep alive forced to false"); this._protocol = new KlapProtocol(this._auth_credential, this._url, this._terminal_random, this._keep_alive, this._debug); const resp = await this.get_component_negotiation(proto, MAX_RETRIES_GUESS); } catch (error) { this.close(); throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_aGP_INCOMPLETE]] + 'Negotiation not completed', error, ErrorCode.ERROR_aGP_INCOMPLETE, this._guess_protocol.name); } } } } ; // Return the final protocol return this._protocol; } } // Public protocol exposed methods for external control async perform_handshake(protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); await this._initialize_protocol_if_needed(proto); this._protocol._session = await this._protocol.perform_handshake(); return this; } // Public method to expose protocol send request async send_request(request, protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); await this._initialize_protocol_if_needed(proto); if ((this._protocol._session == null) || (!this._protocol._session.handshake_complete) || (this._protocol._session.is_handshake_session_expired())) { this._protocol._session = await this._protocol.perform_handshake(); } return this._protocol.send_request(request); } // Public method to close the session close() { if (this._protocol != null) this._protocol.close(false); } // Public methods to execute raw request async execute_raw_request(request, protocol, retry) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); await this._initialize_protocol_if_needed(proto); return await this._protocol.send_request(request, ((typeof (retry) == 'undefined') ? undefined : retry)) .then((value) => { return value.result; }); } // Public methods to execute different kind of requests async get_component_negotiation(protocol, retry) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return new Components().try_from_json(await this.execute_raw_request(new TapoRequest().component_negotiation(), proto, retry)); } async get_device_info(protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().get_device_info(), proto); } async get_current_power(protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().get_current_power(), proto); } async get_energy_usage(protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().get_energy_usage(), proto); } async set_device_info(params, protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().set_device_info(params), proto); } async turn_onoff_device(params, protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().turn_onoff_device((typeof (params) == 'undefined') ? false : params), proto); } async set_color_device(params, protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().set_color_device((typeof (params) == 'undefined') ? "white" : params), proto); } async set_brightness_device(params, protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().set_brightness_device((typeof (params) == 'undefined') ? 100 : params), proto); } async get_child_device_list(params, protocol) { const proto = (typeof (protocol) == 'undefined' ? this._protocol_type : protocol); return await this.execute_raw_request(new TapoRequest().get_child_device_list((typeof (params) == 'undefined') ? 0 : params), proto); } list_methods() { return Object.keys(this.actions); } } exports.TapoClient = TapoClient; // Class for different TapoRequests class TapoRequest { // Constructor to initialize the class constructor(method, params) { this.method = (typeof (method) == 'undefined' ? undefined : method); this.params = (typeof (params) == 'undefined' ? undefined : params); } // Define the different methods handshake(params) { this.method = "handshake"; this.params = params; return this; } login(credential, v2) { this.method = "login_device"; this.params = (((typeof (v2) == 'undefined') || (!v2)) ? new LoginDeviceParams(credential.username, credential.password) : new LoginDeviceParamsV2(credential.username, credential.password)); return this; } cloud_login(credential) { this.method = "login"; this.params = { "appType": "Tapo_Android", "cloudPassword": credential.password, "cloudUserName": credential.username, "terminalUUID": (0, uuid_1.v4)() }; return this; } cloud_list_devices() { this.method = "getDeviceList"; this.params = undefined; return this; } secure_passthrough(params) { this.method = "securePassthrough"; this.params = params; return this; } get_device_info() { this.method = "get_device_info"; this.params = undefined; return this; } get_device_usage() { this.method = "get_device_usage"; this.params = undefined; return this; } get_energy_usage() { this.method = "get_energy_usage"; this.params = undefined; return this; } set_device_info(params) { this.method = "set_device_info"; this.params = params; return this; } turn_onoff_device(params) { this.method = "set_device_info"; this.params = { "device_on": params }; return this; } set_color_device(params) { this.method = "set_device_info"; this.params = new ColorParams().get_color(params); return this; } set_brightness_device(params) { this.method = "set_device_info"; this.params = { "brightness": Math.max(0, Math.min(params, 100)) }; return this; } get_current_power() { this.method = "get_current_power"; this.params = undefined; return this; } // public set_lighting_effect(effect: LightEffect) : TapoRequest { // return new TapoRequest("set_lighting_effect", effect); // } get_child_device_list(start_index) { this.method = "get_child_device_list"; this.params = new PaginationParams(start_index); return this; } get_child_device_component_list() { this.method = "get_child_device_component_list"; this.params = undefined; return this; } multiple_request(requests) { this.method = "multipleRequest"; this.params = undefined; return this; } control_child(device_id, request) { this.method = "control_child"; this.params = new ControlChildParams(device_id, request); return this; } // public get_child_event_logs(trigger_log_params: GetTriggerLogsParams) : TapoRequest { // return new TapoRequest("get_trigger_logs", trigger_log_params); // } get_temperature_humidity_records() { this.method = "get_temp_humidity_records"; this.params = undefined; return this; } component_negotiation() { this.method = "component_nego"; this.params = undefined; return this; } with_request_id(request_id) { this.requestID = request_id; return this; } with_request_time_millis(t) { this.request_time_millis = t; return this; } with_terminal_uuid(uuid) { this.terminal_uuid = uuid; return this; } get_params() { return this.params; } get_method() { return this.method; } __eq__(other) { if (!(other instanceof TapoRequest)) { return false; } else { return ((this.method === other.method) && (this.params === other.params)); } } } exports.TapoRequest = TapoRequest; // Class for different TapoResponses class TapoResponse { // Constructor of the class constructor(err, res, mg) { this.error_code = (typeof (err) == 'undefined' ? 0 : err); this.result = (typeof (res) == 'undefined' ? null : res); this.msg = (typeof (mg) == 'undefined' ? '' : mg); } // Methods defined in the class async try_from_json(json) { // Process the response from server to get the three fields this.error_code = (typeof (json["error_code"]) == 'undefined' ? 0 : json["error_code"]); this.result = (typeof (json["result"]) == 'undefined' ? null : json["result"]); this.msg = (typeof (json["msg"]) == 'undefined' ? 'No message' : json["msg"]); // Return this same class instance await this.check_Error(); return this; } // Private method to check functional errors async check_Error() { // Throw an error in case it is different from 0 switch (this.error_code) { case 0: break; case -1010: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_KEY_LENGTH]] + "Invalid public key length", null, ErrorCode.ERROR_FUNC_KEY_LENGTH, this.check_Error.name); case -1501: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_BAD_CREDENTIALS]] + "Invalid request or credentials", null, ErrorCode.ERROR_FUNC_BAD_CREDENTIALS, this.check_Error.name); case -1001: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_BAD_REQUEST]] + "Incorrect request", null, ErrorCode.ERROR_FUNC_BAD_REQUEST, this.check_Error.name); case -1002: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_BAD_REQUEST]] + "Incorrect request", null, ErrorCode.ERROR_FUNC_BAD_REQUEST, this.check_Error.name); case -1003: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_BAD_JSON]] + "JSON format error", null, ErrorCode.ERROR_FUNC_BAD_JSON, this.check_Error.name); case -20601: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_WRONG_EMAIL]] + "Incorrect email or password", null, ErrorCode.ERROR_FUNC_WRONG_EMAIL, this.check_Error.name); case -20675: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_CLOUD_TOKEN_EXPIRED]] + "Cloud token expired or invalid", null, ErrorCode.ERROR_FUNC_CLOUD_TOKEN_EXPIRED, this.check_Error.name); case 9999: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_DEV_TOKEN_EXPIRED]] + "Device token expired or invalid", null, ErrorCode.ERROR_FUNC_DEV_TOKEN_EXPIRED, this.check_Error.name); default: throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_UNEXPECTED]] + `Unexpected Error Code: ${this.error_code} (${this.msg})`, null, ErrorCode.ERROR_FUNC_UNEXPECTED, this.check_Error.name); } } } exports.TapoResponse = TapoResponse; // Class for different Request parameters class HandshakeParams { //Constructor to initialize the class constructor(key) { this.key = key; } } exports.HandshakeParams = HandshakeParams; class LoginDeviceParams { // Constructor to initialize the class constructor(user, pass) { this.username = Buffer.from((0, node_crypto_1.createHash)("sha1").update(user).digest('hex')).toString('base64'); this.password = Buffer.from(pass).toString('base64'); } } exports.LoginDeviceParams = LoginDeviceParams; class LoginDeviceParamsV2 { // Constructor to initialize the class constructor(user, pass) { this.username = Buffer.from((0, node_crypto_1.createHash)("sha1").update(user).digest('hex')).toString('base64'); this.password2 = Buffer.from((0, node_crypto_1.createHash)("sha1").update(pass).digest('hex')).toString('base64'); } } exports.LoginDeviceParamsV2 = LoginDeviceParamsV2; class SecurePassthroughParams { // Constructor constructor(request) { this.request = request; } } exports.SecurePassthroughParams = SecurePassthroughParams; class PaginationParams { // Constructor to initialize the class constructor(idx) { this.start_index = idx; } } exports.PaginationParams = PaginationParams; class MultipleRequestParams { } exports.MultipleRequestParams = MultipleRequestParams; class ControlChildParams { // Constructor to initialize the class constructor(device, request) { this.device_id = device; this.requestData = request; } } exports.ControlChildParams = ControlChildParams; class Components { // Constructor of the class constructor(list) { this.component_list = list; } // Methods of the class try_from_json(data) { const components = data["component_list"] || []; this.component_list = components.reduce((acc, c) => { acc[c["id"]] = c["ver_code"]; return acc; }, {}); return this; } } exports.Components = Components; class ColorParams { constructor() { // Define constants for preset colors this.preset = { blue: { hue: 240, saturation: 100, color_temp: 0 }, red: { hue: 0, saturation: 100, color_temp: 0 }, yellow: { hue: 60, saturation: 100, color_temp: 0 }, green: { hue: 120, saturation: 100, color_temp: 0 }, white: { color_temp: 4500 }, daylightwhite: { color_temp: 5500 }, warmwhite: { color_temp: 2700 } }; } HEXtoHSL(hex) { // Check valid hex color if (hex.toLowerCase() === '#000000') throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_HEX_COLOR]] + 'Cannot set light to black', null, ErrorCode.ERROR_FUNC_HEX_COLOR, this.HEXtoHSL.name); let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); // Return i fnull color if (result == null) return; // Get RGB components let r = parseInt(result[1], 16); let g = parseInt(result[2], 16); let b = parseInt(result[3], 16); r /= 255, g /= 255, b /= 255; // Get Hue, Saturation and Brightness components let max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s = 0, l = (max + min) / 2; if (max == min) { h = s = 0; // achromatic } else { let d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } s = s * 100; s = Math.round(s); l = l * 100; l = Math.round(l); h = Math.round(360 * h); // Return hue, saturation and brightness return { hue: h, saturation: s, brightness: l }; } temperature(temp) { let t = parseInt(temp.slice(0, -1)); if (t < 2500 || t > 6500) throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_TEMP_COLOR]] + 'Colour temperature should be between 2500K and 6500K.', null, ErrorCode.ERROR_FUNC_TEMP_COLOR, this.temperature.name); return { color_temp: t }; } get_color(color) { color = color.toLowerCase(); if (color.startsWith('#')) return this.HEXtoHSL(color); if (color.endsWith('k')) return this.temperature(color); if (Object.keys(this.preset).includes(color)) return this.preset[color] || ""; throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_FUNC_VALID_COLOR]] + 'Invalid Color', null, ErrorCode.ERROR_FUNC_VALID_COLOR, this.get_color.name); } } exports.ColorParams = ColorParams; // ********************************************* // *** TAPO CLASSES TO DEFINE TAPO PROTOCOL *** // ********************************************* // Class for abstract TapoProtocol class TapoProtocol { } exports.TapoProtocol = TapoProtocol; // Class for abstract TapoSession class TapoSession { } exports.TapoSession = TapoSession; // Class for abstract TapoChiper class TapoChiper { } exports.TapoChiper = TapoChiper; // Class to manage TapoErrors class TapoError { // Constructor of the class constructor(message, track, code, agent) { // Define first the non-recursive properties this.error_code = (typeof (code) == 'undefined') ? ErrorCode.GENERIC_ERROR : code; this.message = (typeof (message) == 'undefined' ? '' : message); this.agent = agent; // Define the recursive ones taking care of the non-recursive already defined and availability ot track and track.cause this.track = ((typeof (track) == 'undefined') || (track == null)) ? undefined : track; if ((typeof (track) == 'undefined') || (track == null) || (!(track instanceof TapoError))) { this.cause = { error_code: this.error_code, message: this.message, agent: this.agent }; } else if ((typeof (track.cause) == 'undefined') || (track.cause == null)) { this.cause = track.get_current_cause(); } else { this.cause = track.cause; } // Delete the empty objects on track and cause if ((typeof (this.track) != 'undefined') && (track != null)) { delete this.track.cause; } else { delete this.track; } } //Public method to transfer Axios to Tapo axios_to_tapo(axios) { if (axios.code == "ERR_BAD_REQUEST") { this.error_code = ErrorCode.ERROR_AXIOS_FORBID; } else { this.error_code = ErrorCode.ERROR_AXIOS_ERROR; } this.message = ErrorMsg[ErrorCode[this.error_code]] + axios.code + ' - ' + axios.message; this.agent = axios.name; this.cause = { message: this.message, error_code: this.error_code, agent: this.agent }; return this; } // Public method to get the Cause get_current_cause() { return { error_code: this.error_code, message: this.message, agent: this.agent }; } } exports.TapoError = TapoError; // ********************************************* // *** KLAP CLASSES TO DEFINE KLAP PROTOCOL *** // ********************************************* // Class for Klap Protocol definition class KlapProtocol extends TapoProtocol { // Constructor to initialize the class constructor(auth_credential, url, terminal_random, keep_alive, debug) { super(); this._base_url = url; this._host = (new URL(this._base_url)).hostname; this._auth_credential = auth_credential; this.local_auth_hash = this.generate_auth_hash(this._auth_credential); this._local_seed = null; this._jar = null; this._session = null; this._protocol_type = TapoProtocolType.KLAP; this._terminal_random = ((typeof (terminal_random) == 'undefined') ? false : terminal_random); this._request_id_generator = new SnowflakeId(1, 1); this._keep_alive = ((typeof (keep_alive) == 'undefined') ? true : keep_alive); this._debug = (typeof (debug) == 'undefined' ? false : debug); try { this._http_agent = (new URL(url).protocol == 'https:' ? new node_https_1.default.Agent({ keepAlive: true, timeout: AXIOS_TIMEOUT }) : new node_http_1.default.Agent({ keepAlive: true, timeout: AXIOS_TIMEOUT })); this._http_session = axios_1.default.create({ timeout: AXIOS_TIMEOUT, httpAgent: this._http_agent, validateStatus: () => { return true; }, proxy: false }); } catch (err) { new TapoError(ErrorMsg[ErrorCode[ErrorCode.GENERIC_ERROR]] + 'Agent error - ' + err, null, ErrorCode.GENERIC_ERROR, this.constructor.name); } // Set the properties to false to avoid any change or access once created if (!this._debug) { Object.defineProperty(this, '_auth_credential', { enumerable: false }); Object.defineProperty(this, '_http_session', { enumerable: false }); Object.defineProperty(this, '_http_agent', { enumerable: false }); Object.defineProperty(this, '_session', { enumerable: false }); Object.defineProperty(this, '_jar', { enumerable: false }); Object.defineProperty(this, 'local_auth_hash', { enumerable: false }); Object.defineProperty(this, '_local_seed', { enumerable: false }); Object.defineProperty(this, '_keep_alive', { enumerable: false }); Object.defineProperty(this, '_debug', { enumerable: false }); } } // Private method to generate Authentication hash generate_auth_hash(auth) { return this._sha256(Buffer.concat([ this._sha1(Buffer.from(new node_util_1.TextEncoder().encode(auth.username))), this._sha1(Buffer.from(new node_util_1.TextEncoder().encode(auth.password))) ])); } _sha1(payload) { return (0, node_crypto_1.createHash)("sha1").update(payload).digest(); } _sha256(payload) { return (0, node_crypto_1.createHash)("sha256").update(payload).digest(); } // Private method to post a session async session_post(url, data, cookies, params) { this._jar = null; const config = { url: url, method: 'post', data: data, headers: (typeof (cookies) == 'undefined' ? undefined : { 'Cookie': cookies }), params: (typeof (params) == 'undefined' ? undefined : params), responseType: 'arraybuffer' }; const response = await this._http_session.request(config) .then((value) => { // Check status of the answer if (value.status != 200) { this._http_agent.destroy(); throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_aSP_bAX_REQ_FORBID]] + 'URL error - ' + value.statusText + " - " + value.status, null, ErrorCode.ERROR_aSP_bAX_REQ_FORBID, this.session_post.name); } else { // Get the cookies if (value.headers.hasOwnProperty('set-cookie')) { const cookies = value.headers['set-cookie'][0].split(';'); for (let i = 0; i < cookies.length; i++) { if (cookies[i].includes('=')) { this._jar = { ...this._jar, [cookies[i].split('=')[0]]: cookies[i].split('=')[1] }; } } } // Return value return value; } }) .catch((error) => { this._http_agent.destroy(); throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_aSP_bAX_REQ_ERR]] + 'Session_post error - Axios', new TapoError().axios_to_tapo(error), ErrorCode.ERROR_aSP_bAX_REQ_ERR, this.session_post.name); }); // Build and return the array of response const empty = JSON.stringify({}); return [response, (((response.data != null) && (typeof (response.data) != 'undefined')) ? Buffer.from(response.data) : Buffer.from(empty))]; } // Private method Handshake to perform the full Handshake and get a valid KlapSession async perform_handshake(new_local_seed) { if (this._debug) console.debug("[KLAP] Starting handshake with " + this._host); const seeds = await this.perform_handshake1((typeof (new_local_seed) == 'undefined' ? undefined : new_local_seed)) .then(async (value) => { const [remote_seed, auth_hash] = value; const session = await this.perform_handshake2(this._local_seed, remote_seed, auth_hash) .then((value) => { if (this._debug) console.debug("[KLAP] Handshake with " + this._host + " complete"); return value; }); return session; }) .catch((error) => { this.close(false); throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_aPH_bPH1_HSK_ERROR]] + "Handshake error - " + error.message, error, ErrorCode.ERROR_aPH_bPH1_HSK_ERROR, this.perform_handshake.name); }); return seeds; } // Private method Handshake1 to get remote_seed and auth_hash async perform_handshake1(new_local_seed) { // Set local seed as random 16 bytes seed if not provided this._local_seed = (typeof (new_local_seed) == 'undefined' ? Buffer.from(node_crypto_1.webcrypto.getRandomValues(new Uint8Array(16))) : new_local_seed); // Prepare post parameters this._session = null; const url = this._base_url + "/handshake1"; // Send the request and check if servers answers with HTTP200 const seeds = await this.session_post(url, this._local_seed) .then((value) => { if (value[0].status != 200) { this._http_agent.destroy(); throw new TapoError(ErrorMsg[ErrorCode[ErrorCode.ERROR_aPH1_bSP_HSK_FORBID]] + "Device failed to respond to handshake1 with " + value[0].status, null, ErrorCode.ERROR_aPH1_bSP_HSK_FORBID, this.perform_handshake1.name); } else { // Define a new KlapSession object for the session and retrieve parameters - Session_id, Timeout, remote_seed and server_hash const terminal_uuid = (this._terminal_random ? node_crypto_1.webcrypto.randomUUID() : (0, uuid_1.v4)()); this._session = new KlapSession(this._jar[TP_SESSION_COOKIE_NAME], parseInt(this._jar["TIMEOUT"], 10), false, terminal_uuid); const remote_seed = value[1].subarray(0, 16); const server_hash = value[1].subarray(16); // Information for debugging if (this._debug) console.debug("Handshake1 posted " + new Date().getTime() + ". Host is " + this._host + ", Session cookie is " + this._session.session_id + ", Response status is " + value[0].status + ", Request was " + this.local_auth_hash.toString('hex')); if (this._debug) console.debug("Server remote_seed is " + remote_seed.toString('hex') + ", server hash is " + server_hash.toString('hex')); // Build the local seed auth hash const local_seed_auth_hash = this._sha256(Buffer.from(new Uint8Array([...this._local_seed, ...remote_seed, ...this.local_auth_hash]))); // Check the locally generated hash with the server one if (Buffer.compare(local_seed_auth_hash, server_hash) == 0) { if (this._debug) console.debug("Handshake1 hashes matched"); return [remote_seed, this.local_auth_hash]; } else { // Check blank auth if (this._debug) console.debug("Expected " + local_seed_auth_hash.toString('hex') + " got " + server_hash.toString('hex') + " in handshake1. Checking if blank auth is a match"); const blank_auth = new AuthCredential(); const blank_auth_hash = this.generate_auth_hash(blank_auth); const blank_seed_auth_hash = this._sha256(Buffer.from(new Uint8Array([...this._local_seed, ...remote_seed, ...blank_auth_hash]))); if (Buffer.compare(blank_seed_auth_hash, server_hash) == 0) { if (this._debug) console.debug("Server response doesn't match our expected hash on ip " + this._host + " but an authentication with blank credentials matched"); return [remote_seed, blank_auth_hash]; } else { // Check kasa setup auth const kasa_setup_auth = new AuthCredential(TP_TEST_USER, TP_TEST_PASSWORD); const kasa_setup_auth_hash = this.generate_auth_hash(kasa_setup_auth); const kasa_setup_seed_auth_hash = this._sha256(Buffer.from(new Uint8Array([...this._local_seed, ...remote_seed, .