Compare commits

..

11 Commits

Author SHA1 Message Date
sebseb7
63544160d8 pic 2026-01-22 09:24:07 -05:00
sebseb7
778af32b5e feat: Implement server-initiated status refresh for new device detection and client-requested full status updates. 2026-01-22 02:01:34 -05:00
sebseb7
f74467b324 feat: Add larger versions of model pictures and backup original images. 2026-01-22 01:51:36 -05:00
sebseb7
d093e18877 feat: Implement Tapo P110/P115 power and energy monitoring, add Tapo device testing utilities, and include a database upsert test. 2026-01-22 01:17:46 -05:00
sebseb7
22050d1350 feat: add script to test Tapo device information retrieval using custom encryption and handshake. 2026-01-21 18:17:42 -05:00
sebseb7
e619acd0da feat: Add Tapo device integration with discovery and client, and generalize the status server to display IoT status. 2026-01-21 18:13:36 -05:00
sebseb7
b6a25a53fc feat: Introduce rule configuration from the status dashboard, allowing storedDuration editing for the water button rule and improving its timer handling. 2026-01-20 04:07:49 -05:00
sebseb7
75a4d1cbc0 feat: Enhance WebSocket connection reliability with client-side retry/timeout and faster pings, and disable dashboard caching. 2026-01-18 21:06:58 -05:00
sebseb7
6061567871 feat: Capture ctx.updateStatus to correctly reflect timer completion status. 2026-01-18 20:11:45 -05:00
sebseb7
e226032d0b feat: Implement real-time rule status updates and dashboard display via WebSockets. 2026-01-18 05:52:54 -05:00
sebseb7
a381b0e121 feat: Implement toast notifications for device status changes and events, and shorten page titles. 2026-01-18 05:04:48 -05:00
46 changed files with 1814 additions and 18 deletions

9
.env.example Normal file
View 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
View File

@@ -2,3 +2,5 @@ node_modules/
logs/
devices.db
rules/timer_state.json
.env
tapo/

BIN
modelPics/C200.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
modelPics/C200_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
modelPics/H100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
modelPics/H100_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
modelPics/P100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
modelPics/P100_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
modelPics/P115.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
modelPics/P115_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
modelPics/S3SN-0U12A.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
modelPics/T110.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
modelPics/T110_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
modelPics_backup/C200.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
modelPics_backup/H100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
modelPics_backup/P100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
modelPics_backup/P115.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
modelPics_backup/T110.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

13
package-lock.json generated
View File

@@ -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",

View File

@@ -8,6 +8,7 @@
"start": "node server.js"
},
"dependencies": {
"dotenv": "^17.2.3",
"sqlite3": "^5.1.7",
"ws": "^8.19.0"
}

View File

@@ -15,13 +15,15 @@ const RULES_DIR = path.join(__dirname, 'rules');
let rules = [];
let db = null;
let sendRPCToDevice = null;
let statusUpdateCallback = null;
/**
* Initialize rule engine with database and RPC function
*/
export function initRuleEngine(database, rpcFunction) {
export function initRuleEngine(database, rpcFunction, onStatusUpdate = null) {
db = database;
sendRPCToDevice = rpcFunction;
statusUpdateCallback = onStatusUpdate;
}
/**
@@ -112,7 +114,7 @@ function getAllChannelStates() {
/**
* Create context object passed to rule scripts
*/
function createContext(triggerEvent) {
function createContext(triggerEvent, ruleName) {
return {
// The event that triggered this evaluation
trigger: triggerEvent,
@@ -144,6 +146,13 @@ function createContext(triggerEvent) {
}
},
// Push status update to dashboard
updateStatus: (status) => {
if (statusUpdateCallback) {
statusUpdateCallback(ruleName, status);
}
},
// Logging
log: (...args) => console.log('[Rule]', ...args)
};
@@ -156,10 +165,10 @@ export async function runRules(mac, component, field, type, event) {
// Cast event value to proper type
const typedEvent = castValue(event, type);
const triggerEvent = { mac, component, field, type, event: typedEvent };
const ctx = createContext(triggerEvent);
for (const rule of rules) {
try {
const ctx = createContext(triggerEvent, rule._filename);
if (typeof rule.run === 'function') {
await rule.run(ctx);
} else if (typeof rule === 'function') {
@@ -179,6 +188,47 @@ export async function reloadRules() {
await loadRules();
}
/**
* Get status from all rules that have a getStatus() hook
*/
export async function getRulesStatus() {
const statuses = [];
for (const rule of rules) {
try {
if (typeof rule.getStatus === 'function') {
const status = await rule.getStatus();
statuses.push({
name: rule._filename,
status
});
} else {
statuses.push({
name: rule._filename,
status: null
});
}
} catch (err) {
console.error(`Error getting status from rule ${rule._filename}:`, err);
statuses.push({
name: rule._filename,
status: { error: err.message }
});
}
}
return statuses;
}
/**
* Set config on a specific rule
*/
export function setRuleConfig(ruleName, key, value) {
const rule = rules.find(r => r._filename === ruleName);
if (rule && typeof rule.setConfig === 'function') {
return rule.setConfig(key, value);
}
return false;
}
/**
* Watch rules directory for changes and auto-reload
*/

View File

@@ -71,13 +71,35 @@ function getState(mac) {
}
export default {
getStatus() {
const state = getState(WATER_BUTTON_MAC);
return {
storedDuration: state.storedDuration,
countMode: state.countMode,
timerActive: state.timer !== null
};
},
setConfig(key, value) {
if (key === 'storedDuration') {
const duration = parseInt(value, 10);
if (duration > 0 && duration <= 300000) { // Max 5 minutes
const state = getState(WATER_BUTTON_MAC);
state.storedDuration = duration;
// Persist
persistedState[WATER_BUTTON_MAC] = { storedDuration: duration };
saveState(persistedState);
console.log(`[Rule] storedDuration set to ${duration}ms`);
return true;
}
}
return false;
},
async run(ctx) {
// Auto-on for water button when it connects (only if remote switch is online)
if (ctx.trigger.mac === WATER_BUTTON_MAC && ctx.trigger.field === 'online' && ctx.trigger.event === true) {
const remoteSwitchConnected = await ctx.getState(REMOTE_SWITCH_MAC, 'system', 'online');
if (remoteSwitchConnected === true) {
ctx.log('Water button connected - remote switch online, turning light on');
await setLight(ctx, ctx.trigger.mac, true, 20);
// Double flash to indicate both devices are connected
ctx.log('Double flashing to confirm connection');
@@ -87,6 +109,9 @@ export default {
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
await sleep(200);
}
// Turn light on after flash (ready state)
await setLight(ctx, ctx.trigger.mac, true, 20);
} else {
ctx.log('Water button connected - remote switch offline, keeping light off');
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
@@ -143,36 +168,79 @@ export default {
// Handle btn_down
if (ctx.trigger.event === 'btn_down') {
// Check if timer was active before clearing
const timerWasActive = state.timer !== null;
// Clear any pending timer
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
// Turn light off (remote switch turns on)
await setLight(ctx, mac, false, 0);
if (state.countMode) {
// We're in count mode - calculate elapsed time and turn light on
// Turn light off first (remote switch turns on)
await setLight(ctx, mac, false, 0);
const elapsed = Date.now() - state.countStart;
state.storedDuration = elapsed;
state.countMode = false;
ctx.log(`Count mode ended. Stored duration: ${elapsed}ms`);
// Push status update to dashboard
ctx.updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: false,
lastAction: 'Duration saved'
});
// Persist the new duration
persistedState[mac] = { storedDuration: elapsed };
saveState(persistedState);
// Turn light on immediately (remote switch turns off)
await setLight(ctx, mac, true, 20);
} else if (timerWasActive) {
// Timer was running - cancel it and turn light on immediately
ctx.log('Timer cancelled by button press. Turning light on.');
await setLight(ctx, mac, true, 20);
// Push status update to dashboard
ctx.updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: false,
lastAction: 'Timer cancelled'
});
} else {
// Normal mode - schedule light to turn on after stored duration
// Normal mode - turn off light and schedule it to turn on after stored duration
await setLight(ctx, mac, false, 0);
ctx.log(`Light off. Will turn on in ${state.storedDuration}ms`);
// Capture updateStatus for use in timer callback
const updateStatus = ctx.updateStatus;
// Push status update to dashboard
updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: true,
lastAction: 'Timer started'
});
state.timer = setTimeout(async () => {
ctx.log(`Timer elapsed. Turning light on.`);
await setLight(ctx, mac, true, 20);
state.timer = null;
// Push timer completed status
updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: false,
lastAction: 'Timer completed'
});
}, state.storedDuration);
}
}
@@ -202,6 +270,14 @@ export default {
state.countMode = true;
state.countStart = Date.now();
ctx.log('Count mode active. Light stays off. Press button to set duration and turn on.');
// Push status update to dashboard
ctx.updateStatus({
storedDuration: state.storedDuration,
countMode: true,
timerActive: false,
lastAction: 'Counting...'
});
}
}
};

View File

@@ -1,10 +1,12 @@
import 'dotenv/config';
import { WebSocketServer } from 'ws';
import fs from 'fs';
import path from 'path';
import sqlite3 from 'sqlite3';
import { fileURLToPath } from 'url';
import { initRuleEngine, loadRules, runRules, watchRules } from './rule_engine.js';
import { broadcastEvent, startStatusServer } from './status_server.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);
@@ -174,7 +176,7 @@ const wss = new WebSocketServer({ port: 8080 });
console.log('Shelly Agent Server listening on port 8080');
// Initialize and load rules
initRuleEngine(db, sendRPCToDevice);
initRuleEngine(db, sendRPCToDevice, broadcastRuleUpdate);
loadRules().then(() => {
console.log('Rule engine ready');
watchRules(); // Auto-reload rules when files change
@@ -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;
@@ -198,7 +264,7 @@ const interval = setInterval(() => {
ws.isAlive = false;
ws.ping();
});
}, 30000);
}, 10000);
wss.on('close', () => {
clearInterval(interval);
@@ -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(() => {

View File

@@ -4,6 +4,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { WebSocketServer } from 'ws';
import sqlite3 from 'sqlite3';
import { getRulesStatus, setRuleConfig } from './rule_engine.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -79,13 +80,28 @@ export function broadcastEvent(mac, component, field, type, event) {
}
}
// Broadcast rule status update to all connected WebSocket clients
export function broadcastRuleUpdate(ruleName, status) {
const message = JSON.stringify({
type: 'rule_update',
name: ruleName,
status,
timestamp: new Date().toISOString()
});
for (const client of wsClients) {
if (client.readyState === 1) { // OPEN
client.send(message);
}
}
}
// HTML Dashboard
const dashboardHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shelly Status Dashboard</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 {
@@ -316,11 +332,160 @@ const dashboardHTML = `<!DOCTYPE html>
margin-bottom: 1rem;
opacity: 0.5;
}
/* Toast notifications */
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
z-index: 1000;
max-height: 80vh;
overflow: hidden;
}
.toast {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 0.875rem 1rem;
backdrop-filter: blur(10px);
min-width: 280px;
max-width: 360px;
animation: toast-in 0.3s ease-out;
display: flex;
align-items: center;
gap: 0.75rem;
}
.toast.removing {
animation: toast-out 0.3s ease-in forwards;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(100px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100px); }
}
.toast-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toast-icon.online {
background: rgba(34, 197, 94, 0.2);
color: var(--accent-online);
}
.toast-icon.offline {
background: rgba(239, 68, 68, 0.2);
color: var(--accent-offline);
}
.toast-icon.event {
background: rgba(59, 130, 246, 0.2);
color: var(--accent-blue);
}
.toast-content {
flex: 1;
min-width: 0;
}
.toast-title {
font-weight: 600;
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toast-message {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 2px;
}
/* Rules section */
.section-header {
max-width: 1400px;
margin: 2rem auto 1rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-header svg {
width: 20px;
height: 20px;
}
.rules-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
max-width: 1400px;
margin: 0 auto 2rem;
}
.rule-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem 1.25rem;
backdrop-filter: blur(10px);
}
.rule-name {
font-family: monospace;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--accent-blue);
}
.rule-status {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.rule-stat {
background: var(--bg-secondary);
padding: 0.375rem 0.625rem;
border-radius: 6px;
font-size: 0.75rem;
}
.rule-stat-label {
color: var(--text-secondary);
margin-right: 0.25rem;
}
.rule-stat-value {
font-family: monospace;
font-weight: 500;
}
</style>
</head>
<body>
<div class="header">
<h1>Shelly Status Dashboard</h1>
<h1>IoT Status</h1>
<p class="status-indicator">
<span class="status-dot" id="ws-dot"></span>
<span id="connection-status">Connecting...</span>
@@ -336,26 +501,74 @@ const dashboardHTML = `<!DOCTYPE html>
</div>
</div>
<div class="section-header">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Rules
</div>
<div id="rules-container" class="rules-grid"></div>
<div id="toast-container" class="toast-container"></div>
<script>
let devices = {};
let ws;
let retryCount = 0;
let connectionTimeout;
function connectWebSocket() {
retryCount++;
console.log('WebSocket: Connecting... (attempt ' + retryCount + ')');
document.getElementById('connection-status').textContent = 'Connecting... (attempt ' + retryCount + ')';
// Clear any existing connection
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.onopen = null;
try { ws.close(); } catch(e) {}
}
try {
ws = new WebSocket('ws://' + window.location.host);
} catch (err) {
console.error('WebSocket: Failed to create', err);
setTimeout(connectWebSocket, 2000);
return;
}
// Connection timeout - abort if no response in 3 seconds
connectionTimeout = setTimeout(() => {
console.log('WebSocket: Connection timeout, retrying...');
if (ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
}, 3000);
ws.onopen = () => {
clearTimeout(connectionTimeout);
console.log('WebSocket: Connected');
retryCount = 0;
document.getElementById('ws-dot').classList.add('connected');
document.getElementById('connection-status').textContent = 'Connected';
document.getElementById('connection-status').classList.add('connected');
};
ws.onclose = () => {
ws.onclose = (e) => {
clearTimeout(connectionTimeout);
console.log('WebSocket: Closed', e.code, e.reason);
document.getElementById('ws-dot').classList.remove('connected');
document.getElementById('connection-status').textContent = 'Disconnected - Reconnecting...';
document.getElementById('connection-status').classList.remove('connected');
setTimeout(connectWebSocket, 2000);
};
ws.onerror = (e) => {
console.error('WebSocket: Error', e);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
@@ -366,18 +579,62 @@ const dashboardHTML = `<!DOCTYPE html>
devices[device.mac] = device;
}
renderDevices();
if (data.rules) {
renderRules(data.rules);
}
} else if (data.type === 'event') {
// Real-time event update
handleEvent(data);
} else if (data.type === 'rule_update') {
// Real-time rule status update
handleRuleUpdate(data);
} else if (data.type === 'rules_update') {
// Full rules refresh (after config change)
renderRules(data.rules);
}
};
}
let currentRules = [];
function sendConfig(ruleName, key, value) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'set_config',
ruleName,
key,
value
}));
showToast(ruleName, 'Saving...', key + ' = ' + value, 'event');
}
}
function handleRuleUpdate(data) {
// Update the rule in our local cache
const ruleIndex = currentRules.findIndex(r => r.name === data.name);
if (ruleIndex >= 0) {
currentRules[ruleIndex].status = data.status;
} else {
currentRules.push({ name: data.name, status: data.status });
}
// Re-render rules
renderRules(currentRules);
// Show toast for the update
const action = data.status.lastAction || 'Status updated';
showToast(data.name, action, data.name, 'event');
}
function handleEvent(data) {
const { mac, component, field, event, eventType } = data;
if (!devices[mac]) {
// New device, request full refresh
console.log('New device detected (' + mac + '), requesting refresh...');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'get_status' }));
}
return;
}
@@ -409,6 +666,28 @@ const dashboardHTML = `<!DOCTYPE html>
// Update online status if it's a system.online event
if (component === 'system' && field === 'online') {
device.connected = event === 'true' || event === true ? 1 : 0;
const isOnline = device.connected === 1;
showToast(
device.mac,
isOnline ? \`Device Online\` : \`Device Offline\`,
\`\${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(
device.mac,
\`\${component}.\${field}\`,
\`\${event}\`,
'event'
);
}
}
// Re-render the specific device card
@@ -423,6 +702,39 @@ const dashboardHTML = `<!DOCTYPE html>
}
}
function showToast(mac, title, message, type) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
const iconSvg = type === 'online'
? '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg>'
: type === 'offline'
? '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>'
: '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>';
toast.innerHTML = \`
<div class="toast-icon \${type}">\${iconSvg}</div>
<div class="toast-content">
<div class="toast-title">\${title}</div>
<div class="toast-message">\${message}</div>
</div>
\`;
container.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
toast.classList.add('removing');
setTimeout(() => toast.remove(), 300);
}, 4000);
// Limit to 5 toasts max
while (container.children.length > 5) {
container.firstChild.remove();
}
}
function renderDevices() {
const container = document.getElementById('devices-container');
@@ -514,6 +826,72 @@ const dashboardHTML = `<!DOCTYPE html>
return date.toLocaleDateString();
}
function formatDuration(ms) {
if (ms < 1000) return \`\${ms}ms\`;
const secs = Math.floor(ms / 1000);
if (secs < 60) return \`\${secs}s\`;
const mins = Math.floor(secs / 60);
const remainingSecs = secs % 60;
return \`\${mins}m \${remainingSecs}s\`;
}
function renderRules(rules) {
const container = document.getElementById('rules-container');
currentRules = rules; // Store for updates
if (!rules || rules.length === 0) {
container.innerHTML = '<p style="color: var(--text-secondary); font-size: 0.8rem;">No rules loaded</p>';
return;
}
container.innerHTML = rules.map(rule => {
let statusHTML = '';
if (rule.status === null) {
statusHTML = '<span style="color: var(--text-secondary); font-size: 0.75rem;">No status hook</span>';
} else if (rule.status.error) {
statusHTML = \`<span style="color: var(--accent-offline);">Error: \${rule.status.error}</span>\`;
} else {
// Display each status property as a badge or input
const stats = Object.entries(rule.status).map(([key, value]) => {
let displayValue = value;
let isEditable = key === 'storedDuration';
if (key.toLowerCase().includes('duration') && typeof value === 'number') {
const secs = Math.round(value / 1000);
if (isEditable) {
return \`
<div class="rule-stat editable">
<span class="rule-stat-label">\${key}:</span>
<input type="number" class="rule-input" value="\${secs}" min="1" max="300"
onchange="sendConfig('\${rule.name}', '\${key}', this.value * 1000)"
style="width: 50px; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 2px 4px; font-family: monospace;">
<span style="color: var(--text-secondary);">s</span>
</div>
\`;
}
displayValue = formatDuration(value);
} else if (typeof value === 'boolean') {
displayValue = value ? '✓' : '✗';
}
return \`
<div class="rule-stat">
<span class="rule-stat-label">\${key}:</span>
<span class="rule-stat-value">\${displayValue}</span>
</div>
\`;
}).join('');
statusHTML = \`<div class="rule-status">\${stats}</div>\`;
}
return \`
<div class="rule-card">
<div class="rule-name">\${rule.name}</div>
\${statusHTML}
</div>
\`;
}).join('');
}
connectWebSocket();
</script>
</body>
@@ -540,7 +918,12 @@ const server = http.createServer(async (req, res) => {
// Serve dashboard
if (url.pathname === '/' || url.pathname === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.writeHead(200, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
});
res.end(dashboardHTML);
return;
}
@@ -559,11 +942,40 @@ wss.on('connection', async (ws) => {
// Send initial status data
try {
const devices = await getStatusData();
ws.send(JSON.stringify({ type: 'init', devices }));
const rules = await getRulesStatus();
ws.send(JSON.stringify({ type: 'init', devices, rules }));
} catch (err) {
console.error('[Status] Error fetching initial data:', err);
}
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
if (data.type === 'set_config') {
const { ruleName, key, value } = data;
const success = setRuleConfig(ruleName, key, value);
if (success) {
// Broadcast updated status to all clients
const rules = await getRulesStatus();
const updateMsg = JSON.stringify({ type: 'rules_update', rules });
for (const client of wsClients) {
if (client.readyState === 1) {
client.send(updateMsg);
}
}
}
ws.send(JSON.stringify({ type: 'set_config_result', success }));
} else if (data.type === 'get_status') {
// Client requested full status refresh
const devices = await getStatusData();
const rules = await getRulesStatus();
ws.send(JSON.stringify({ type: 'init', devices, rules }));
}
} catch (err) {
console.error('[Status] Error processing message:', err);
}
});
ws.on('close', () => {
console.log('[Status] Browser client disconnected');
wsClients.delete(ws);

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);

882
tapo_client.js Normal file
View 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 };