u
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user