feat: Enhance water valve control with Telegram bot integration, allowing remote commands and user authorization. Add channel history querying and improve server functionality for rule execution and static file serving.

This commit is contained in:
sebseb7
2026-02-27 11:22:49 -05:00
parent 63544160d8
commit 7d96ed29c4
5 changed files with 862 additions and 80 deletions

View File

@@ -7,3 +7,7 @@ TAPO_PASSWORD=your-password
TAPO_BROADCAST_ADDR=192.168.3.255
TAPO_DISCOVERY_INTERVAL=300000
TAPO_POLL_INTERVAL=10000
# Telegram bot token for water valve rule (waterButtonLearnAndCall.js)
WATER_VALVE_TELEGRAM_TOKEN=your-telegram-bot-token
WATER_VALVE_TELEGRAM_PASSWORD=your-secret-password

4
.gitignore vendored
View File

@@ -3,4 +3,6 @@ logs/
devices.db
rules/timer_state.json
.env
tapo/
tapo/
rules/telegram_authorized_users.json
server.log

View File

@@ -1,11 +1,18 @@
/**
* Timer light rule
*
*
* - btn_down: Light goes off
* - long_push: Flash to confirm, enter "count mode" - counts seconds until next btn_down, then light goes on
* - Normal btn_down (no long_push): Light goes off, then turns back on after the stored count elapsed
*
*
* Also syncs remote switch CC8DA243B0A0 inversely (light off = switch on, light on = switch off)
*
* Telegram bot (remote control):
* /open — Open valve
* /close — Close valve
* /run — Close valve then reopen after stored duration
* /duration — Get or set timer duration (e.g. 30s, 2m, 10000)
* /status — Show current status
*/
import fs from 'fs';
@@ -20,14 +27,31 @@ const STATE_FILE = path.join(__dirname, 'timer_state.json');
const WATER_BUTTON_MAC = '08A6F773510C';
const REMOTE_SWITCH_MAC = 'CC8DA243B0A0';
// Helper to set local light and sync remote switch inversely
async function setLight(ctx, mac, on, brightness) {
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on, brightness });
// Remote switch is inverse: light off = switch on, light on = switch off
await ctx.sendRPC(REMOTE_SWITCH_MAC, 'Switch.Set', { id: 0, on: !on });
// Telegram
const BOT_TOKEN = process.env.WATER_VALVE_TELEGRAM_TOKEN;
const BOT_PASSWORD = process.env.WATER_VALVE_TELEGRAM_PASSWORD;
const TELEGRAM_API = `https://api.telegram.org/bot${BOT_TOKEN}`;
const AUTH_FILE = path.join(__dirname, 'telegram_authorized_users.json');
// Generation counter — incremented on each hot reload to stop the old poll loop
const BOT_GEN = Date.now();
if (!global.__waterBotGen) global.__waterBotGen = 0;
global.__waterBotGen = BOT_GEN;
// sendRPC / getState persisted across hot reloads via globals (populated on first ctx event)
if (!global.__waterBotRPC) global.__waterBotRPC = null;
if (!global.__waterBotGetState) global.__waterBotGetState = null;
// ── Device helpers ────────────────────────────────────────────────────────────
async function setLight(sendRPC, mac, on, brightness) {
await sendRPC(mac, 'Light.Set', { id: 0, on, brightness });
// Remote switch is inverse: light off → switch on, light on → switch off
await sendRPC(REMOTE_SWITCH_MAC, 'Switch.Set', { id: 0, on: !on });
}
// Load persisted state
// ── Persistence ───────────────────────────────────────────────────────────────
function loadPersistedState() {
try {
if (fs.existsSync(STATE_FILE)) {
@@ -39,10 +63,8 @@ function loadPersistedState() {
return {};
}
// Save state to file
function saveState(state) {
try {
// Only save storedDuration per MAC
const toSave = {};
for (const [mac, data] of Object.entries(state)) {
toSave[mac] = { storedDuration: data.storedDuration };
@@ -53,7 +75,8 @@ function saveState(state) {
}
}
// State for devices (in memory, with persistence for storedDuration)
// ── In-memory device state ────────────────────────────────────────────────────
const persistedState = loadPersistedState();
const deviceState = new Map();
@@ -63,13 +86,279 @@ function getState(mac) {
deviceState.set(mac, {
countMode: false,
countStart: 0,
storedDuration: persisted.storedDuration || 5000, // Default 5 seconds
storedDuration: persisted.storedDuration || 5000,
timer: null
});
}
return deviceState.get(mac);
}
// ── Telegram auth ─────────────────────────────────────────────────────────────
function loadAuthorizedUsers() {
try {
if (fs.existsSync(AUTH_FILE)) {
return new Set(JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')));
}
} catch (err) {
console.error('[TelegramBot] Error loading authorized users:', err);
}
return new Set();
}
function saveAuthorizedUsers(users) {
try {
fs.writeFileSync(AUTH_FILE, JSON.stringify([...users], null, 2));
} catch (err) {
console.error('[TelegramBot] Error saving authorized users:', err);
}
}
const authorizedUsers = loadAuthorizedUsers();
// ── Telegram bot ──────────────────────────────────────────────────────────────
async function telegramRequest(method, params = {}) {
const res = await fetch(`${TELEGRAM_API}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return res.json();
}
function menuKeyboard(durSec) {
return {
keyboard: [
[{ text: '🔓 Open valve' }, { text: '🔒 Close valve' }],
[{ text: `💧 Run timer (${durSec}s)` }, { text: '📊 Status' }]
],
resize_keyboard: true,
persistent: true
};
}
async function getConnectionStatus() {
try {
const getState = global.__waterBotGetState;
if (!getState) return '❓ Button\n❓ Valve';
const [buttonOnline, valveOnline] = await Promise.all([
getState(WATER_BUTTON_MAC, 'system', 'online'),
getState(REMOTE_SWITCH_MAC, 'system', 'online')
]);
const btn = buttonOnline === true ? '✅' : buttonOnline === false ? '❌ OFFLINE' : '❓';
const vlv = valveOnline === true ? '✅' : valveOnline === false ? '❌ OFFLINE' : '❓';
return `${btn} Button\n${vlv} Valve`;
} catch {
return '❓ Button\n❓ Valve';
}
}
async function reply(chatId, text, durSec) {
const connStatus = await getConnectionStatus();
return telegramRequest('sendMessage', {
chat_id: chatId,
text: `${connStatus}\n\n${text}`,
reply_markup: menuKeyboard(durSec)
});
}
async function sendMessage(chatId, text) {
return telegramRequest('sendMessage', { chat_id: chatId, text });
}
function parseDuration(raw) {
if (!raw) return null;
if (raw.endsWith('m')) return Math.round(parseFloat(raw) * 60000);
if (raw.endsWith('s')) return Math.round(parseFloat(raw) * 1000);
const ms = parseInt(raw, 10);
return isNaN(ms) ? null : ms;
}
async function handleBotMessage(msg) {
const chatId = msg.chat.id;
const text = (msg.text || '').trim();
const parts = text.split(/\s+/);
// Strip @botname suffix that Telegram adds in groups
const cmd = parts[0].toLowerCase().split('@')[0];
// ── Authorization gate ───────────────────────────────────────────────────
if (!authorizedUsers.has(chatId)) {
if (text === BOT_PASSWORD) {
authorizedUsers.add(chatId);
saveAuthorizedUsers(authorizedUsers);
console.log(`[TelegramBot] New authorized user: ${chatId} (${msg.from?.username || msg.from?.first_name || 'unknown'})`);
const state = getState(WATER_BUTTON_MAC);
await reply(chatId, 'Access granted. Use the buttons below or type a number (seconds) to set the duration.', (state.storedDuration / 1000).toFixed(1));
} else {
await sendMessage(chatId, 'Please enter the password to use this bot.');
}
return;
}
const state = getState(WATER_BUTTON_MAC);
const durSec = (state.storedDuration / 1000).toFixed(1);
// ── Plain number → set duration in seconds ───────────────────────────────
const numericMatch = text.match(/^(\d+(?:\.\d+)?)$/);
if (numericMatch) {
const ms = Math.round(parseFloat(numericMatch[1]) * 1000);
if (ms <= 0 || ms > 300000) {
await reply(chatId, 'Duration must be between 1 and 300 seconds.', durSec);
return;
}
state.storedDuration = ms;
persistedState[WATER_BUTTON_MAC] = { storedDuration: ms };
saveState(persistedState);
const newSec = (ms / 1000).toFixed(1);
await reply(chatId, `Duration set to ${newSec}s.`, newSec);
return;
}
// ── Menu button labels map to commands ───────────────────────────────────
const buttonMap = {
'🔓 open valve': '/open',
'🔒 close valve': '/close',
'📊 status': '/status'
};
// Run timer button label includes dynamic duration — match by prefix
const normalised = text.toLowerCase();
const resolvedCmd = buttonMap[normalised]
|| (normalised.startsWith('💧 run timer') ? '/run' : cmd);
if (resolvedCmd === '/start' || resolvedCmd === '/help') {
await reply(chatId,
`💧 Water valve bot\n\n` +
`Use the buttons below, or:\n` +
`/open — Open valve\n` +
`/close — Close valve\n` +
`/run — Open then close after ${durSec}s\n` +
`/duration <value> — Set timer (e.g. 30s, 2m, 10000)\n` +
`<number> — Set duration in seconds (e.g. 45)\n` +
`/status — Current status`,
durSec
);
return;
}
if (resolvedCmd === '/status') {
await reply(chatId,
`Status:\n` +
`• Duration: ${state.storedDuration}ms (${durSec}s)\n` +
`• Count mode: ${state.countMode}\n` +
`• Timer active: ${state.timer !== null}`,
durSec
);
return;
}
if (resolvedCmd === '/duration') {
if (!parts[1]) {
await reply(chatId,
`Current duration: ${durSec}s\n` +
`Type a number (seconds) or use /duration <value>\n` +
`Examples: 30 /duration 30s /duration 2m`,
durSec
);
return;
}
const ms = parseDuration(parts[1]);
if (!ms || ms <= 0 || ms > 300000) {
await reply(chatId, 'Invalid duration. Use e.g. 30s, 2m, or seconds 1300.', durSec);
return;
}
state.storedDuration = ms;
persistedState[WATER_BUTTON_MAC] = { storedDuration: ms };
saveState(persistedState);
const newSec = (ms / 1000).toFixed(1);
await reply(chatId, `Duration set to ${newSec}s.`, newSec);
return;
}
const sendRPC = global.__waterBotRPC;
if (!sendRPC) {
await reply(chatId, 'Not ready yet — waiting for first device event. Try again in a moment.', durSec);
return;
}
if (resolvedCmd === '/open') {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.countMode = false;
await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); // light off = switch on = valve open
await reply(chatId, 'Valve opened.', durSec);
} else if (resolvedCmd === '/close') {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.countMode = false;
await setLight(sendRPC, WATER_BUTTON_MAC, true, 95); // light on = switch off = valve closed
await reply(chatId, 'Valve closed.', durSec);
} else if (resolvedCmd === '/run') {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.countMode = false;
await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); // open valve
const duration = state.storedDuration;
await reply(chatId, `Valve opened. Closing in ${(duration / 1000).toFixed(1)}s…`, durSec);
state.timer = setTimeout(async () => {
try {
const rpc = global.__waterBotRPC;
if (rpc) await setLight(rpc, WATER_BUTTON_MAC, true, 95); // close valve
state.timer = null;
await reply(chatId, 'Timer elapsed — valve closed.', (getState(WATER_BUTTON_MAC).storedDuration / 1000).toFixed(1));
} catch (err) {
console.error('[TelegramBot] Timer callback error:', err);
}
}, duration);
} else {
await reply(chatId, 'Unknown command. Use the buttons below or send /help.', durSec);
}
}
async function botLoop() {
console.log(`[TelegramBot] Starting (gen ${BOT_GEN})`);
let offset = 0;
while (BOT_GEN === global.__waterBotGen) {
try {
const data = await telegramRequest('getUpdates', {
offset,
timeout: 25,
allowed_updates: ['message']
});
if (!data.ok) {
await sleep(5000);
continue;
}
for (const update of data.result || []) {
offset = update.update_id + 1;
if (update.message) {
handleBotMessage(update.message).catch(err =>
console.error('[TelegramBot] Handler error:', err)
);
}
}
} catch (err) {
console.error('[TelegramBot] Polling error:', err.message);
await sleep(5000);
}
}
console.log(`[TelegramBot] Stopped (gen ${BOT_GEN} superseded)`);
}
// Start long-polling immediately (non-blocking)
if (BOT_TOKEN) {
botLoop().catch(err => console.error('[TelegramBot] Fatal error:', err));
} else {
console.error('[TelegramBot] WATER_VALVE_TELEGRAM_TOKEN not set in .env — bot disabled');
}
// ── Rule export ───────────────────────────────────────────────────────────────
export default {
getStatus() {
const state = getState(WATER_BUTTON_MAC);
@@ -79,13 +368,13 @@ export default {
timerActive: state.timer !== null
};
},
setConfig(key, value) {
if (key === 'storedDuration') {
const duration = parseInt(value, 10);
if (duration > 0 && duration <= 300000) { // Max 5 minutes
if (duration > 0 && duration <= 300000) {
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`);
@@ -94,24 +383,26 @@ export default {
}
return false;
},
async run(ctx) {
// Auto-on for water button when it connects (only if remote switch is online)
// Make sendRPC and getState available to the Telegram bot handlers
global.__waterBotRPC = ctx.sendRPC;
global.__waterBotGetState = ctx.getState;
// ── Online / offline events ──────────────────────────────────────────
// Water button comes online — turn light on if remote switch is also 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');
// Double flash to indicate both devices are connected
ctx.log('Double flashing to confirm connection');
for (let i = 0; i < 2; i++) {
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 20 });
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 95 });
await sleep(200);
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);
await setLight(ctx.sendRPC, ctx.trigger.mac, true, 95);
} 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 });
@@ -119,35 +410,26 @@ export default {
return;
}
// Auto-off for remote switch when it connects
// Remote switch comes online
if (ctx.trigger.mac === REMOTE_SWITCH_MAC && ctx.trigger.field === 'online' && ctx.trigger.event === true) {
ctx.log('Remote switch connected - turning switch off, flashing light, then turning light on');
await ctx.sendRPC(REMOTE_SWITCH_MAC, 'Switch.Set', { id: 0, on: false });
// Double flash the light
for (let i = 0; i < 2; i++) {
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 20 });
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 95 });
await sleep(200);
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
await sleep(200);
}
// Turn light on after flash
await setLight(ctx, WATER_BUTTON_MAC, true, 20);
await setLight(ctx.sendRPC, WATER_BUTTON_MAC, true, 95);
return;
}
// Turn off light when remote switch goes offline
// Remote switch goes offline — turn light off and cancel any timer
if (ctx.trigger.mac === REMOTE_SWITCH_MAC && ctx.trigger.field === 'online' && ctx.trigger.event === false) {
ctx.log('Remote switch went offline - turning light off');
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
// Clear any pending timer
const state = getState(WATER_BUTTON_MAC);
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.countMode = false;
return;
}
@@ -161,33 +443,25 @@ export default {
return;
}
// ── Button events ────────────────────────────────────────────────────
const mac = ctx.trigger.mac;
const state = getState(mac);
ctx.log(`Event: ${ctx.trigger.event}, countMode: ${state.countMode}, storedDuration: ${state.storedDuration}ms`);
// 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;
}
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
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);
await setLight(ctx.sendRPC, 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,
@@ -195,33 +469,27 @@ export default {
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);
await setLight(ctx.sendRPC, mac, true, 95);
} else if (timerWasActive) {
ctx.log('Timer cancelled by button press. Turning light on.');
await setLight(ctx.sendRPC, mac, true, 95);
// Push status update to dashboard
ctx.updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: false,
lastAction: 'Timer cancelled'
});
} else {
// Normal mode - turn off light and schedule it to turn on after stored duration
await setLight(ctx, mac, false, 0);
await setLight(ctx.sendRPC, 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,
@@ -230,11 +498,11 @@ export default {
});
state.timer = setTimeout(async () => {
ctx.log(`Timer elapsed. Turning light on.`);
await setLight(ctx, mac, true, 20);
ctx.log('Timer elapsed. Turning light on.');
const rpc = global.__waterBotRPC;
if (rpc) await setLight(rpc, mac, true, 95);
state.timer = null;
// Push timer completed status
updateStatus({
storedDuration: state.storedDuration,
countMode: false,
@@ -245,33 +513,25 @@ export default {
}
}
// Handle long_push - enter count mode
// long_push enter count mode
if (ctx.trigger.event === 'long_push' && !state.countMode) {
// Clear any pending timer
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
ctx.log('Entering count mode...');
// Flash to confirm (don't sync remote during flash)
for (let i = 0; i < 2; i++) {
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on: true, brightness: 20 });
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on: true, brightness: 95 });
await sleep(200);
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on: false, brightness: 0 });
await sleep(200);
}
// Ensure light stays off during count mode (remote switch on)
await setLight(ctx, mac, false, 0);
await setLight(ctx.sendRPC, mac, false, 0);
// Enter count mode
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,

119
server.js
View File

@@ -1,10 +1,12 @@
import 'dotenv/config';
import { WebSocketServer } from 'ws';
import http from 'http';
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 { Interpreter } from './ruleUITest/interpreter.js'; // Import for manual execution
import { broadcastEvent, broadcastRuleUpdate, startStatusServer } from './status_server.js';
import { TapoManager } from './tapo_client.js';
@@ -171,9 +173,99 @@ function checkAndLogEvent(mac, component, field, type, event, connectionId = nul
});
}
const wss = new WebSocketServer({ port: 8080 });
const ruleContext = {
getState: (mac, component, field) => {
return new Promise((resolve, reject) => {
db.get(`SELECT e.event, c.type FROM events e
JOIN channels c ON e.channel_id = c.id
WHERE c.mac = ? AND c.component = ? AND c.field = ?
ORDER BY e.id DESC LIMIT 1`, [mac, component, field], (err, row) => {
if (err) resolve(null);
else resolve(row ? row.event : null); // Simple cast?
});
});
},
setOutput: (mac, component, on) => sendRPCToDevice(mac, 'Switch.Set', { id: parseInt(component.split(':')[1]), on: !!on }),
setLevel: (mac, component, level) => sendRPCToDevice(mac, 'Light.Set', { id: parseInt(component.split(':')[1]), brightness: parseInt(level) }),
notify: (msg) => console.log(`[RuleNotify] ${msg}`),
log: (msg) => console.log(`[RuleLog] ${msg}`)
};
console.log('Shelly Agent Server listening on port 8080');
const server = http.createServer(async (req, res) => {
// Basic Static File Serving
if (req.method === 'GET') {
let filePath = '.' + req.url;
if (filePath === './rule-editor') filePath = './ruleUITest/index.html';
// Map root requests to ruleUITest internal files if referred
if (req.url.startsWith('/blocks.js')) filePath = './ruleUITest/blocks.js';
const extname = path.extname(filePath);
let contentType = 'text/html';
switch (extname) {
case '.js': contentType = 'text/javascript'; break;
case '.css': contentType = 'text/css'; break;
case '.json': contentType = 'application/json'; break;
}
if (fs.existsSync(filePath) && fs.lstatSync(filePath).isFile()) {
fs.readFile(filePath, (error, content) => {
if (error) {
res.writeHead(500);
res.end('Error: ' + error.code);
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
});
return;
}
}
// API Routes
if (req.method === 'GET' && req.url === '/api/channels') {
db.all("SELECT DISTINCT mac, component, field, type FROM channels", [], (err, rows) => {
if (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: err.message }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(rows));
});
return;
}
if (req.method === 'POST' && req.url === '/api/execute-rule') {
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
req.on('end', async () => {
try {
const ast = JSON.parse(body);
const logs = [];
// Capture logs
const ctx = { ...ruleContext, log: (msg) => logs.push(String(msg)), notify: (msg) => logs.push('Notify: ' + msg) };
const interpreter = new Interpreter(ctx);
await interpreter.execute(ast);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', logs }));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
});
const wss = new WebSocketServer({ server });
server.listen(8080, () => {
console.log('Shelly Agent Server listening on port 8080 (HTTP + WS)');
console.log('Rule Editor available at http://localhost:8080/rule-editor');
});
// Initialize and load rules
initRuleEngine(db, sendRPCToDevice, broadcastRuleUpdate);
@@ -324,6 +416,19 @@ wss.on('connection', (ws, req) => {
checkAndLogEvent(possibleMac, key, 'input', 'boolean', eventVal, connectionId);
}
}
// Log humidity
if (key.startsWith('humidity') && typeof value.rh !== 'undefined') {
if (possibleMac) {
checkAndLogEvent(possibleMac, key, 'rh', 'range', value.rh, connectionId);
}
}
// Log temperature
if (key.startsWith('temperature') && typeof value.tC !== 'undefined') {
if (possibleMac) {
checkAndLogEvent(possibleMac, key, 'tC', 'range', value.tC, connectionId);
}
}
}
// Log extracted RSSI from NotifyFullStatus
@@ -419,6 +524,16 @@ wss.on('connection', (ws, req) => {
}
}
}
// Check for humidity updates
if (key.startsWith('humidity') && typeof value.rh !== 'undefined') {
checkAndLogEvent(mac, key, 'rh', 'range', value.rh, connectionId);
}
// Check for temperature updates
if (key.startsWith('temperature') && typeof value.tC !== 'undefined') {
checkAndLogEvent(mac, key, 'tC', 'range', value.tC, connectionId);
}
}
// Check for wifi updates in NotifyStatus

View File

@@ -62,6 +62,52 @@ function getStatusData() {
});
}
// Query channel history with time range filter
function getChannelHistory(channelId, range = '24h') {
return new Promise((resolve, reject) => {
// Calculate the start timestamp based on range
const now = new Date();
let startTime;
if (range === '1week') {
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
} else {
// Default to 24h
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
}
const sql = `
SELECT c.component, c.field, c.type, c.mac,
e.event, e.timestamp
FROM channels c
LEFT JOIN events e ON e.channel_id = c.id
WHERE c.id = ? AND (e.timestamp IS NULL OR e.timestamp >= ?)
ORDER BY e.timestamp ASC
`;
db.all(sql, [channelId, startTime.toISOString()], (err, rows) => {
if (err) reject(err);
else if (rows.length === 0) {
reject(new Error('Channel not found'));
} else {
const channel = {
id: channelId,
component: rows[0].component,
field: rows[0].field,
type: rows[0].type,
mac: rows[0].mac
};
const events = rows
.filter(r => r.timestamp !== null)
.map(r => ({
event: r.event,
timestamp: r.timestamp
}));
resolve({ channel, events });
}
});
});
}
// Broadcast event to all connected WebSocket clients
export function broadcastEvent(mac, component, field, type, event) {
const message = JSON.stringify({
@@ -103,6 +149,8 @@ const dashboardHTML = `<!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IoT Status</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
:root {
--bg-primary: #0f0f14;
@@ -481,6 +529,144 @@ const dashboardHTML = `<!DOCTYPE html>
font-family: monospace;
font-weight: 500;
}
/* Clickable channel rows */
.channels-table tbody tr {
cursor: pointer;
transition: background-color 0.15s ease;
}
.channels-table tbody tr:hover {
background: rgba(59, 130, 246, 0.1);
}
/* Modal overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
width: 90%;
max-width: 900px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
backdrop-filter: blur(10px);
transform: scale(0.95);
transition: transform 0.2s ease;
}
.modal-overlay.active .modal-content {
transform: scale(1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.modal-title {
font-size: 1.1rem;
font-weight: 600;
}
.modal-subtitle {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.15s ease;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.range-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.range-btn {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
transition: all 0.15s ease;
}
.range-btn:hover {
background: rgba(59, 130, 246, 0.1);
border-color: var(--accent-blue);
}
.range-btn.active {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: white;
}
.chart-container {
flex: 1;
min-height: 300px;
position: relative;
}
.chart-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.chart-empty {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
</style>
</head>
<body>
@@ -510,6 +696,31 @@ const dashboardHTML = `<!DOCTYPE html>
</div>
<div id="rules-container" class="rules-grid"></div>
<!-- Chart Modal -->
<div id="chart-modal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="modal-title" id="modal-title">Channel History</div>
<div class="modal-subtitle" id="modal-subtitle"></div>
</div>
<button class="modal-close" onclick="closeChartModal()">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="range-toggle">
<button class="range-btn active" data-range="24h" onclick="setChartRange('24h')">24 Hours</button>
<button class="range-btn" data-range="1week" onclick="setChartRange('1week')">1 Week</button>
</div>
<div class="chart-container">
<canvas id="history-chart"></canvas>
<div class="chart-loading" id="chart-loading">Loading...</div>
</div>
</div>
</div>
<div id="toast-container" class="toast-container"></div>
<script>
@@ -779,7 +990,7 @@ const dashboardHTML = `<!DOCTYPE html>
</thead>
<tbody>
\${device.channels.map(ch => \`
<tr>
<tr data-channel-id="\${ch.id}" onclick="openChartModal(\${ch.id}, '\${device.mac}', '\${ch.component}', '\${ch.field}')">
<td class="channel-component">\${ch.component}</td>
<td class="channel-field">\${ch.field}</td>
<td class="channel-event" data-channel="\${ch.component}_\${ch.field}">\${ch.event ?? '-'}</td>
@@ -892,6 +1103,175 @@ const dashboardHTML = `<!DOCTYPE html>
}).join('');
}
// Chart modal functions
let historyChart = null;
let currentChannelId = null;
let currentRange = '24h';
function openChartModal(channelId, mac, component, field) {
currentChannelId = channelId;
currentRange = '24h';
// Update modal content
document.getElementById('modal-title').textContent = \`\${component}.\${field}\`;
document.getElementById('modal-subtitle').textContent = mac;
// Reset range buttons
document.querySelectorAll('.range-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.range === '24h');
});
// Show modal
document.getElementById('chart-modal').classList.add('active');
// Load chart data
loadChartData(channelId, '24h');
}
function closeChartModal() {
document.getElementById('chart-modal').classList.remove('active');
if (historyChart) {
historyChart.destroy();
historyChart = null;
}
}
function setChartRange(range) {
if (range === currentRange) return;
currentRange = range;
// Update button states
document.querySelectorAll('.range-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.range === range);
});
// Reload data
loadChartData(currentChannelId, range);
}
async function loadChartData(channelId, range) {
const loadingEl = document.getElementById('chart-loading');
loadingEl.style.display = 'flex';
loadingEl.textContent = 'Loading...';
try {
const res = await fetch(\`/api/channel-history/\${channelId}?range=\${range}\`);
if (!res.ok) throw new Error('Failed to load data');
const data = await res.json();
if (data.events.length === 0) {
loadingEl.textContent = 'No data for this time range';
if (historyChart) {
historyChart.destroy();
historyChart = null;
}
return;
}
loadingEl.style.display = 'none';
renderChart(data.events, data.channel.type);
} catch (err) {
console.error('Error loading chart data:', err);
loadingEl.textContent = 'Error loading data';
}
}
function renderChart(events, channelType) {
const ctx = document.getElementById('history-chart').getContext('2d');
// Destroy existing chart
if (historyChart) {
historyChart.destroy();
}
// Process data based on type
const isBoolean = channelType === 'boolean';
const data = events.map(e => ({
x: new Date(e.timestamp),
y: isBoolean ? (e.event === 'true' || e.event === true ? 1 : 0) : parseFloat(e.event)
}));
// Chart config
const config = {
type: 'line',
data: {
datasets: [{
data: data,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: isBoolean ? 0 : 0.3,
stepped: isBoolean ? 'before' : false,
pointRadius: data.length > 100 ? 0 : 3,
pointHoverRadius: 5,
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => {
if (isBoolean) {
return context.raw.y === 1 ? 'true' : 'false';
}
return context.raw.y.toString();
}
}
}
},
scales: {
x: {
type: 'time',
time: {
unit: currentRange === '24h' ? 'hour' : 'day',
displayFormats: {
hour: 'HH:mm',
day: 'MMM dd'
}
},
grid: {
color: 'rgba(255, 255, 255, 0.05)'
},
ticks: {
color: '#a0a0b0'
}
},
y: {
beginAtZero: isBoolean,
max: isBoolean ? 1.1 : undefined,
grid: {
color: 'rgba(255, 255, 255, 0.05)'
},
ticks: {
color: '#a0a0b0',
callback: isBoolean ? (val) => val === 1 ? 'true' : val === 0 ? 'false' : '' : undefined
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
};
historyChart = new Chart(ctx, config);
}
// Close modal on overlay click or Escape key
document.getElementById('chart-modal').addEventListener('click', (e) => {
if (e.target.id === 'chart-modal') closeChartModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeChartModal();
});
connectWebSocket();
</script>
</body>
@@ -916,6 +1296,27 @@ const server = http.createServer(async (req, res) => {
return;
}
// API endpoint for channel history
const historyMatch = url.pathname.match(/^\/api\/channel-history\/(\d+)$/);
if (historyMatch) {
const channelId = parseInt(historyMatch[1], 10);
const range = url.searchParams.get('range') || '24h';
try {
const data = await getChannelHistory(channelId, range);
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
});
res.end(JSON.stringify(data));
} catch (err) {
console.error('[Status] Error fetching channel history:', err);
res.writeHead(err.message === 'Channel not found' ? 404 : 500);
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// Serve dashboard
if (url.pathname === '/' || url.pathname === '/index.html') {
res.writeHead(200, {