feat: Add Tapo device integration with discovery and client, and generalize the status server to display IoT status.

This commit is contained in:
sebseb7
2026-01-21 18:13:36 -05:00
parent b6a25a53fc
commit e619acd0da
13 changed files with 1203 additions and 3 deletions

268
tapo-discover.js Executable file
View 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);