/** * Tapo Client Module * * Handles Tapo device discovery and polling for integration with Shelly server. * Implements the KLAP protocol for authentication and encrypted communication. */ import dgram from 'dgram'; import crypto from 'crypto'; import http from 'http'; import net from 'net'; import { Buffer } from 'buffer'; // Tapo discovery port const TAPO_DISCOVERY_PORT = 20002; /** * CRC32 implementation for discovery packets */ function crc32(data) { let crc = 0xFFFFFFFF; const table = getCrc32Table(); for (let i = 0; i < data.length; i++) { crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xFF]; } return (crc ^ 0xFFFFFFFF) >>> 0; } let crc32Table = null; function getCrc32Table() { if (crc32Table) return crc32Table; crc32Table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let j = 0; j < 8; j++) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } crc32Table[i] = c; } return crc32Table; } /** * Generate RSA key pair for discovery */ function generateRsaKeyPair() { const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 1024, publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } }); return { publicKey, privateKey }; } /** * Generate discovery packet using AES discovery protocol */ function generateDiscoveryPacket() { const { publicKey } = generateRsaKeyPair(); const keyPayload = JSON.stringify({ params: { rsa_key: publicKey } }); const keyPayloadBytes = Buffer.from(keyPayload, 'utf8'); const secret = crypto.randomBytes(4); const deviceSerial = secret.readUInt32BE(0); const header = Buffer.alloc(16); let offset = 0; header.writeUInt8(2, offset++); // version header.writeUInt8(0, offset++); // msg_type header.writeUInt16BE(1, offset); // op_code offset += 2; header.writeUInt16BE(keyPayloadBytes.length, offset); // msg_size offset += 2; header.writeUInt8(17, offset++); // flags header.writeUInt8(0, offset++); // padding header.writeUInt32BE(deviceSerial, offset); // device_serial offset += 4; header.writeInt32BE(0x5A6B7C8D, offset); // initial_crc (placeholder) const query = Buffer.concat([header, keyPayloadBytes]); const crcValue = crc32(query); query.writeUInt32BE(crcValue, 12); return query; } /** * KLAP Cipher for encrypted communication */ class KlapCipher { constructor(localSeed, remoteSeed, authHash) { const localHash = Buffer.concat([localSeed, remoteSeed, authHash]); this.key = this._keyDerive(localHash); const { iv, seq } = this._ivDerive(localHash); this.iv = iv; this.seq = seq; this.sig = this._sigDerive(localHash); } static sha1(data) { return crypto.createHash('sha1').update(data).digest(); } static sha256(data) { return crypto.createHash('sha256').update(data).digest(); } _keyDerive(localHash) { const data = Buffer.concat([Buffer.from('lsk'), localHash]); return KlapCipher.sha256(data).subarray(0, 16); } _ivDerive(localHash) { const data = Buffer.concat([Buffer.from('iv'), localHash]); const hash = KlapCipher.sha256(data); const iv = hash.subarray(0, 12); const seq = hash.readInt32BE(hash.length - 4); return { iv, seq }; } _sigDerive(localHash) { const data = Buffer.concat([Buffer.from('ldk'), localHash]); return KlapCipher.sha256(data).subarray(0, 28); } _ivSeq(seq) { const seqBuf = Buffer.alloc(4); seqBuf.writeInt32BE(seq); return Buffer.concat([this.iv, seqBuf]); } encrypt(data) { this.seq++; const ivSeq = this._ivSeq(this.seq); const cipher = crypto.createCipheriv('aes-128-cbc', this.key, ivSeq); const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]); const sigData = Buffer.concat([ this.sig, Buffer.from([(this.seq >> 24) & 0xff, (this.seq >> 16) & 0xff, (this.seq >> 8) & 0xff, this.seq & 0xff]), encrypted ]); const signature = KlapCipher.sha256(sigData); return { payload: Buffer.concat([signature, encrypted]), seq: this.seq }; } decrypt(seq, data) { const ivSeq = this._ivSeq(seq); const ciphertext = data.subarray(32); // Skip 32-byte signature const decipher = crypto.createDecipheriv('aes-128-cbc', this.key, ivSeq); const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return decrypted.toString('utf8'); } } /** * Tapo Device Client */ class TapoDevice { constructor(ip, username, password) { this.ip = ip; this.username = username; this.password = password; this.baseUrl = `http://${ip}/app`; this.cookie = null; this.cipher = null; this.deviceInfo = null; } // HTTP request helper using native http module (fetch has issues with binary bodies) async _request(path, body, cookie = null) { return new Promise((resolve, reject) => { const options = { hostname: this.ip, port: 80, path: `/app${path}`, method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': body.length }, timeout: 5000 }; if (cookie) { options.headers['Cookie'] = cookie; } const req = http.request(options, (res) => { const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('end', () => { resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }); }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error(`Request timeout to ${this.ip}`)); }); req.write(body); req.end(); }); } async handshake() { // IMPORTANT: Tapo requires email to be lowercase for the hash const normalizedUsername = this.username.toLowerCase().trim(); const authHash = KlapCipher.sha256(Buffer.concat([ KlapCipher.sha1(Buffer.from(normalizedUsername)), KlapCipher.sha1(Buffer.from(this.password)) ])); const localSeed = crypto.randomBytes(16); // Handshake1 - send local seed, receive remote seed + server hash const hs1Response = await this._request('/handshake1', localSeed); if (hs1Response.status !== 200) { if (hs1Response.status === 403) { throw new Error('Forbidden - Enable Third-Party Compatibility in Tapo app'); } throw new Error(`Handshake1 failed: ${hs1Response.status}`); } // Get session cookie const cookies = hs1Response.headers['set-cookie']; if (cookies) { const cookieStr = Array.isArray(cookies) ? cookies[0] : cookies; const match = cookieStr.match(/TP_SESSIONID=([^;]+)/); if (match) { this.cookie = `TP_SESSIONID=${match[1]}`; } } const hs1Body = hs1Response.body; if (hs1Body.length < 48) { throw new Error(`Handshake1 response too short: ${hs1Body.length} bytes`); } const remoteSeed = hs1Body.subarray(0, 16); const serverHash = hs1Body.subarray(16, 48); // Verify server hash const localHash = KlapCipher.sha256(Buffer.concat([localSeed, remoteSeed, authHash])); if (!localHash.equals(serverHash)) { throw new Error('Authentication failed - check credentials'); } // Handshake2 - send our hash to confirm const hs2Payload = KlapCipher.sha256(Buffer.concat([remoteSeed, localSeed, authHash])); const hs2Response = await this._request('/handshake2', hs2Payload, this.cookie); if (hs2Response.status !== 200) { throw new Error(`Handshake2 failed: ${hs2Response.status}`); } this.cipher = new KlapCipher(localSeed, remoteSeed, authHash); } async request(method, params = {}) { if (!this.cipher) { await this.handshake(); } const requestData = JSON.stringify({ method, params }); const { payload, seq } = this.cipher.encrypt(requestData); try { const response = await this._request(`/request?seq=${seq}`, payload, this.cookie); if (response.status === 401 || response.status === 403) { // Session expired, try re-handshake this.cipher = null; await this.handshake(); return this.request(method, params); } if (response.status !== 200) { throw new Error(`Request failed: ${response.status}`); } const decrypted = this.cipher.decrypt(seq, response.body); return JSON.parse(decrypted); } catch (e) { throw e; } } async getDeviceInfo() { const response = await this.request('get_device_info'); if (response.error_code === 0) { this.deviceInfo = response.result; return response.result; } throw new Error(`get_device_info failed: ${response.error_code}`); } async getChildDeviceList() { const response = await this.request('get_child_device_list'); if (response.error_code === 0) { return response.result.child_device_list || []; } throw new Error(`get_child_device_list failed: ${response.error_code}`); } async getCurrentPower() { const response = await this.request('get_current_power'); if (response.error_code === 0) { return response.result; } throw new Error(`get_current_power failed: ${response.error_code}`); } async getEnergyUsage() { const response = await this.request('get_energy_usage'); if (response.error_code === 0) { return response.result; } throw new Error(`get_energy_usage failed: ${response.error_code}`); } async turnOn() { const response = await this.request('set_device_info', { device_on: true }); return response.error_code === 0; } async turnOff() { const response = await this.request('set_device_info', { device_on: false }); return response.error_code === 0; } } /** * Decode base64 nickname to readable string */ function decodeNickname(base64) { if (!base64) return null; try { return Buffer.from(base64, 'base64').toString('utf8'); } catch (e) { return base64; } } /** * Normalize MAC address format (remove -, :, and uppercase) */ function normalizeMac(mac) { if (!mac) return null; return mac.replace(/[-:]/g, '').toUpperCase(); } /** * Tapo Manager - handles discovery and polling */ class TapoManager { constructor(username, password, options = {}) { this.username = username; this.password = password; this.discoveryInterval = options.discoveryInterval || 5 * 60 * 1000; // 5 minutes this.pollInterval = options.pollInterval || 10 * 1000; // 10 seconds this.broadcastAddr = options.broadcastAddr || '255.255.255.255'; this.discoveryTimeout = options.discoveryTimeout || 10; // 10 seconds this.devices = new Map(); // IP -> device info this.deviceClients = new Map(); // IP -> TapoDevice instance this.childDevices = new Map(); // deviceId -> { hub, child info } this.discoveryTimer = null; this.pollTimer = null; // Callbacks - updated signatures to include more info this.onDeviceDiscovered = null; // callback(deviceInfo) - includes mac, nickname, model this.onDeviceStateChange = null; // callback(mac, component, field, type, value, deviceInfo) this.onChildDeviceDiscovered = null; // callback(hubInfo, childInfo) - includes mac, nickname } async start() { console.log('[Tapo] Starting Tapo manager...'); // Initial discovery await this.discoverDevices(); // Start discovery interval this.discoveryTimer = setInterval(() => { this.discoverDevices().catch(e => console.error('[Tapo] Discovery error:', e.message)); }, this.discoveryInterval); // Start polling interval this.pollTimer = setInterval(() => { this.pollDevices().catch(e => console.error('[Tapo] Polling error:', e.message)); }, this.pollInterval); console.log(`[Tapo] Manager started. Discovery every ${this.discoveryInterval / 1000}s, polling every ${this.pollInterval / 1000}s`); } stop() { if (this.discoveryTimer) { clearInterval(this.discoveryTimer); this.discoveryTimer = null; } if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } console.log('[Tapo] Manager stopped'); } async discoverDevices() { console.log('[Tapo] Starting device discovery...'); return new Promise((resolve) => { const foundDevices = new Map(); const socket = dgram.createSocket('udp4'); socket.on('error', (err) => { console.error('[Tapo] Discovery socket error:', err.message); socket.close(); resolve(foundDevices); }); socket.on('message', (msg, rinfo) => { if (!foundDevices.has(rinfo.address) && msg.length > 16) { try { const jsonStr = msg.subarray(16).toString('utf8'); const jsonEnd = jsonStr.lastIndexOf('}') + 1; if (jsonEnd > 0) { const data = JSON.parse(jsonStr.slice(0, jsonEnd)); if (data.result) { foundDevices.set(rinfo.address, { ip: rinfo.address, deviceId: data.result.device_id, deviceType: data.result.device_type, deviceModel: data.result.device_model, owner: data.result.owner, encryptScheme: data.result.mgt_encrypt_schm }); console.log(`[Tapo] Discovered: ${rinfo.address} - ${data.result.device_model} (${data.result.device_type})`); } } } catch (e) { // Ignore parse errors } } }); socket.bind(() => { socket.setBroadcast(true); const sendDiscovery = () => { try { const packet = generateDiscoveryPacket(); socket.send(packet, 0, packet.length, TAPO_DISCOVERY_PORT, this.broadcastAddr); } catch (e) { console.error('[Tapo] Error generating discovery packet:', e.message); } }; sendDiscovery(); const interval = setInterval(sendDiscovery, 3000); setTimeout(async () => { clearInterval(interval); socket.close(); // Process discovered devices for (const [ip, info] of foundDevices) { if (!this.devices.has(ip)) { this.devices.set(ip, info); if (this.onDeviceDiscovered) { this.onDeviceDiscovered(info); } } // Create client for supported device types const deviceType = info.deviceType || ''; if ((deviceType.includes('TAPOPLUG') || deviceType.includes('TAPOHUB')) && !this.deviceClients.has(ip)) { const client = new TapoDevice(ip, this.username, this.password); this.deviceClients.set(ip, client); } } // Discover H100 hub children await this.discoverHubChildren(); console.log(`[Tapo] Discovery complete. ${this.devices.size} devices, ${this.childDevices.size} child devices`); resolve(foundDevices); }, this.discoveryTimeout * 1000); }); }); } async discoverHubChildren() { for (const [ip, info] of this.devices) { if (info.deviceType && info.deviceType.includes('TAPOHUB')) { const client = this.deviceClients.get(ip); if (client) { try { const children = await client.getChildDeviceList(); for (const child of children) { const childKey = child.device_id; if (!this.childDevices.has(childKey)) { this.childDevices.set(childKey, { hubIp: ip, hubDeviceId: info.deviceId, ...child }); console.log(`[Tapo] Hub child discovered: ${child.nickname || child.device_id} (${child.model})`); if (this.onChildDeviceDiscovered) { this.onChildDeviceDiscovered(info, child); } } else { // Update existing child info this.childDevices.set(childKey, { ...this.childDevices.get(childKey), ...child }); } } } catch (e) { console.error(`[Tapo] Error getting children from hub ${ip}:`, e.message); } } } } } async pollDevices() { const pollPromises = []; for (const [ip, info] of this.devices) { const deviceType = info.deviceType || ''; // Poll P100/P115 plugs if (deviceType.includes('TAPOPLUG')) { pollPromises.push(this._pollPlug(ip, info)); } // Poll H100 hub children if (deviceType.includes('TAPOHUB')) { pollPromises.push(this._pollHub(ip, info)); } // Poll cameras for online status (they may use different protocol) if (deviceType.includes('IPCAMERA')) { pollPromises.push(this._pollCamera(ip, info)); } } await Promise.allSettled(pollPromises); } async _pollCamera(ip, info) { // Cameras don't use KLAP typically, but we can check TCP connectivity // to see if they're online return new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(3000); socket.on('connect', async () => { socket.destroy(); // Camera is reachable - try to get device info if possible let client = this.deviceClients.get(ip); if (!client) { client = new TapoDevice(ip, this.username, this.password); this.deviceClients.set(ip, client); } try { const deviceInfo = await client.getDeviceInfo(); const mac = normalizeMac(deviceInfo.mac) || info.deviceId; const nickname = decodeNickname(deviceInfo.nickname); info.mac = mac; info.nickname = nickname; info.model = deviceInfo.model; info.online = true; info.lastSeen = new Date().toISOString(); if (this.onDeviceStateChange) { this.onDeviceStateChange(mac, 'system', 'online', 'boolean', true, deviceInfo); } } catch (e) { // KLAP failed but TCP connected - camera is online but uses different protocol // Use discovery deviceId as fallback const mac = info.mac || info.deviceId; if (info.online !== true) { info.online = true; if (this.onDeviceStateChange) { this.onDeviceStateChange(mac, 'system', 'online', 'boolean', true, null); } } } resolve(); }); socket.on('timeout', () => { socket.destroy(); const mac = info.mac || info.deviceId; if (info.online !== false) { info.online = false; if (this.onDeviceStateChange) { this.onDeviceStateChange(mac, 'system', 'online', 'boolean', false, null); } } resolve(); }); socket.on('error', () => { socket.destroy(); const mac = info.mac || info.deviceId; if (info.online !== false) { info.online = false; if (this.onDeviceStateChange) { this.onDeviceStateChange(mac, 'system', 'online', 'boolean', false, null); } } resolve(); }); // Try port 554 (RTSP) as cameras typically expose this socket.connect(554, ip); }); } async _pollPlug(ip, info) { let client = this.deviceClients.get(ip); if (!client) { client = new TapoDevice(ip, this.username, this.password); this.deviceClients.set(ip, client); } try { const deviceInfo = await client.getDeviceInfo(); // Use real MAC from device (normalized) - this is the key change const mac = normalizeMac(deviceInfo.mac) || info.deviceId; const nickname = decodeNickname(deviceInfo.nickname); // Update stored info with real MAC and nickname info.mac = mac; info.nickname = nickname; info.model = deviceInfo.model; info.lastState = deviceInfo; info.lastSeen = new Date().toISOString(); info.online = true; // Emit state changes using real MAC if (this.onDeviceStateChange) { // Online status this.onDeviceStateChange(mac, 'system', 'online', 'boolean', true, deviceInfo); // Switch state if (typeof deviceInfo.device_on !== 'undefined') { this.onDeviceStateChange(mac, 'switch:0', 'output', 'boolean', deviceInfo.device_on, deviceInfo); } // On time (how long has it been on) // if (typeof deviceInfo.on_time !== 'undefined') { // this.onDeviceStateChange(mac, 'switch:0', 'on_time', 'range', deviceInfo.on_time, deviceInfo); // } // Signal strength if (typeof deviceInfo.rssi !== 'undefined') { this.onDeviceStateChange(mac, 'wifi', 'rssi', 'range', deviceInfo.rssi, deviceInfo); } // Overheated status (safety) if (typeof deviceInfo.overheated !== 'undefined') { this.onDeviceStateChange(mac, 'system', 'overheated', 'boolean', deviceInfo.overheated, deviceInfo); } // P115 specific: power protection status if (typeof deviceInfo.power_protection_status !== 'undefined') { this.onDeviceStateChange(mac, 'power', 'protection_status', 'enum', deviceInfo.power_protection_status, deviceInfo); } // Power Monitoring for P110/P115 if (info.model && (info.model.includes('P110') || info.model.includes('P115'))) { try { const powerData = await client.getCurrentPower(); if (powerData && typeof powerData.current_power !== 'undefined') { // current_power is in mW, convert to W for Shelly compatibility usually or keep as is? // Shelly usually reports W. Tapo P110 returns current_power in mW. const powerWatts = powerData.current_power / 1000.0; this.onDeviceStateChange(mac, 'power:0', 'apower', 'range', powerWatts, deviceInfo); } const energyData = await client.getEnergyUsage(); if (energyData && typeof energyData.today_energy !== 'undefined') { // today_energy is in Wh this.onDeviceStateChange(mac, 'power:0', 'aenergy', 'range', energyData.today_energy, deviceInfo); } } catch (err) { // Ignore power poll errors, don't fail the whole poll console.error(`[Tapo] Error polling power for ${mac}:`, err.message); } } } } catch (e) { console.error(`[Tapo] Error polling plug ${ip}:`, e.message); // Use stored MAC for offline notification, or fallback to deviceId const mac = info.mac || info.deviceId; // Mark as offline if previously online if (info.online !== false) { info.online = false; if (this.onDeviceStateChange) { this.onDeviceStateChange(mac, 'system', 'online', 'boolean', false, null); } } } } async _pollHub(ip, info) { let client = this.deviceClients.get(ip); if (!client) { client = new TapoDevice(ip, this.username, this.password); this.deviceClients.set(ip, client); } try { // Get hub device info const hubDeviceInfo = await client.getDeviceInfo(); // Use real MAC from device const hubMac = normalizeMac(hubDeviceInfo.mac) || info.deviceId; const hubNickname = decodeNickname(hubDeviceInfo.nickname); // Update stored hub info info.mac = hubMac; info.nickname = hubNickname; info.model = hubDeviceInfo.model; info.online = true; info.lastSeen = new Date().toISOString(); if (this.onDeviceStateChange) { this.onDeviceStateChange(hubMac, 'system', 'online', 'boolean', true, hubDeviceInfo); // Hub alarm state if (typeof hubDeviceInfo.in_alarm !== 'undefined') { this.onDeviceStateChange(hubMac, 'alarm', 'active', 'boolean', hubDeviceInfo.in_alarm, hubDeviceInfo); } // Signal strength if (typeof hubDeviceInfo.rssi !== 'undefined') { this.onDeviceStateChange(hubMac, 'wifi', 'rssi', 'range', hubDeviceInfo.rssi, hubDeviceInfo); } } // Get and process child devices const children = await client.getChildDeviceList(); for (const child of children) { // Use real MAC from child device const childMac = normalizeMac(child.mac) || child.device_id; const childNickname = decodeNickname(child.nickname); // Update stored child info with real MAC and nickname if (this.childDevices.has(child.device_id)) { const stored = this.childDevices.get(child.device_id); this.childDevices.set(child.device_id, { ...stored, ...child, mac: childMac, nickname: childNickname }); } // Emit child device state changes using real MAC if (this.onDeviceStateChange) { // Online status this.onDeviceStateChange(childMac, 'system', 'online', 'boolean', child.status === 'online', child); // T100 motion sensor if (child.model && child.model.startsWith('T100')) { this.onDeviceStateChange(childMac, 'sensor:0', 'motion', 'boolean', child.detected || false, child); } // T110 door/window sensor if (child.model && child.model.startsWith('T110')) { this.onDeviceStateChange(childMac, 'sensor:0', 'open', 'boolean', child.open || false, child); } // T300 water leak sensor if (child.model && child.model.startsWith('T300')) { this.onDeviceStateChange(childMac, 'sensor:0', 'water_leak', 'boolean', child.water_leak_status === 'water_leak', child); this.onDeviceStateChange(childMac, 'alarm', 'active', 'boolean', child.in_alarm || false, child); } // T31X temperature/humidity sensor if (child.model && (child.model.startsWith('T310') || child.model.startsWith('T315'))) { if (typeof child.current_temperature !== 'undefined') { this.onDeviceStateChange(childMac, 'sensor:0', 'temperature', 'range', child.current_temperature, child); } if (typeof child.current_humidity !== 'undefined') { this.onDeviceStateChange(childMac, 'sensor:0', 'humidity', 'range', child.current_humidity, child); } } // Battery level for all battery-powered sensors if (typeof child.at_low_battery !== 'undefined') { this.onDeviceStateChange(childMac, 'battery', 'low', 'boolean', child.at_low_battery, child); } // Signal strength if (typeof child.rssi !== 'undefined') { this.onDeviceStateChange(childMac, 'rf', 'rssi', 'range', child.rssi, child); } } } } catch (e) { console.error(`[Tapo] Error polling hub ${ip}:`, e.message); const hubMac = info.mac || info.deviceId; if (info.online !== false) { info.online = false; if (this.onDeviceStateChange) { this.onDeviceStateChange(hubMac, 'system', 'online', 'boolean', false, null); } } } } getDevice(deviceId) { for (const [ip, info] of this.devices) { if (info.deviceId === deviceId) { return { ip, ...info }; } } return null; } getChildDevice(deviceId) { return this.childDevices.get(deviceId) || null; } getAllDevices() { return Array.from(this.devices.values()); } getAllChildDevices() { return Array.from(this.childDevices.values()); } async setDeviceState(deviceId, on) { for (const [ip, info] of this.devices) { if (info.deviceId === deviceId) { const client = this.deviceClients.get(ip); if (client) { return on ? await client.turnOn() : await client.turnOff(); } } } return false; } } export { TapoManager, TapoDevice, generateDiscoveryPacket };