From e226032d0be65d45bd4006b2518e1a2eef9e5f94 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Sun, 18 Jan 2026 05:52:54 -0500 Subject: [PATCH] feat: Implement real-time rule status updates and dashboard display via WebSockets. --- rule_engine.js | 45 +++++++- rules/waterButtonLearnAndCall.js | 35 +++++++ server.js | 4 +- status_server.js | 169 ++++++++++++++++++++++++++++++- 4 files changed, 247 insertions(+), 6 deletions(-) diff --git a/rule_engine.js b/rule_engine.js index 0f0bfa3..8c24b54 100644 --- a/rule_engine.js +++ b/rule_engine.js @@ -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,36 @@ 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; +} + /** * Watch rules directory for changes and auto-reload */ diff --git a/rules/waterButtonLearnAndCall.js b/rules/waterButtonLearnAndCall.js index e4a10de..24eae54 100644 --- a/rules/waterButtonLearnAndCall.js +++ b/rules/waterButtonLearnAndCall.js @@ -71,6 +71,14 @@ function getState(mac) { } export default { + getStatus() { + const state = getState(WATER_BUTTON_MAC); + return { + storedDuration: state.storedDuration, + countMode: state.countMode, + timerActive: state.timer !== null + }; + }, 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) { @@ -159,6 +167,14 @@ export default { 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); @@ -169,10 +185,21 @@ export default { // Normal mode - schedule light to turn on after stored duration ctx.log(`Light off. Will turn on in ${state.storedDuration}ms`); + // Push status update to dashboard + ctx.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; + + // Can't use ctx.updateStatus here since ctx is out of scope + // The timer completing will be reflected in next getStatus() call }, state.storedDuration); } } @@ -202,6 +229,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...' + }); } } }; diff --git a/server.js b/server.js index 01840be..cc21a49 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ 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'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -174,7 +174,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 diff --git a/status_server.js b/status_server.js index 9740034..978bbde 100644 --- a/status_server.js +++ b/status_server.js @@ -4,6 +4,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { WebSocketServer } from 'ws'; import sqlite3 from 'sqlite3'; +import { getRulesStatus } from './rule_engine.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -79,6 +80,21 @@ 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 = ` @@ -401,6 +417,70 @@ const dashboardHTML = ` 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; + } @@ -421,6 +501,15 @@ const dashboardHTML = ` +
+ + + + + Rules +
+
+
@@ -694,7 +860,8 @@ 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); }