This commit is contained in:
sebseb7
2025-12-25 03:28:12 +01:00
parent acbf168218
commit ce87faa551
6 changed files with 1183 additions and 13 deletions

View File

@@ -216,6 +216,334 @@ module.exports = {
}
});
// =============================================
// RULES API
// =============================================
// Apply checkAuth middleware to rules API routes
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: 'TentLightLevel', type: 'number', min: 0, max: 10, description: 'Tent Light Level' },
{ channel: 'TentExhaustLevel', type: 'number', min: 0, max: 10, description: 'Tent Exhaust Fan Level' },
{ channel: 'RoomExhaustLevel', type: 'number', min: 0, max: 10, description: 'Room Exhaust 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: 'TentDehumid', type: 'boolean', min: 0, max: 1, description: 'Tent Dehumidifier' },
];
// GET /api/outputs - List output channel definitions
app.get('/api/outputs', (req, res) => {
res.json(OUTPUT_CHANNELS);
});
// GET /api/outputs/values - Get current output values
app.get('/api/outputs/values', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const result = {};
const stmt = db.prepare(`
SELECT channel, value FROM output_events
WHERE id IN (
SELECT MAX(id) FROM output_events GROUP BY channel
)
`);
const rows = stmt.all();
rows.forEach(row => {
result[row.channel] = row.value;
});
// Fill in defaults for missing channels
OUTPUT_CHANNELS.forEach(ch => {
if (result[ch.channel] === undefined) {
result[ch.channel] = 0;
}
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/rules - List all rules
app.get('/api/rules', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC');
const rows = stmt.all();
const rules = rows.map(row => ({
...row,
conditions: JSON.parse(row.conditions || '{}'),
action: JSON.parse(row.action || '{}')
}));
res.json(rules);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules - Create rule (admin only)
app.post('/api/rules', requireAdmin, (req, res) => {
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
if (!name || !conditions || !action) {
return res.status(400).json({ error: 'Missing required fields: name, conditions, action' });
}
try {
const stmt = db.prepare(`
INSERT INTO rules (name, type, enabled, conditions, action, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
name,
type,
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.user?.id || null
);
res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/rules/:id - Update rule (admin only)
app.put('/api/rules/:id', requireAdmin, (req, res) => {
const { name, type, enabled, conditions, action } = req.body;
try {
const stmt = db.prepare(`
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
WHERE id = ?
`);
const info = stmt.run(
name,
type || 'static',
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.params.id
);
if (info.changes > 0) {
res.json({ id: req.params.id, name, type, enabled, conditions, action });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/rules/:id - Delete rule (admin only)
app.delete('/api/rules/:id', requireAdmin, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM rules WHERE id = ?');
const info = stmt.run(req.params.id);
if (info.changes > 0) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules/reorder - Reorder rules (admin only)
app.post('/api/rules/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// =============================================
// 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();
// Get last value for this channel
const lastStmt = db.prepare(`
SELECT id, value FROM output_events
WHERE channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const last = lastStmt.get(channel);
if (last && Math.abs(last.value - value) < Number.EPSILON) {
// 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}`);
}
}
// 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();
const desiredOutputs = {}; // channel -> value
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);
}
}
// Start rule runner (every 10 seconds)
const ruleRunnerInterval = setInterval(runRules, 10000);
console.log('[RuleRunner] Started background job (10s interval)');
// Clean up on server close
devServer.server?.on('close', () => {
clearInterval(ruleRunnerInterval);
console.log('[RuleRunner] Stopped background job');
});
// GET /api/devices
// Returns list of unique device/channel pairs
app.get('/api/devices', (req, res) => {