feat: Implement a rule engine for scriptable device automation, including an example timer light rule.
This commit is contained in:
186
rule_engine.js
Normal file
186
rule_engine.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Initialize rule engine with database and RPC function
|
||||
*/
|
||||
export function initRuleEngine(database, rpcFunction) {
|
||||
db = database;
|
||||
sendRPCToDevice = rpcFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state of a channel
|
||||
*/
|
||||
function getChannelState(mac, component, field) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT e.event 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 ? row.event : 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) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
// Logging
|
||||
log: (...args) => console.log('[Rule]', ...args)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all rules after an event occurs
|
||||
*/
|
||||
export async function runRules(mac, component, field, type, event) {
|
||||
const triggerEvent = { mac, component, field, type, event };
|
||||
const ctx = createContext(triggerEvent);
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
Reference in New Issue
Block a user