feat: Implement Tapo P110/P115 power and energy monitoring, add Tapo device testing utilities, and include a database upsert test.
This commit is contained in:
24
server.js
24
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, '')
|
? Buffer.from(deviceInfo.nickname, 'base64').toString('utf8').replace(/\0/g, '')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const stmt = db.prepare("INSERT OR REPLACE INTO devices (mac, model, connected, last_seen) VALUES (?, ?, ?, ?)");
|
const stmt = db.prepare(`
|
||||||
stmt.run(mac, model, value === true && component === 'system' && field === 'online' ? 1 : null, new Date().toISOString());
|
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();
|
stmt.finalize();
|
||||||
|
|
||||||
if (nickname) {
|
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
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -669,6 +669,12 @@ const dashboardHTML = `<!DOCTYPE html>
|
|||||||
\`\${device.model}\`,
|
\`\${device.model}\`,
|
||||||
isOnline ? 'online' : 'offline'
|
isOnline ? 'online' : 'offline'
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// 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 {
|
} else {
|
||||||
// Show toast for other events
|
// Show toast for other events
|
||||||
showToast(
|
showToast(
|
||||||
@@ -678,6 +684,7 @@ const dashboardHTML = `<!DOCTYPE html>
|
|||||||
'event'
|
'event'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-render the specific device card
|
// Re-render the specific device card
|
||||||
renderDevice(device);
|
renderDevice(device);
|
||||||
|
|||||||
@@ -320,6 +320,22 @@ class TapoDevice {
|
|||||||
throw new Error(`get_child_device_list failed: ${response.error_code}`);
|
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() {
|
async turnOn() {
|
||||||
const response = await this.request('set_device_info', { device_on: true });
|
const response = await this.request('set_device_info', { device_on: true });
|
||||||
return response.error_code === 0;
|
return response.error_code === 0;
|
||||||
@@ -661,9 +677,9 @@ class TapoManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On time (how long has it been on)
|
// On time (how long has it been on)
|
||||||
if (typeof deviceInfo.on_time !== 'undefined') {
|
// if (typeof deviceInfo.on_time !== 'undefined') {
|
||||||
this.onDeviceStateChange(mac, 'switch:0', 'on_time', 'range', deviceInfo.on_time, deviceInfo);
|
// this.onDeviceStateChange(mac, 'switch:0', 'on_time', 'range', deviceInfo.on_time, deviceInfo);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Signal strength
|
// Signal strength
|
||||||
if (typeof deviceInfo.rssi !== 'undefined') {
|
if (typeof deviceInfo.rssi !== 'undefined') {
|
||||||
@@ -679,6 +695,28 @@ class TapoManager {
|
|||||||
if (typeof deviceInfo.power_protection_status !== 'undefined') {
|
if (typeof deviceInfo.power_protection_status !== 'undefined') {
|
||||||
this.onDeviceStateChange(mac, 'power', 'protection_status', 'enum', deviceInfo.power_protection_status, deviceInfo);
|
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) {
|
} catch (e) {
|
||||||
|
|||||||
139
tapo_test.js
Normal file
139
tapo_test.js
Normal file
@@ -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);
|
||||||
63
test_db_upsert.js
Normal file
63
test_db_upsert.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user