From 7d96ed29c42f731f9eaf09abab765b95a1518278 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Fri, 27 Feb 2026 11:22:49 -0500 Subject: [PATCH] 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. --- .env.example | 4 + .gitignore | 4 +- rules/waterButtonLearnAndCall.js | 412 +++++++++++++++++++++++++------ server.js | 119 ++++++++- status_server.js | 403 +++++++++++++++++++++++++++++- 5 files changed, 862 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index eabb05c..31e3a9b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 030f340..83d80cd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ logs/ devices.db rules/timer_state.json .env -tapo/ \ No newline at end of file +tapo/ +rules/telegram_authorized_users.json +server.log \ No newline at end of file diff --git a/rules/waterButtonLearnAndCall.js b/rules/waterButtonLearnAndCall.js index 61f04ef..bc91f72 100644 --- a/rules/waterButtonLearnAndCall.js +++ b/rules/waterButtonLearnAndCall.js @@ -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 — Set timer (e.g. 30s, 2m, 10000)\n` + + ` — 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 \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 1–300.', 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, diff --git a/server.js b/server.js index 253b255..c80799e 100644 --- a/server.js +++ b/server.js @@ -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 diff --git a/status_server.js b/status_server.js index 567465e..ed18536 100644 --- a/status_server.js +++ b/status_server.js @@ -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 = ` IoT Status + + @@ -510,6 +696,31 @@ const dashboardHTML = `
+ + +
@@ -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, {