This commit is contained in:
sebseb7
2025-12-25 05:56:44 +01:00
parent a09da9a835
commit 22701a2614
2 changed files with 207 additions and 201 deletions

View File

@@ -246,6 +246,10 @@ function handleAgentMessage(ws, message, clientState, clientId) {
try {
const validReadings = readings.filter(r => r.device && r.channel && (r.value !== undefined || r.data !== undefined));
const result = insertReadingsSmart(clientState.devicePrefix, validReadings);
// Trigger rules immediately on new data
if (runRules) runRules();
ws.send(JSON.stringify({ type: 'ack', count: result.inserted + result.updated }));
} catch (err) {
console.error('[WS] Error inserting readings:', err.message);
@@ -321,6 +325,202 @@ function syncOutputStates() {
// Start output state sync interval (every 60s)
setInterval(syncOutputStates, 60000);
// =============================================
// RULE ENGINE (Global Scope)
// =============================================
// Virtual output channel definitions
const OUTPUT_CHANNELS = [
{ channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation Fan Level' },
{ channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' },
{ channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
{ channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' },
];
// Get current sensor value
function getSensorValue(channel) {
// channel format: "device:channel" e.g. "ac:controller:co2"
const lastColonIndex = channel.lastIndexOf(':');
if (lastColonIndex === -1) return null;
const device = channel.substring(0, lastColonIndex);
const ch = channel.substring(lastColonIndex + 1);
const stmt = db.prepare(`
SELECT value FROM sensor_events
WHERE device = ? AND channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const row = stmt.get(device, ch);
return row ? row.value : null;
}
// Get current output value
function getOutputValue(channel) {
const stmt = db.prepare(`
SELECT value FROM output_events
WHERE channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const row = stmt.get(channel);
return row ? row.value : 0;
}
// Write output value with RLE
function writeOutputValue(channel, value) {
const now = new Date().toISOString();
const lastStmt = db.prepare(`
SELECT id, value FROM output_events
WHERE channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const last = lastStmt.get(channel);
const valueChanged = !last || Math.abs(last.value - value) >= Number.EPSILON;
if (!valueChanged) {
// Same value - update the until timestamp (RLE)
const updateStmt = db.prepare('UPDATE output_events SET until = ? WHERE id = ?');
updateStmt.run(now, last.id);
} else {
// New value - insert new record
const insertStmt = db.prepare(`
INSERT INTO output_events (timestamp, until, channel, value, data_type)
VALUES (?, NULL, ?, ?, 'number')
`);
insertStmt.run(now, channel, value);
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
// Send command to bound physical device
const binding = OUTPUT_BINDINGS[channel];
if (binding) {
let commandValue = value;
if (binding.type === 'switch') {
commandValue = value > 0 ? 1 : 0;
}
console.log(`[RuleRunner] Binding for ${channel}: type=${binding.type}, val=${value}, cmdVal=${commandValue}`);
sendCommandToDevicePrefix(`${binding.device}:`, {
device: binding.channel,
action: 'set_state',
value: commandValue
});
}
}
}
// Compare values with operator
function compareValues(actual, operator, target) {
if (actual === null || actual === undefined) return false;
switch (operator) {
case '=':
case '==': return actual === target;
case '!=': return actual !== target;
case '<': return actual < target;
case '>': return actual > target;
case '<=': return actual <= target;
case '>=': return actual >= target;
default: return false;
}
}
// Evaluate a single condition
function evaluateCondition(condition) {
const { type, operator, value, channel } = condition;
// Handle AND/OR groups
if (operator === 'AND' || operator === 'OR') {
const results = (condition.conditions || []).map(c => evaluateCondition(c));
return operator === 'AND'
? results.every(r => r)
: results.some(r => r);
}
switch (type) {
case 'time': {
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight
if (operator === 'between' && Array.isArray(value)) {
const [start, end] = value.map(t => {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
});
return currentTime >= start && currentTime <= end;
}
const [h, m] = String(value).split(':').map(Number);
const targetTime = h * 60 + m;
return compareValues(currentTime, operator, targetTime);
}
case 'date': {
const now = new Date();
const today = now.toISOString().split('T')[0];
if (operator === 'between' && Array.isArray(value)) {
return today >= value[0] && today <= value[1];
}
if (operator === 'before') return today < value;
if (operator === 'after') return today > value;
return today === value;
}
case 'sensor': {
const sensorValue = getSensorValue(channel);
return compareValues(sensorValue, operator, value);
}
case 'output': {
const outputValue = getOutputValue(channel);
return compareValues(outputValue, operator, value);
}
default:
console.warn(`[RuleRunner] Unknown condition type: ${type}`);
return false;
}
}
// Run all rules
function runRules() {
if (!db) return;
try {
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
// Default all outputs to OFF (0) - if no rule sets them, they stay off
const desiredOutputs = {};
for (const ch of OUTPUT_CHANNELS) {
desiredOutputs[ch.channel] = 0;
}
for (const rule of rules) {
try {
const conditions = JSON.parse(rule.conditions || '{}');
const action = JSON.parse(rule.action || '{}');
if (evaluateCondition(conditions)) {
// Rule matches - set output (later rules override)
if (action.channel && action.value !== undefined) {
desiredOutputs[action.channel] = action.value;
}
}
} catch (err) {
console.error(`[RuleRunner] Error evaluating rule ${rule.id}:`, err.message);
}
}
// Write output values
for (const [channel, value] of Object.entries(desiredOutputs)) {
writeOutputValue(channel, value);
}
} catch (err) {
console.error('[RuleRunner] Error running rules:', err.message);
}
}
// Also sync immediately on startup after a short delay
setTimeout(syncOutputStates, 5000);
@@ -531,12 +731,7 @@ module.exports = {
app.use('/api/rules', checkAuth);
// Virtual output channel definitions
const OUTPUT_CHANNELS = [
{ channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation Fan Level' },
{ channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' },
{ channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
{ channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' },
];
// Virtual output channel definitions - MOVED TO GLOBAL SCOPE
// GET /api/outputs - List output channel definitions
app.get('/api/outputs', (req, res) => {
@@ -642,6 +837,7 @@ module.exports = {
JSON.stringify(action),
req.user?.id || null
);
runRules(); // Trigger rules immediately
res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -665,6 +861,7 @@ module.exports = {
req.params.id
);
if (info.changes > 0) {
runRules(); // Trigger rules immediately
res.json({ id: req.params.id, name, type, enabled, conditions, action });
} else {
res.status(404).json({ error: 'Rule not found' });
@@ -680,6 +877,7 @@ module.exports = {
const stmt = db.prepare('DELETE FROM rules WHERE id = ?');
const info = stmt.run(req.params.id);
if (info.changes > 0) {
runRules(); // Trigger rules immediately
res.json({ success: true });
} else {
res.status(404).json({ error: 'Rule not found' });
@@ -703,6 +901,7 @@ module.exports = {
try {
updateMany(order);
runRules(); // Trigger rules immediately
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -713,191 +912,7 @@ module.exports = {
// RULE RUNNER (Background Job)
// =============================================
// Get current sensor value
function getSensorValue(channel) {
// channel format: "device:channel" e.g. "ac:controller:co2"
const lastColonIndex = channel.lastIndexOf(':');
if (lastColonIndex === -1) return null;
const device = channel.substring(0, lastColonIndex);
const ch = channel.substring(lastColonIndex + 1);
const stmt = db.prepare(`
SELECT value FROM sensor_events
WHERE device = ? AND channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const row = stmt.get(device, ch);
return row ? row.value : null;
}
// Get current output value
function getOutputValue(channel) {
const stmt = db.prepare(`
SELECT value FROM output_events
WHERE channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const row = stmt.get(channel);
return row ? row.value : 0;
}
// Write output value with RLE
function writeOutputValue(channel, value) {
const now = new Date().toISOString();
const lastStmt = db.prepare(`
SELECT id, value FROM output_events
WHERE channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const last = lastStmt.get(channel);
// Debug log removed
const valueChanged = !last || Math.abs(last.value - value) >= Number.EPSILON;
if (!valueChanged) {
// Same value - update the until timestamp (RLE)
const updateStmt = db.prepare('UPDATE output_events SET until = ? WHERE id = ?');
updateStmt.run(now, last.id);
} else {
// New value - insert new record
const insertStmt = db.prepare(`
INSERT INTO output_events (timestamp, until, channel, value, data_type)
VALUES (?, NULL, ?, ?, 'number')
`);
insertStmt.run(now, channel, value);
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
// Send command to bound physical device
const binding = OUTPUT_BINDINGS[channel];
if (binding) {
let commandValue = value;
if (binding.type === 'switch') {
commandValue = value > 0 ? 1 : 0;
}
console.log(`[RuleRunner] Binding for ${channel}: type=${binding.type}, val=${value}, cmdVal=${commandValue}`);
sendCommandToDevicePrefix(`${binding.device}:`, {
device: binding.channel,
action: 'set_state',
value: commandValue
});
}
}
}
// Compare values with operator
function compareValues(actual, operator, target) {
if (actual === null || actual === undefined) return false;
switch (operator) {
case '=':
case '==': return actual === target;
case '!=': return actual !== target;
case '<': return actual < target;
case '>': return actual > target;
case '<=': return actual <= target;
case '>=': return actual >= target;
default: return false;
}
}
// Evaluate a single condition
function evaluateCondition(condition) {
const { type, operator, value, channel } = condition;
// Handle AND/OR groups
if (operator === 'AND' || operator === 'OR') {
const results = (condition.conditions || []).map(c => evaluateCondition(c));
return operator === 'AND'
? results.every(r => r)
: results.some(r => r);
}
switch (type) {
case 'time': {
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight
if (operator === 'between' && Array.isArray(value)) {
const [start, end] = value.map(t => {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
});
return currentTime >= start && currentTime <= end;
}
const [h, m] = String(value).split(':').map(Number);
const targetTime = h * 60 + m;
return compareValues(currentTime, operator, targetTime);
}
case 'date': {
const now = new Date();
const today = now.toISOString().split('T')[0];
if (operator === 'between' && Array.isArray(value)) {
return today >= value[0] && today <= value[1];
}
if (operator === 'before') return today < value;
if (operator === 'after') return today > value;
return today === value;
}
case 'sensor': {
const sensorValue = getSensorValue(channel);
return compareValues(sensorValue, operator, value);
}
case 'output': {
const outputValue = getOutputValue(channel);
return compareValues(outputValue, operator, value);
}
default:
console.warn(`[RuleRunner] Unknown condition type: ${type}`);
return false;
}
}
// Run all rules
function runRules() {
if (!db) return;
try {
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
// Default all outputs to OFF (0) - if no rule sets them, they stay off
const desiredOutputs = {};
for (const ch of OUTPUT_CHANNELS) {
desiredOutputs[ch.channel] = 0;
}
for (const rule of rules) {
try {
const conditions = JSON.parse(rule.conditions || '{}');
const action = JSON.parse(rule.action || '{}');
if (evaluateCondition(conditions)) {
// Rule matches - set output (later rules override)
if (action.channel && action.value !== undefined) {
desiredOutputs[action.channel] = action.value;
}
}
} catch (err) {
console.error(`[RuleRunner] Error evaluating rule ${rule.id}:`, err.message);
}
}
// Write output values
for (const [channel, value] of Object.entries(desiredOutputs)) {
writeOutputValue(channel, value);
}
} catch (err) {
console.error('[RuleRunner] Error running rules:', err.message);
}
}
// Rule Engine functions moved to global scope
// Start rule runner (every 10 seconds)
const ruleRunnerInterval = setInterval(runRules, 10000);