Compare commits
3 Commits
b6a25a53fc
...
d093e18877
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d093e18877 | ||
|
|
22050d1350 | ||
|
|
e619acd0da |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Tapo Credentials
|
||||
# Your TP-Link/Tapo account credentials
|
||||
TAPO_USERNAME=your-email@example.com
|
||||
TAPO_PASSWORD=your-password
|
||||
|
||||
# Tapo Discovery Settings (optional)
|
||||
TAPO_BROADCAST_ADDR=192.168.3.255
|
||||
TAPO_DISCOVERY_INTERVAL=300000
|
||||
TAPO_POLL_INTERVAL=10000
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules/
|
||||
logs/
|
||||
devices.db
|
||||
rules/timer_state.json
|
||||
.env
|
||||
tapo/
|
||||
BIN
modelPics/C200.png
Normal file
BIN
modelPics/C200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
modelPics/H100.png
Normal file
BIN
modelPics/H100.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
modelPics/P100.png
Normal file
BIN
modelPics/P100.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
BIN
modelPics/P115.png
Normal file
BIN
modelPics/P115.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
modelPics/T110.png
Normal file
BIN
modelPics/T110.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
13
package-lock.json
generated
13
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "shellyagent",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
@@ -347,6 +348,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
|
||||
83
server.js
83
server.js
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -5,6 +6,7 @@ import sqlite3 from 'sqlite3';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initRuleEngine, loadRules, runRules, watchRules } from './rule_engine.js';
|
||||
import { broadcastEvent, broadcastRuleUpdate, startStatusServer } from './status_server.js';
|
||||
import { TapoManager } from './tapo_client.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -185,6 +187,70 @@ loadRules().then(() => {
|
||||
// Start status dashboard server
|
||||
startStatusServer();
|
||||
|
||||
// Initialize Tapo Manager if credentials are configured
|
||||
let tapoManager = null;
|
||||
if (process.env.TAPO_USERNAME && process.env.TAPO_PASSWORD) {
|
||||
console.log('[Tapo] Credentials found, initializing Tapo manager...');
|
||||
|
||||
tapoManager = new TapoManager(process.env.TAPO_USERNAME, process.env.TAPO_PASSWORD, {
|
||||
broadcastAddr: process.env.TAPO_BROADCAST_ADDR || '255.255.255.255',
|
||||
discoveryInterval: parseInt(process.env.TAPO_DISCOVERY_INTERVAL) || 5 * 60 * 1000, // 5 minutes
|
||||
pollInterval: parseInt(process.env.TAPO_POLL_INTERVAL) || 10 * 1000, // 10 seconds
|
||||
discoveryTimeout: 10
|
||||
});
|
||||
|
||||
// Handle Tapo device discovery (initial UDP discovery - before we have real MAC)
|
||||
tapoManager.onDeviceDiscovered = (deviceInfo) => {
|
||||
console.log(`[Tapo] Device discovered: ${deviceInfo.deviceModel} at ${deviceInfo.ip}`);
|
||||
// Note: Real MAC and nickname will be updated when we first poll the device
|
||||
};
|
||||
|
||||
// Handle Tapo child device discovery (H100 hub sensors)
|
||||
tapoManager.onChildDeviceDiscovered = (hubInfo, childInfo) => {
|
||||
console.log(`[Tapo] Child device discovered: ${childInfo.nickname || childInfo.device_id} (${childInfo.model}) on hub`);
|
||||
// Note: MAC and nickname will be updated when we poll
|
||||
};
|
||||
|
||||
// Handle Tapo state changes - route through the same event system as Shelly
|
||||
// Now receives: (mac, component, field, type, value, deviceInfo)
|
||||
tapoManager.onDeviceStateChange = (mac, component, field, type, value, deviceInfo) => {
|
||||
// Update device record if we have device info with nickname
|
||||
if (deviceInfo && deviceInfo.mac) {
|
||||
const model = deviceInfo.model || 'Unknown';
|
||||
const nickname = deviceInfo.nickname
|
||||
? Buffer.from(deviceInfo.nickname, 'base64').toString('utf8').replace(/\0/g, '')
|
||||
: null;
|
||||
|
||||
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) {
|
||||
console.log(`[Tapo] ${nickname} (${model}) ${mac}: ${component}.${field} = ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log the event through the standard event system
|
||||
checkAndLogEvent(mac, component, field, type, value, null);
|
||||
};
|
||||
|
||||
// Start the Tapo manager
|
||||
tapoManager.start().catch(err => {
|
||||
console.error('[Tapo] Failed to start manager:', err);
|
||||
});
|
||||
} else {
|
||||
console.log('[Tapo] No credentials configured. Set TAPO_USERNAME and TAPO_PASSWORD in .env to enable Tapo integration.');
|
||||
}
|
||||
|
||||
// Global counter for connection IDs
|
||||
let connectionIdCounter = 0;
|
||||
|
||||
@@ -259,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
|
||||
@@ -348,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,6 +518,12 @@ wss.on('connection', (ws, req) => {
|
||||
// Graceful shutdown
|
||||
function shutdown() {
|
||||
console.log('Shutting down server...');
|
||||
|
||||
// Stop Tapo manager
|
||||
if (tapoManager) {
|
||||
tapoManager.stop();
|
||||
}
|
||||
|
||||
wss.clients.forEach(ws => ws.terminate());
|
||||
|
||||
db.serialize(() => {
|
||||
|
||||
@@ -101,7 +101,7 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shelly Status</title>
|
||||
<title>IoT Status</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
@@ -485,7 +485,7 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Shelly Status</h1>
|
||||
<h1>IoT Status</h1>
|
||||
<p class="status-indicator">
|
||||
<span class="status-dot" id="ws-dot"></span>
|
||||
<span id="connection-status">Connecting...</span>
|
||||
@@ -669,6 +669,12 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
\`\${device.model}\`,
|
||||
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 {
|
||||
// Show toast for other events
|
||||
showToast(
|
||||
@@ -678,6 +684,7 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
'event'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render the specific device card
|
||||
renderDevice(device);
|
||||
|
||||
268
tapo-discover.js
Executable file
268
tapo-discover.js
Executable file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tapo Device Discovery Tool
|
||||
*
|
||||
* Discovers TP-Link Tapo devices on the local network using UDP broadcast.
|
||||
* Based on the protocol from the tapo-rs library.
|
||||
*
|
||||
* Usage: node tapo-discover.js [broadcast_ip] [timeout_seconds]
|
||||
* broadcast_ip: Default is 255.255.255.255
|
||||
* timeout_seconds: Default is 10
|
||||
*/
|
||||
|
||||
import dgram from 'dgram';
|
||||
import crypto from 'crypto';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Tapo discovery port
|
||||
const TAPO_DISCOVERY_PORT = 20002;
|
||||
const DISCOVERY_INTERVAL_MS = 3000;
|
||||
|
||||
/**
|
||||
* Generate a simple RSA key pair for the discovery request
|
||||
* Tapo devices expect an RSA public key in PEM format
|
||||
*/
|
||||
function generateRsaKeyPair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 1024,
|
||||
publicKeyEncoding: {
|
||||
type: 'pkcs1',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs1',
|
||||
format: 'pem'
|
||||
}
|
||||
});
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* CRC32 implementation (same polynomial as crc32fast in Rust)
|
||||
*/
|
||||
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 the AES discovery query packet
|
||||
* Based on tapo-rs: aes_discovery_query_generator.rs
|
||||
*/
|
||||
function generateDiscoveryPacket() {
|
||||
const { publicKey } = generateRsaKeyPair();
|
||||
|
||||
// Create the JSON payload with RSA public key
|
||||
const keyPayload = JSON.stringify({
|
||||
params: {
|
||||
rsa_key: publicKey
|
||||
}
|
||||
});
|
||||
const keyPayloadBytes = Buffer.from(keyPayload, 'utf8');
|
||||
|
||||
// Generate random device serial (4 bytes)
|
||||
const secret = crypto.randomBytes(4);
|
||||
const deviceSerial = secret.readUInt32BE(0);
|
||||
|
||||
// Header fields (based on tapo-rs implementation)
|
||||
const version = 2; // u8
|
||||
const msgType = 0; // u8
|
||||
const opCode = 1; // u16 BE
|
||||
const msgSize = keyPayloadBytes.length; // u16 BE
|
||||
const flags = 17; // u8
|
||||
const paddingByte = 0; // u8
|
||||
const initialCrc = 0x5A6B7C8D; // i32 BE (placeholder, will be replaced)
|
||||
|
||||
// Build header (16 bytes total)
|
||||
const header = Buffer.alloc(16);
|
||||
let offset = 0;
|
||||
header.writeUInt8(version, offset++); // 1 byte
|
||||
header.writeUInt8(msgType, offset++); // 1 byte
|
||||
header.writeUInt16BE(opCode, offset); // 2 bytes
|
||||
offset += 2;
|
||||
header.writeUInt16BE(msgSize, offset); // 2 bytes
|
||||
offset += 2;
|
||||
header.writeUInt8(flags, offset++); // 1 byte
|
||||
header.writeUInt8(paddingByte, offset++); // 1 byte
|
||||
header.writeUInt32BE(deviceSerial, offset); // 4 bytes
|
||||
offset += 4;
|
||||
header.writeInt32BE(initialCrc, offset); // 4 bytes (placeholder)
|
||||
|
||||
// Combine header and payload
|
||||
const query = Buffer.concat([header, keyPayloadBytes]);
|
||||
|
||||
// Calculate CRC32 of the entire packet and update bytes 12-16
|
||||
const crcValue = crc32(query);
|
||||
query.writeUInt32BE(crcValue, 12);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse discovery response from Tapo device
|
||||
*/
|
||||
function parseDiscoveryResponse(data, rinfo) {
|
||||
const result = {
|
||||
ip: rinfo.address,
|
||||
port: rinfo.port,
|
||||
rawSize: data.length
|
||||
};
|
||||
|
||||
// Response has 16-byte header + JSON payload
|
||||
if (data.length > 16) {
|
||||
try {
|
||||
const jsonStr = data.slice(16).toString('utf8');
|
||||
// Find the end of JSON (some responses have trailing garbage)
|
||||
const jsonEnd = jsonStr.lastIndexOf('}') + 1;
|
||||
if (jsonEnd > 0) {
|
||||
const json = JSON.parse(jsonStr.slice(0, jsonEnd));
|
||||
result.data = json;
|
||||
|
||||
// Extract common fields
|
||||
if (json.result) {
|
||||
result.deviceId = json.result.device_id;
|
||||
result.owner = json.result.owner;
|
||||
result.deviceType = json.result.device_type;
|
||||
result.deviceModel = json.result.device_model;
|
||||
result.factoryDefault = json.result.factory_default;
|
||||
result.mgtEncryptSchm = json.result.mgt_encrypt_schm;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
result.parseError = e.message;
|
||||
result.rawHex = data.toString('hex');
|
||||
}
|
||||
} else {
|
||||
result.rawHex = data.toString('hex');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main discovery function
|
||||
*/
|
||||
async function discoverDevices(broadcastAddr = '255.255.255.255', timeoutSeconds = 10) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const devices = new Map();
|
||||
const socket = dgram.createSocket('udp4');
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.error('Socket error:', err);
|
||||
socket.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.on('message', (msg, rinfo) => {
|
||||
if (!devices.has(rinfo.address)) {
|
||||
const device = parseDiscoveryResponse(msg, rinfo);
|
||||
devices.set(rinfo.address, device);
|
||||
|
||||
console.log(`\n✓ Found device at ${rinfo.address}`);
|
||||
if (device.deviceModel) {
|
||||
console.log(` Model: ${device.deviceModel}`);
|
||||
}
|
||||
if (device.deviceType) {
|
||||
console.log(` Type: ${device.deviceType}`);
|
||||
}
|
||||
if (device.deviceId) {
|
||||
console.log(` Device ID: ${device.deviceId}`);
|
||||
}
|
||||
if (device.mgtEncryptSchm) {
|
||||
console.log(` Encryption: ${JSON.stringify(device.mgtEncryptSchm)}`);
|
||||
}
|
||||
if (device.parseError) {
|
||||
console.log(` Parse error: ${device.parseError}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
|
||||
console.log(`Discovering Tapo devices on ${broadcastAddr}:${TAPO_DISCOVERY_PORT} for ${timeoutSeconds} seconds...`);
|
||||
|
||||
const sendDiscovery = () => {
|
||||
try {
|
||||
const packet = generateDiscoveryPacket();
|
||||
socket.send(packet, 0, packet.length, TAPO_DISCOVERY_PORT, broadcastAddr, (err) => {
|
||||
if (err) {
|
||||
console.error('Error sending discovery packet:', err.message);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error generating discovery packet:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Send immediately and then at intervals
|
||||
sendDiscovery();
|
||||
const interval = setInterval(sendDiscovery, DISCOVERY_INTERVAL_MS);
|
||||
|
||||
// Stop after timeout
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
socket.close();
|
||||
resolve(Array.from(devices.values()));
|
||||
}, timeoutSeconds * 1000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||||
const broadcastAddr = args[0] || '255.255.255.255';
|
||||
const timeout = parseInt(args[1]) || 10;
|
||||
const jsonOutput = args.includes('--json');
|
||||
|
||||
console.log('╔════════════════════════════════════════╗');
|
||||
console.log('║ Tapo Device Discovery Tool ║');
|
||||
console.log('╚════════════════════════════════════════╝\n');
|
||||
|
||||
const devices = await discoverDevices(broadcastAddr, timeout);
|
||||
|
||||
console.log('\n────────────────────────────────────────');
|
||||
console.log(`Discovery complete. Found ${devices.length} device(s).`);
|
||||
|
||||
if (devices.length > 0) {
|
||||
console.log('\nDiscovered Devices:');
|
||||
devices.forEach((device, idx) => {
|
||||
console.log(` ${idx + 1}. ${device.ip} - ${device.deviceModel || 'Unknown Model'} (${device.deviceType || 'Unknown Type'})`);
|
||||
});
|
||||
} else {
|
||||
console.log('\nNo Tapo devices found on the network.');
|
||||
console.log('Tips:');
|
||||
console.log(' - Ensure devices are powered on and connected to the same network');
|
||||
console.log(' - Try specifying your network broadcast address (e.g., 192.168.3.255)');
|
||||
console.log(' - Some devices may not respond to UDP discovery');
|
||||
}
|
||||
|
||||
if (jsonOutput && devices.length > 0) {
|
||||
console.log('\nJSON Output:');
|
||||
console.log(JSON.stringify(devices, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
882
tapo_client.js
Normal file
882
tapo_client.js
Normal file
@@ -0,0 +1,882 @@
|
||||
/**
|
||||
* 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 };
|
||||
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