import axios from 'axios'; import { TapoLightState } from '../lightstate/tapo-lightstate'; import { throwErrorIfFound } from '../utils/helpers'; import TpLinkCipher from '../utils/tplink-cypher'; import crypto from 'crypto'; import http from 'http'; import KlapCipher from '../utils/klap-cipher'; import SecurePassthroughTransport from '../utils/secure-passthrough-transport'; export class TapoApi { protected readonly terminalUUID: string; protected loginToken?: string; public _baseUrl = 'https://eu-wap.tplinkcloud.com'; private static readonly REGIONAL_ENDPOINTS = ['https://eu-wap.tplinkcloud.com', 'https://us-wap.tplinkcloud.com', 'https://wap.tplinkcloud.com', 'https://ap-wap.tplinkcloud.com']; private static readonly TP_TEST_USER = 'test@tp-link.net'; private static readonly TP_TEST_PASSWORD = 'test'; private session?: DeviceSession; private securePassthrough?: SecurePassthroughTransport; private protocolType: 'KLAP' | 'SecurePassthrough' | null = null; private axiosInstance; private _config = { rawEmail: '', rawPassword: '', email: '', password: '', authToken: null, timeout: 10000, httpTimeout: 4000, }; private _token: string = ''; private _devices: Map = new Map(); constructor(config?: { email?: string; password?: string; token?: string; timeout?: number; httpTimeout?: number }) { this.axiosInstance = axios.create(); this.axiosInstance.defaults.timeout = config?.httpTimeout || 4000; this._config = { ...this._config, ...config }; // Store raw credentials this._config.rawEmail = this._config.email || ''; this._config.rawPassword = this._config.password || ''; // Only encode if email/password are provided if (this._config.email) { this._config.email = TpLinkCipher.toBase64(TpLinkCipher.encodeUsername(this._config.email)); } if (this._config.password) { this._config.password = TpLinkCipher.toBase64(this._config.password); } // Set token if provided if (config?.token) { this._token = config.token; console.debug('[Tapo] Constructor: Using provided token'); } this.terminalUUID = crypto.randomUUID(); } auth = async ({ email, password }: { email: string; password: string }): Promise => { let lastError: any; // Try all regional endpoints for (const endpoint of TapoApi.REGIONAL_ENDPOINTS) { try { console.debug(`[Tapo] Trying endpoint: ${endpoint}`); // Try modern Tapo cloud API format first const loginRequest = { appType: 'TP-Link_Tapo_Android', appVersion: '3.7.113', cloudPassword: password, // Use raw password, not base64 encoded cloudUserName: email, // Use raw email, not encoded platform: 'Android 12', refreshTokenNeeded: false, terminalMeta: '1', terminalName: 'Google sdk_gphone64_x86_64', terminalUUID: this.terminalUUID, }; console.debug('[Tapo] Modern API login request:', { url: endpoint + '/api/v2/account/login', appType: loginRequest.appType, cloudUserName: loginRequest.cloudUserName, terminalUUID: loginRequest.terminalUUID, }); let response = await axios({ method: 'post', url: endpoint + '/api/v2/account/login', data: loginRequest, headers: { 'Content-Type': 'application/json', }, timeout: this._config.timeout, }); console.debug('[Tapo] Modern API response status:', response.status); // Check if we have a token in the response if (response.data?.token) { this._token = response.data.token; this._baseUrl = endpoint; // Update base URL to working endpoint console.debug(`[Tapo] Modern API login successful with ${endpoint}, token received`); return this._token; } // If modern API failed, try legacy format as fallback console.debug('[Tapo] Modern API failed, trying legacy format on', endpoint); const encodedEmail = TpLinkCipher.toBase64(TpLinkCipher.encodeUsername(email)); const encodedPassword = TpLinkCipher.toBase64(password); const legacyLoginRequest = { method: 'login', params: { appType: 'Tapo_Ios', cloudPassword: encodedPassword, cloudUserName: encodedEmail, terminalUUID: this.terminalUUID, }, }; response = await axios({ method: 'post', url: endpoint + '/', data: legacyLoginRequest, timeout: this._config.timeout, }); console.debug('[Tapo] Legacy API response status:', response.status); throwErrorIfFound(response.data); this._token = response.data.result.token; this._baseUrl = endpoint; // Update base URL to working endpoint console.debug(`[Tapo] Legacy API login successful with ${endpoint}, token received`); return this._token; } catch (error: any) { console.debug(`[Tapo] Failed with endpoint ${endpoint}:`, error.message); lastError = error; // If it's an account not found error, continue trying other regions // If it's a different error (like invalid credentials), that would apply to all regions if (error.code !== -20600) { console.debug('[Tapo] Non-regional error, stopping endpoint iteration:', error.code); break; } } } console.error('[Tapo] Login failed with all regional endpoints. Last error:', lastError?.code); throw lastError; }; setup = async (devices: Array<{ id: string; host: string }>, logger?: any): Promise<{ responses: PromiseSettledResult[]; devices: Map }> => { const promises = devices.map(async (device) => { const deviceSession = await this.handshake(device.host, undefined, false, logger); this._devices.set(device.id, deviceSession!); return; }); let responses: PromiseSettledResult[] = []; try { responses = await Promise.allSettled(promises); } catch (err) { console.error('tapo setup err: ', err); } return { responses, devices: this._devices }; }; // Change state using lightstate sendState = async (config: { device: { id: string }; state: TapoLightState; fetchConfig?: { shouldWait: boolean } }): Promise => { const devSession = this._devices.get(config.device.id); if (!devSession) { return Promise.resolve(false); } await this.handshake(devSession?.ip, devSession, false); const values = config.state.getValues(); const deviceRequest = { method: 'set_device_info', params: values, }; if (this.protocolType === 'KLAP' && devSession.cipher) { const requestData = devSession.cipher.encrypt(JSON.stringify(deviceRequest)); const response = await this.sessionPost(devSession.ip, '/request', requestData.encrypted, 'arraybuffer', devSession.Cookie, { seq: requestData.seq.toString(), }); if (response.status !== 200) { throw new Error('[KLAP] Request failed'); } } else if (this.protocolType === 'SecurePassthrough' && this.securePassthrough) { const encrypted = this.securePassthrough.encrypt(deviceRequest); const response = await this.sessionPost(devSession.ip, '/app', Buffer.from(JSON.stringify(encrypted)), 'json'); if (response.status !== 200) { throw new Error('[SecurePassthrough] Request failed'); } const decrypted = this.securePassthrough.decrypt(response.data.result.response); throwErrorIfFound(decrypted); } else { throw new Error('No valid protocol established'); } return true; }; sendPower = async (config: { device: { id: string }; power: boolean }) => { return await this.sendState({ device: config.device, state: new TapoLightState({ on: config.power }) }); }; getDeviceInfo = async (deviceId: string): Promise => { const devSession = this._devices.get(deviceId); if (!devSession) { throw new Error('Device session not found'); } await this.handshake(devSession.ip, devSession, false); const deviceRequest = { method: 'get_device_info', }; if (this.protocolType === 'KLAP' && devSession.cipher) { const requestData = devSession.cipher.encrypt(JSON.stringify(deviceRequest)); const response = await this.sessionPost(devSession.ip, '/request', requestData.encrypted, 'arraybuffer', devSession.Cookie, { seq: requestData.seq.toString(), }); if (response.status === 200) { const decrypted = devSession.cipher.decrypt(Buffer.from(response.data)); return JSON.parse(decrypted); } } else if (this.protocolType === 'SecurePassthrough' && this.securePassthrough) { const encrypted = this.securePassthrough.encrypt(deviceRequest); const response = await this.sessionPost(devSession.ip, '/app', Buffer.from(JSON.stringify(encrypted)), 'json'); if (response.status === 200) { return this.securePassthrough.decrypt(response.data.result.response); } } throw new Error('Failed to get device info'); }; getEnergyUsage = async (deviceId: string): Promise => { const devSession = this._devices.get(deviceId); if (!devSession) { throw new Error('Device session not found'); } await this.handshake(devSession.ip, devSession, false); const deviceRequest = { method: 'get_energy_usage', }; if (this.protocolType === 'KLAP' && devSession.cipher) { const requestData = devSession.cipher.encrypt(JSON.stringify(deviceRequest)); const response = await this.sessionPost(devSession.ip, '/request', requestData.encrypted, 'arraybuffer', devSession.Cookie, { seq: requestData.seq.toString(), }); if (response.status === 200) { const decrypted = devSession.cipher.decrypt(Buffer.from(response.data)); return JSON.parse(decrypted); } } else if (this.protocolType === 'SecurePassthrough' && this.securePassthrough) { const encrypted = this.securePassthrough.encrypt(deviceRequest); const response = await this.sessionPost(devSession.ip, '/app', Buffer.from(JSON.stringify(encrypted)), 'json'); if (response.status === 200) { return this.securePassthrough.decrypt(response.data.result.response); } } throw new Error('Failed to get energy usage'); }; // Helpers private async sessionPost(deviceIp: string, path: string, payload: Buffer, responseType: any, cookie?: string, params?: Record) { const headers: Record = { Accept: '*/*', 'Content-Type': 'application/octet-stream', }; if (cookie) { // Check if in electron if (process?.versions?.electron) { headers.BypassCookie = cookie; } else { headers.Cookie = cookie; } } return axios.post(`http://${deviceIp}/app${path}`, payload, { responseType: responseType, params: params, headers, httpAgent: new http.Agent({ keepAlive: false, }), }); } public needsNewHandshake(devSession) { if (!devSession) { return true; } if (!devSession.cipher) { return true; } if (devSession.IsExpired) { return true; } if (!devSession.Cookie) { return true; } return false; } private async handshake(deviceIp: string, devSession?: DeviceSession, force = false, logger?: any) { if (!this.needsNewHandshake(devSession) && !force) { return devSession; } // Try KLAP protocol first try { logger?.debug('[KLAP] Attempting KLAP handshake'); const { localSeed, remoteSeed, authHash, deviceSession } = await this.firstHandshake(deviceIp, undefined, logger); const result = await this.secondHandshake(deviceSession, deviceIp, localSeed, remoteSeed, authHash, logger); if (result) { this.protocolType = 'KLAP'; logger?.debug('[KLAP] Successfully established KLAP connection'); return result; } } catch (error) { logger?.debug('[KLAP] KLAP handshake failed, trying SecurePassthrough:', error); } // Fallback to SecurePassthrough protocol try { logger?.debug('[SecurePassthrough] Attempting SecurePassthrough handshake'); this.securePassthrough = new SecurePassthroughTransport(); const sessionPostFn = (path: string, payload: any, responseType: string) => this.sessionPost(deviceIp, path, Buffer.from(JSON.stringify(payload)), responseType); const success = await this.securePassthrough.handshake(sessionPostFn); if (success) { this.protocolType = 'SecurePassthrough'; logger?.debug('[SecurePassthrough] Successfully established SecurePassthrough connection'); // Create a minimal session for SecurePassthrough return new DeviceSession('3600', deviceIp, 'securepassthrough-session'); } } catch (error) { logger?.error('[SecurePassthrough] SecurePassthrough handshake failed:', error); } throw new Error('Failed to establish connection with both KLAP and SecurePassthrough protocols'); } private async firstHandshake(deviceIp: string, seed?: Buffer, logger?: any) { const localSeed = seed ? seed : crypto.randomBytes(16); const handshake1Result = await this.sessionPost(deviceIp, '/handshake1', localSeed, 'arraybuffer'); logger?.debug('handshake1Result: ', handshake1Result); if (handshake1Result.status !== 200) { throw new Error('Handshake1 failed'); } if (handshake1Result.headers['content-length'] !== '48') { throw new Error('Handshake1 failed due to invalid content length'); } const cookie = handshake1Result.headers['bypass-cookie'] || handshake1Result.headers['set-cookie']?.[0]; const data = Buffer.from(new Uint8Array(handshake1Result.data)); const [cookieValue, timeout] = cookie!.split(';'); const timeoutValue = timeout.split('=').pop(); const deviceSession = new DeviceSession(timeoutValue!, deviceIp, cookieValue!); const remoteSeed: Buffer = data.subarray(0, 16); const serverHash: Buffer = data.subarray(16); logger?.debug('[KLAP] First handshake decoded successfully:\nRemote Seed:', remoteSeed.toString('hex'), '\nServer Hash:', serverHash.toString('hex'), '\nCookie:', cookieValue); const localHash = this.hashAuth(this._config.rawEmail, this._config.rawPassword); const localAuthHash = this.sha256(Buffer.concat([localSeed, remoteSeed, localHash])); if (Buffer.compare(localAuthHash, serverHash) === 0) { logger?.debug('[KLAP] Local auth hash matches server hash'); return { localSeed, remoteSeed, authHash: localHash, deviceSession, }; } const emptyHash = this.sha256(Buffer.concat([localSeed, remoteSeed, this.hashAuth('', '')])); if (Buffer.compare(emptyHash, serverHash) === 0) { logger?.debug('[KLAP] [WARN] Empty auth hash matches server hash'); return { localSeed, remoteSeed, authHash: emptyHash, deviceSession, }; } const testHash = this.sha256(Buffer.concat([localSeed, remoteSeed, this.hashAuth(TapoApi.TP_TEST_USER, TapoApi.TP_TEST_PASSWORD)])); if (Buffer.compare(testHash, serverHash) === 0) { logger?.debug('[KLAP] [WARN] Test auth hash matches server hash'); return { localSeed, remoteSeed, authHash: testHash, deviceSession, }; } throw new Error('Failed to verify server hash'); } private async secondHandshake(devSession: DeviceSession, deviceIp, localSeed: Buffer, remoteSeed: Buffer, authHash: Buffer, logger?: any) { const localAuthHash = this.sha256(Buffer.concat([remoteSeed, localSeed, authHash])); try { const handshake2Result = await this.sessionPost(deviceIp, '/handshake2', localAuthHash, 'text', devSession!.Cookie); if (handshake2Result.status === 200) { logger?.debug('[KLAP] Second handshake successful'); const deviceSession = devSession!.completeHandshake(deviceIp, new KlapCipher(localSeed, remoteSeed, authHash)); return deviceSession; } logger.warn('[KLAP] Second handshake failed', handshake2Result.data); } catch (e: any) { logger.error('[KLAP] Second handshake failed:', e.response.data || e.message); } return undefined; } private sha256(data: Buffer) { return crypto.createHash('sha256').update(data).digest(); } private sha1(data: Buffer) { return crypto.createHash('sha1').update(data).digest(); } private hashAuth(email: string, password: string) { return this.sha256(Buffer.concat([this.sha1(Buffer.from(email.normalize('NFKC'))), this.sha1(Buffer.from(password.normalize('NFKC')))])); } } class DeviceSession { public readonly handshakeCompleted: boolean = false; private readonly expireAt: Date; private readonly rawTimeout: string; constructor(timeout: string, public ip: string, private readonly cookie: string, public readonly cipher?: KlapCipher) { this.rawTimeout = timeout; this.expireAt = new Date(Date.now() + parseInt(timeout) * 1000); if (cipher) { this.handshakeCompleted = true; } } public get IsExpired() { return this.expireAt.getTime() - Date.now() <= 40 * 1000; } public get Cookie() { return this.cookie; } public completeHandshake(ip: string, cipher: KlapCipher) { return new DeviceSession(this.rawTimeout, ip, this.cookie, cipher); } }