Files
shellySrv/rule_engine.js

205 lines
5.5 KiB
JavaScript

/**
* 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)`);
}
/**
* 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) {
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) {
// 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 {
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');
}