/** * Rule Engine * Loads and executes JS rule scripts from the rules/ directory */ import fs from 'fs'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); 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, onStatusUpdate = null) { db = database; sendRPCToDevice = rpcFunction; statusUpdateCallback = onStatusUpdate; } /** * Load all rule scripts from rules/ directory */ export async function loadRules() { rules = []; if (!fs.existsSync(RULES_DIR)) { console.log('Rules directory not found, creating...'); fs.mkdirSync(RULES_DIR, { recursive: true }); return; } const files = fs.readdirSync(RULES_DIR).filter(f => f.endsWith('.js')); for (const file of files) { try { const filePath = path.join(RULES_DIR, file); // Add timestamp to bust cache on reload const fileUrl = pathToFileURL(filePath).href + '?t=' + Date.now(); const module = await import(fileUrl); const rule = module.default || module; rule._filename = file; rules.push(rule); console.log(`Loaded rule: ${file}`); } catch (err) { console.error(`Error loading rule ${file}:`, err); } } console.log(`Loaded ${rules.length} rule(s)`); } /** * Cast string value to proper type based on channel type */ function castValue(value, type) { if (value === null || value === undefined) return null; switch (type) { case 'boolean': return value === 'true' || value === true; case 'range': return parseInt(value, 10); case 'enum': default: return value; } } /** * Get current state of a channel */ function getChannelState(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) reject(err); else resolve(row ? castValue(row.event, row.type) : null); } ); }); } /** * Get all current channel states */ function getAllChannelStates() { return new Promise((resolve, reject) => { db.all( `SELECT c.mac, c.component, c.field, c.type, (SELECT e.event FROM events e WHERE e.channel_id = c.id ORDER BY e.id DESC LIMIT 1) as value FROM channels c`, [], (err, rows) => { if (err) reject(err); else resolve(rows || []); } ); }); } /** * Create context object passed to rule scripts */ function createContext(triggerEvent, ruleName) { return { // The event that triggered this evaluation trigger: triggerEvent, // Get state of a specific channel getState: getChannelState, // Get all channel states getAllStates: getAllChannelStates, // Set light level (0-100) setLevel: async (mac, component, level) => { if (sendRPCToDevice) { await sendRPCToDevice(mac, 'Light.Set', { id: parseInt(component.split(':')[1]), brightness: level }); } }, // Set switch output setOutput: async (mac, component, on) => { if (sendRPCToDevice) { await sendRPCToDevice(mac, 'Switch.Set', { id: parseInt(component.split(':')[1]), on: on }); } }, // Generic RPC call sendRPC: async (mac, method, params) => { if (sendRPCToDevice) { await sendRPCToDevice(mac, method, params); } }, // Push status update to dashboard updateStatus: (status) => { if (statusUpdateCallback) { statusUpdateCallback(ruleName, status); } }, // Logging log: (...args) => console.log('[Rule]', ...args) }; } /** * Run all rules after an event occurs */ 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 }; 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') { await rule(ctx); } } catch (err) { console.error(`Error running rule ${rule._filename || 'unknown'}:`, err); } } } /** * Reload rules from disk */ export async function reloadRules() { console.log('Reloading rules...'); 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 */ export function watchRules() { if (!fs.existsSync(RULES_DIR)) { fs.mkdirSync(RULES_DIR, { recursive: true }); } let reloadTimeout = null; fs.watch(RULES_DIR, (eventType, filename) => { if (filename && filename.endsWith('.js')) { // Debounce reloads if (reloadTimeout) clearTimeout(reloadTimeout); reloadTimeout = setTimeout(async () => { console.log(`Rule file changed: ${filename}`); await reloadRules(); }, 500); } }); console.log('Watching rules/ directory for changes'); }