feat: Add Tapo device integration with discovery and client, and generalize the status server to display IoT status.
This commit is contained in:
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);
|
||||
Reference in New Issue
Block a user