Files
shellySrv/tapo_client.js

883 lines
32 KiB
JavaScript

/**
* 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 };