From d093e18877e0143c37926529805bf4c6a826efb6 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Thu, 22 Jan 2026 01:17:46 -0500 Subject: [PATCH] feat: Implement Tapo P110/P115 power and energy monitoring, add Tapo device testing utilities, and include a database upsert test. --- server.js | 24 +++++++- status_server.js | 21 ++++--- tapo_client.js | 44 ++++++++++++++- tapo_test.js | 139 ++++++++++++++++++++++++++++++++++++++++++++++ test_db_upsert.js | 63 +++++++++++++++++++++ 5 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 tapo_test.js create mode 100644 test_db_upsert.js diff --git a/server.js b/server.js index d30a1aa..253b255 100644 --- a/server.js +++ b/server.js @@ -221,8 +221,17 @@ if (process.env.TAPO_USERNAME && process.env.TAPO_PASSWORD) { ? Buffer.from(deviceInfo.nickname, 'base64').toString('utf8').replace(/\0/g, '') : null; - const stmt = db.prepare("INSERT OR REPLACE INTO devices (mac, model, connected, last_seen) VALUES (?, ?, ?, ?)"); - stmt.run(mac, model, value === true && component === 'system' && field === 'online' ? 1 : null, new Date().toISOString()); + const stmt = db.prepare(` + INSERT INTO devices (mac, model, connected, last_seen) + VALUES (?, ?, ?, ?) + ON CONFLICT(mac) DO UPDATE SET + model = excluded.model, + connected = COALESCE(excluded.connected, devices.connected), + last_seen = excluded.last_seen + `); + const connectedState = value === true && component === 'system' && field === 'online' ? 1 : + (value === false && component === 'system' && field === 'online' ? 0 : null); + stmt.run(mac, model, connectedState, new Date().toISOString()); stmt.finalize(); if (nickname) { @@ -316,6 +325,12 @@ wss.on('connection', (ws, req) => { } } } + + // Log extracted RSSI from NotifyFullStatus + const possibleMac = data.params.sys ? data.params.sys.mac : null; + if (possibleMac && data.params.wifi && typeof data.params.wifi.rssi !== 'undefined') { + checkAndLogEvent(possibleMac, 'wifi', 'rssi', 'range', data.params.wifi.rssi, connectionId); + } } // Request device info to populate database @@ -405,6 +420,11 @@ wss.on('connection', (ws, req) => { } } } + + // Check for wifi updates in NotifyStatus + if (data.params.wifi && typeof data.params.wifi.rssi !== 'undefined') { + checkAndLogEvent(mac, 'wifi', 'rssi', 'range', data.params.wifi.rssi, connectionId); + } } } } diff --git a/status_server.js b/status_server.js index ee43d82..af4e701 100644 --- a/status_server.js +++ b/status_server.js @@ -670,13 +670,20 @@ const dashboardHTML = ` isOnline ? 'online' : 'offline' ); } else { - // Show toast for other events - showToast( - device.mac, - \`\${component}.\${field}\`, - \`\${event}\`, - 'event' - ); + // Suppress high-frequency power and RSSI events from notifications + if ((component.startsWith('power') && (field === 'apower' || field === 'aenergy')) || + (component === 'wifi' && field === 'rssi') || + (component === 'rf' && field === 'rssi')) { + // Do not show toast + } else { + // Show toast for other events + showToast( + device.mac, + \`\${component}.\${field}\`, + \`\${event}\`, + 'event' + ); + } } // Re-render the specific device card diff --git a/tapo_client.js b/tapo_client.js index 9885504..707aa0c 100644 --- a/tapo_client.js +++ b/tapo_client.js @@ -320,6 +320,22 @@ class TapoDevice { 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; @@ -661,9 +677,9 @@ class TapoManager { } // 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); - } + // 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') { @@ -679,6 +695,28 @@ class TapoManager { 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) { diff --git a/tapo_test.js b/tapo_test.js new file mode 100644 index 0000000..afcfb70 --- /dev/null +++ b/tapo_test.js @@ -0,0 +1,139 @@ +/** + * Tapo Device Info Test + * + * This script polls each Tapo device type to see what data is available. + */ + +import 'dotenv/config'; +import crypto from 'crypto'; +import http from 'http'; + +// KLAP Cipher +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(h) { return KlapCipher.sha256(Buffer.concat([Buffer.from('lsk'), h])).subarray(0, 16); } + _ivDerive(h) { + const hash = KlapCipher.sha256(Buffer.concat([Buffer.from('iv'), h])); + return { iv: hash.subarray(0, 12), seq: hash.readInt32BE(hash.length - 4) }; + } + _sigDerive(h) { return KlapCipher.sha256(Buffer.concat([Buffer.from('ldk'), h])).subarray(0, 28); } + _ivSeq(seq) { const b = Buffer.alloc(4); b.writeInt32BE(seq); return Buffer.concat([this.iv, b]); } + + encrypt(data) { + this.seq++; + const cipher = crypto.createCipheriv('aes-128-cbc', this.key, this._ivSeq(this.seq)); + 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]); + return { payload: Buffer.concat([KlapCipher.sha256(sigData), encrypted]), seq: this.seq }; + } + + decrypt(seq, data) { + const decipher = crypto.createDecipheriv('aes-128-cbc', this.key, this._ivSeq(seq)); + return Buffer.concat([decipher.update(data.subarray(32)), decipher.final()]).toString('utf8'); + } +} + +function httpRequest(ip, path, body, cookie = null) { + return new Promise((resolve, reject) => { + const options = { + hostname: 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('Timeout')); }); + req.write(body); + req.end(); + }); +} + +async function connectDevice(ip, username, password) { + const authHash = KlapCipher.sha256(Buffer.concat([ + KlapCipher.sha1(Buffer.from(username.toLowerCase().trim())), + KlapCipher.sha1(Buffer.from(password)) + ])); + + const localSeed = crypto.randomBytes(16); + const hs1 = await httpRequest(ip, '/handshake1', localSeed); + if (hs1.status !== 200) throw new Error(`Handshake1 failed: ${hs1.status}`); + + const cookies = hs1.headers['set-cookie']; + const cookieStr = Array.isArray(cookies) ? cookies[0] : cookies; + const cookie = cookieStr.match(/TP_SESSIONID=([^;]+)/)?.[0] || ''; + + const remoteSeed = hs1.body.subarray(0, 16); + const serverHash = hs1.body.subarray(16, 48); + const localHash = KlapCipher.sha256(Buffer.concat([localSeed, remoteSeed, authHash])); + if (!localHash.equals(serverHash)) throw new Error('Auth failed'); + + const hs2Payload = KlapCipher.sha256(Buffer.concat([remoteSeed, localSeed, authHash])); + const hs2 = await httpRequest(ip, '/handshake2', hs2Payload, cookie); + if (hs2.status !== 200) throw new Error(`Handshake2 failed: ${hs2.status}`); + + return { cipher: new KlapCipher(localSeed, remoteSeed, authHash), cookie }; +} + +async function request(ip, cipher, cookie, method, params = {}) { + const requestData = JSON.stringify({ method, params }); + const { payload, seq } = cipher.encrypt(requestData); + const response = await httpRequest(ip, `/request?seq=${seq}`, payload, cookie); + if (response.status !== 200) throw new Error(`Request failed: ${response.status}`); + return JSON.parse(cipher.decrypt(seq, response.body)); +} + +async function testDevice(ip, type) { + console.log(`\n${'='.repeat(60)}`); + console.log(`Testing ${type} at ${ip}`); + console.log('='.repeat(60)); + + try { + const { cipher, cookie } = await connectDevice(ip, process.env.TAPO_USERNAME, process.env.TAPO_PASSWORD); + + // Get device info + const info = await request(ip, cipher, cookie, 'get_device_info'); + console.log('\nšŸ“± get_device_info result:'); + console.log(JSON.stringify(info.result, null, 2)); + + // For hubs, get child devices + if (type.includes('H100')) { + const children = await request(ip, cipher, cookie, 'get_child_device_list'); + console.log('\nšŸ‘¶ get_child_device_list result:'); + console.log(JSON.stringify(children.result, null, 2)); + } + + } catch (e) { + console.log(`āŒ Error: ${e.message}`); + } +} + +async function main() { + console.log('Tapo Device Info Test'); + console.log('Username:', process.env.TAPO_USERNAME); + + // Test each device type + await testDevice('192.168.3.18', 'P100'); + await testDevice('192.168.3.21', 'P115(EU)'); + await testDevice('192.168.3.28', 'H100(EU)'); + await testDevice('192.168.3.45', 'C200'); + await testDevice('192.168.3.22', 'C200 #2'); +} + +main().catch(console.error); diff --git a/test_db_upsert.js b/test_db_upsert.js new file mode 100644 index 0000000..48965e0 --- /dev/null +++ b/test_db_upsert.js @@ -0,0 +1,63 @@ +import sqlite3 from 'sqlite3'; + +const db = new sqlite3.Database(':memory:'); + +db.serialize(() => { + db.run("CREATE TABLE devices (mac TEXT PRIMARY KEY, model TEXT, connected INTEGER, last_seen TEXT)"); +}); + +const upsertSql = ` + INSERT INTO devices (mac, model, connected, last_seen) + VALUES (?, ?, ?, ?) + ON CONFLICT(mac) DO UPDATE SET + model = excluded.model, + connected = COALESCE(excluded.connected, devices.connected), + last_seen = excluded.last_seen +`; + +console.log("Starting sequential tests..."); + +// Test 1: Insert initial online device +db.run(upsertSql, ['AA:BB:CC', 'T110', 1, '2023-01-01T12:00:00Z'], (err) => { + if (err) console.error(err); + checkDevice('Test 1 (Init Online)', 'AA:BB:CC', 1, () => { + + // Test 2: Update with NULL status + db.run(upsertSql, ['AA:BB:CC', 'T110', null, '2023-01-01T12:05:00Z'], (err) => { + if (err) console.error(err); + checkDevice('Test 2 (Sensor Event/Null)', 'AA:BB:CC', 1, () => { + + // Test 3: Update with offline status + db.run(upsertSql, ['AA:BB:CC', 'T110', 0, '2023-01-01T12:10:00Z'], (err) => { + if (err) console.error(err); + checkDevice('Test 3 (Offline/0)', 'AA:BB:CC', 0, () => { + + // Test 4: Update with NULL status while offline + db.run(upsertSql, ['AA:BB:CC', 'T110', null, '2023-01-01T12:15:00Z'], (err) => { + if (err) console.error(err); + checkDevice('Test 4 (Sensor Event while Offline)', 'AA:BB:CC', 0, () => { + + // Test 5: Back online + db.run(upsertSql, ['AA:BB:CC', 'T110', 1, '2023-01-01T12:20:00Z'], (err) => { + if (err) console.error(err); + checkDevice('Test 5 (Online again)', 'AA:BB:CC', 1, () => { + console.log("All tests completed."); + }); + }); + }); + }); + }); + }); + }); + }); + }); +}); + +function checkDevice(testName, mac, expectedConnected, callback) { + db.get("SELECT * FROM devices WHERE mac = ?", [mac], (err, row) => { + if (err) console.error("DB Error:", err); + const status = row.connected === expectedConnected ? 'PASS' : 'FAIL'; + console.log(`${testName}: connected=${row.connected} (Expected: ${expectedConnected}) -> ${status}`); + if (callback) callback(); + }); +}