diff --git a/server/src/db/schema.js b/server/src/db/schema.js index f830ef3..40fa543 100644 --- a/server/src/db/schema.js +++ b/server/src/db/schema.js @@ -77,6 +77,35 @@ export function initDatabase(dbPath) { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(created_by) REFERENCES users(id) ); + + -- Rules for automation + CREATE TABLE IF NOT EXISTS rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT DEFAULT 'static', + enabled INTEGER DEFAULT 1, + position INTEGER DEFAULT 0, + conditions TEXT NOT NULL, -- JSON (nested AND/OR structure) + action TEXT NOT NULL, -- JSON object + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(created_by) REFERENCES users(id) + ); + + -- Output events with RLE (same structure as sensor_events) + CREATE TABLE IF NOT EXISTS output_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME NOT NULL, + until DATETIME, + channel TEXT NOT NULL, + value REAL, + data TEXT, + data_type TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_output_events_search + ON output_events(channel, timestamp); `); console.log('[DB] Database initialized successfully'); diff --git a/uiserver/src/App.js b/uiserver/src/App.js index 7ef1007..4bee386 100644 --- a/uiserver/src/App.js +++ b/uiserver/src/App.js @@ -5,12 +5,14 @@ import { ThemeProvider, createTheme } from '@mui/material/styles'; import SettingsIcon from '@mui/icons-material/Settings'; import ShowChartIcon from '@mui/icons-material/ShowChart'; import DashboardIcon from '@mui/icons-material/Dashboard'; +import RuleIcon from '@mui/icons-material/Rule'; import Settings from './components/Settings'; import Chart from './components/Chart'; import Login from './components/Login'; import ViewManager from './components/ViewManager'; import ViewDisplay from './components/ViewDisplay'; +import RuleEditor from './components/RuleEditor'; const darkTheme = createTheme({ palette: { @@ -98,6 +100,9 @@ export default class App extends Component { + {user && user.role === 'admin' && ( + + )} {user ? ( @@ -111,6 +116,7 @@ export default class App extends Component { } /> } /> + } /> c.id === id); if (item) { @@ -240,6 +241,7 @@ export default class Chart extends Component { if (item.yAxis) yAxisKey = item.yAxis; if (item.color) color = item.color; if (item.fillColor) fillColor = item.fillColor; + if (item.fillOpacity !== undefined) fillOpacity = item.fillOpacity; } } @@ -251,15 +253,10 @@ export default class Chart extends Component { yAxisKey: yAxisKey, }; if (color) sObj.color = color; - // Enable area fill if fillColor is set (with 50% transparency) + // Enable area fill if fillColor is set (with configurable opacity) if (fillColor) { sObj.area = true; - // Convert hex to rgba with 50% opacity - const hex = fillColor.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - sObj.areaColor = `rgba(${r}, ${g}, ${b}, 0.5)`; + sObj.fillOpacity = fillOpacity; } return sObj; }); @@ -310,7 +307,7 @@ export default class Chart extends Component { strokeWidth: 3, }, '& .MuiAreaElement-root': { - fillOpacity: 0.5, + fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5, }, }} /> diff --git a/uiserver/src/components/RuleEditor.js b/uiserver/src/components/RuleEditor.js new file mode 100644 index 0000000..42ed8fb --- /dev/null +++ b/uiserver/src/components/RuleEditor.js @@ -0,0 +1,651 @@ +import React, { Component } from 'react'; +import { + Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon, + Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, + FormControl, InputLabel, Select, MenuItem, Box, IconButton, Switch, + FormControlLabel, Chip, Divider, Tooltip +} from '@mui/material'; +import RuleIcon from '@mui/icons-material/Rule'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import PauseIcon from '@mui/icons-material/Pause'; + +// Condition operators by type +const CONDITION_OPERATORS = { + time: [ + { value: 'between', label: 'Between' }, + { value: '=', label: '=' }, + { value: '<', label: '<' }, + { value: '>', label: '>' } + ], + date: [ + { value: 'before', label: 'Before' }, + { value: 'after', label: 'After' }, + { value: 'between', label: 'Between' } + ], + sensor: [ + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, + { value: '<', label: '<' }, + { value: '>', label: '>' }, + { value: '<=', label: '<=' }, + { value: '>=', label: '>=' } + ], + output: [ + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, + { value: '<', label: '<' }, + { value: '>', label: '>' }, + { value: '<=', label: '<=' }, + { value: '>=', label: '>=' } + ] +}; + +class RuleEditor extends Component { + constructor(props) { + super(props); + this.state = { + rules: [], + outputChannels: [], + devices: [], + outputValues: {}, + + // Dialog state + open: false, + editingId: null, + ruleName: '', + ruleEnabled: true, + conditions: { operator: 'AND', conditions: [] }, + action: { channel: '', value: 0 } + }; + } + + componentDidMount() { + this.refreshRules(); + this.loadOutputChannels(); + this.loadDevices(); + this.loadOutputValues(); + // Refresh output values every 10s + this.refreshInterval = setInterval(() => this.loadOutputValues(), 10000); + } + + componentWillUnmount() { + if (this.refreshInterval) clearInterval(this.refreshInterval); + } + + isAdmin() { + const { user } = this.props; + return user && user.role === 'admin'; + } + + refreshRules = () => { + fetch('/api/rules') + .then(res => res.json()) + .then(rules => this.setState({ rules })) + .catch(console.error); + }; + + loadOutputChannels = () => { + fetch('/api/outputs') + .then(res => res.json()) + .then(outputChannels => this.setState({ outputChannels })) + .catch(console.error); + }; + + loadDevices = () => { + fetch('/api/devices') + .then(res => res.json()) + .then(devices => this.setState({ devices })) + .catch(console.error); + }; + + loadOutputValues = () => { + fetch('/api/outputs/values') + .then(res => res.json()) + .then(outputValues => this.setState({ outputValues })) + .catch(console.error); + }; + + // Dialog handlers + handleOpenCreate = () => { + this.setState({ + editingId: null, + ruleName: '', + ruleEnabled: true, + conditions: { operator: 'AND', conditions: [] }, + action: { channel: this.state.outputChannels[0]?.channel || '', value: 0 }, + open: true + }); + }; + + handleOpenEdit = (rule, e) => { + e.stopPropagation(); + this.setState({ + editingId: rule.id, + ruleName: rule.name, + ruleEnabled: !!rule.enabled, + conditions: rule.conditions || { operator: 'AND', conditions: [] }, + action: rule.action || { channel: '', value: 0 }, + open: true + }); + }; + + handleDelete = async (id, e) => { + e.stopPropagation(); + if (!window.confirm("Delete this rule?")) return; + const { user } = this.props; + await fetch(`/api/rules/${id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${user.token}` } + }); + this.refreshRules(); + }; + + moveRule = async (idx, dir) => { + const newRules = [...this.state.rules]; + const target = idx + dir; + if (target < 0 || target >= newRules.length) return; + + [newRules[idx], newRules[target]] = [newRules[target], newRules[idx]]; + this.setState({ rules: newRules }); + + const order = newRules.map((r, i) => ({ id: r.id, position: i })); + const { user } = this.props; + + try { + await fetch('/api/rules/reorder', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.token}` + }, + body: JSON.stringify({ order }) + }); + } catch (err) { + console.error("Failed to save order", err); + } + }; + + handleSave = async () => { + const { ruleName, ruleEnabled, conditions, action, editingId } = this.state; + const { user } = this.props; + + if (!ruleName || !action.channel) { + alert('Please fill in all required fields'); + return; + } + + const url = editingId ? `/api/rules/${editingId}` : '/api/rules'; + const method = editingId ? 'PUT' : 'POST'; + + try { + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.token}` + }, + body: JSON.stringify({ + name: ruleName, + enabled: ruleEnabled, + conditions, + action + }) + }); + + if (res.ok) { + this.setState({ open: false }); + this.refreshRules(); + } else { + const err = await res.json(); + alert('Failed to save rule: ' + err.error); + } + } catch (err) { + console.error(err); + } + }; + + toggleRuleEnabled = async (rule) => { + const { user } = this.props; + try { + await fetch(`/api/rules/${rule.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.token}` + }, + body: JSON.stringify({ + ...rule, + enabled: !rule.enabled + }) + }); + this.refreshRules(); + } catch (err) { + console.error(err); + } + }; + + // Condition editing + addCondition = (parentPath = []) => { + this.setState(prev => { + const newConditions = JSON.parse(JSON.stringify(prev.conditions)); + let target = newConditions; + for (const idx of parentPath) { + target = target.conditions[idx]; + } + target.conditions.push({ + type: 'sensor', + operator: '>', + channel: '', + value: 0 + }); + return { conditions: newConditions }; + }); + }; + + addConditionGroup = (parentPath = [], groupType = 'AND') => { + this.setState(prev => { + const newConditions = JSON.parse(JSON.stringify(prev.conditions)); + let target = newConditions; + for (const idx of parentPath) { + target = target.conditions[idx]; + } + target.conditions.push({ + operator: groupType, + conditions: [] + }); + return { conditions: newConditions }; + }); + }; + + updateCondition = (path, updates) => { + this.setState(prev => { + const newConditions = JSON.parse(JSON.stringify(prev.conditions)); + let target = newConditions; + for (let i = 0; i < path.length - 1; i++) { + target = target.conditions[path[i]]; + } + const idx = path[path.length - 1]; + target.conditions[idx] = { ...target.conditions[idx], ...updates }; + return { conditions: newConditions }; + }); + }; + + removeCondition = (path) => { + this.setState(prev => { + const newConditions = JSON.parse(JSON.stringify(prev.conditions)); + let target = newConditions; + for (let i = 0; i < path.length - 1; i++) { + target = target.conditions[path[i]]; + } + const idx = path[path.length - 1]; + target.conditions.splice(idx, 1); + return { conditions: newConditions }; + }); + }; + + toggleGroupOperator = (path) => { + this.setState(prev => { + const newConditions = JSON.parse(JSON.stringify(prev.conditions)); + let target = newConditions; + for (const idx of path) { + target = target.conditions[idx]; + } + if (path.length === 0) { + // Root level + newConditions.operator = newConditions.operator === 'AND' ? 'OR' : 'AND'; + } else { + target.operator = target.operator === 'AND' ? 'OR' : 'AND'; + } + return { conditions: newConditions }; + }); + }; + + // Render a condition group recursively + renderConditionGroup = (group, path = []) => { + const { devices, outputChannels } = this.state; + const isRoot = path.length === 0; + + // Build sensor channels list + const sensorChannels = devices.map(d => `${d.device}:${d.channel}`); + + return ( + + + this.toggleGroupOperator(path) : undefined} + sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }} + /> + {this.isAdmin() && ( + <> + + + + + )} + + + {group.conditions?.map((cond, idx) => { + const condPath = [...path, idx]; + + // Nested group + if (cond.operator === 'AND' || cond.operator === 'OR') { + return ( + + {this.renderConditionGroup(cond, condPath)} + {this.isAdmin() && ( + this.removeCondition(condPath)}> + + + )} + + ); + } + + // Single condition + return ( + + + + {(cond.type === 'sensor' || cond.type === 'output') && ( + + )} + + this.setState({ action: { ...action, channel: e.target.value } })} + sx={{ minWidth: 200 }} + > + {outputChannels.map(ch => ( + + {ch.description} ({ch.channel}) + + ))} + + to + this.setState({ action: { ...action, value: parseFloat(e.target.value) || 0 } })} + inputProps={{ + min: outputChannels.find(c => c.channel === action.channel)?.min || 0, + max: outputChannels.find(c => c.channel === action.channel)?.max || 10 + }} + sx={{ width: 100 }} + /> + + + + + + + + + ); + } +} + +export default RuleEditor; diff --git a/uiserver/src/components/ViewManager.js b/uiserver/src/components/ViewManager.js index c072b5b..5fcd7fa 100644 --- a/uiserver/src/components/ViewManager.js +++ b/uiserver/src/components/ViewManager.js @@ -3,7 +3,7 @@ import { Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton, - ToggleButton, ToggleButtonGroup + ToggleButton, ToggleButtonGroup, Slider } from '@mui/material'; import DashboardIcon from '@mui/icons-material/Dashboard'; import AddIcon from '@mui/icons-material/Add'; @@ -42,6 +42,8 @@ class ViewManager extends Component { super(props); this.state = { views: [], + rules: [], + outputValues: {}, open: false, colorPickerOpen: false, colorPickerMode: 'line', @@ -69,6 +71,13 @@ class ViewManager extends Component { componentDidMount() { this.refreshViews(); + this.loadRules(); + this.loadOutputValues(); + // Refresh rules and outputs every 30s + this.rulesInterval = setInterval(() => { + this.loadRules(); + this.loadOutputValues(); + }, 30000); if (this.isAdmin()) { fetch('/api/devices') .then(res => res.json()) @@ -77,6 +86,10 @@ class ViewManager extends Component { } } + componentWillUnmount() { + if (this.rulesInterval) clearInterval(this.rulesInterval); + } + componentDidUpdate(prevProps) { if (prevProps.user !== this.props.user) { this.refreshViews(); @@ -101,6 +114,20 @@ class ViewManager extends Component { .catch(console.error); }; + loadRules = () => { + fetch('/api/rules') + .then(res => res.json()) + .then(rules => this.setState({ rules })) + .catch(console.error); + }; + + loadOutputValues = () => { + fetch('/api/outputs/values') + .then(res => res.json()) + .then(outputValues => this.setState({ outputValues })) + .catch(console.error); + }; + parseViewData(view) { let channels = []; let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } }; @@ -131,6 +158,73 @@ class ViewManager extends Component { return { channels, axes }; } + // Emoji for rule based on action channel + getRuleEmoji = (rule) => { + const channel = rule.action?.channel || ''; + const emojis = { + 'CircFanLevel': '🌀', + 'TentLightLevel': '💡', + 'TentExhaustLevel': '💨', + 'RoomExhaustLevel': '🌬️', + 'CO2Valve': '🫧', + 'BigDehumid': '💧', + 'TentDehumid': '💦' + }; + return emojis[channel] || '⚡'; + }; + + // Format conditions for display + formatRuleConditions = (condition) => { + if (!condition) return '(always)'; + + if (condition.operator === 'AND' || condition.operator === 'OR') { + const parts = (condition.conditions || []).map(c => this.formatRuleConditions(c)).filter(Boolean); + if (parts.length === 0) return '(always)'; + const sep = condition.operator === 'AND' ? ' & ' : ' | '; + return parts.join(sep); + } + + const { type, channel, operator, value } = condition; + const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' }; + const op = opSymbols[operator] || operator; + + switch (type) { + case 'time': + if (operator === 'between' && Array.isArray(value)) { + return `🕐 ${value[0]} - ${value[1]}`; + } + return `🕐 ${op} ${value}`; + case 'date': + if (operator === 'between' && Array.isArray(value)) { + return `📅 ${value[0]} to ${value[1]}`; + } + return `📅 ${operator} ${value}`; + case 'sensor': + const sensorName = channel?.split(':').pop() || channel; + return `📡 ${sensorName} ${op} ${value}`; + case 'output': + return `⚙️ ${channel} ${op} ${value}`; + default: + return '?'; + } + }; + + // Format action for display + formatRuleAction = (action) => { + if (!action?.channel) return '?'; + const channelNames = { + 'CircFanLevel': '🌀 Circ Fan', + 'TentLightLevel': '💡 Tent Light', + 'TentExhaustLevel': '💨 Tent Exhaust', + 'RoomExhaustLevel': '🌬️ Room Exhaust', + 'CO2Valve': '🫧 CO2', + 'BigDehumid': '💧 Big Dehumid', + 'TentDehumid': '💦 Tent Dehumid' + }; + const name = channelNames[action.channel] || action.channel; + return `${name} = ${action.value}`; + }; + getNextColor(idx) { return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length]; } @@ -305,6 +399,17 @@ class ViewManager extends Component { clearFillColor = (idx) => { const newConfig = [...this.state.viewConfig]; delete newConfig[idx].fillColor; + delete newConfig[idx].fillOpacity; + this.setState({ viewConfig: newConfig }); + }; + + updateFillOpacity = (idx, value) => { + const newConfig = this.state.viewConfig.map((ch, i) => { + if (i === idx) { + return { ...ch, fillOpacity: value }; + } + return ch; + }); this.setState({ viewConfig: newConfig }); }; @@ -391,7 +496,8 @@ class ViewManager extends Component { alias: c.alias, yAxis: c.yAxis || 'left', color: c.color, - fillColor: c.fillColor + fillColor: c.fillColor, + fillOpacity: c.fillOpacity }))} axisConfig={axes} windowEnd={windowEnd} @@ -402,6 +508,47 @@ class ViewManager extends Component { ); })} {views.length === 0 && No views available.} + + {/* Rules Summary */} + {this.state.rules.length > 0 && ( + + 🤖 Active Rules + + {this.state.rules.filter(r => r.enabled).map((rule, idx) => ( + + + {this.getRuleEmoji(rule)} + + + + {rule.name} + + + {this.formatRuleConditions(rule.conditions)} → {this.formatRuleAction(rule.action)} + + + + #{idx + 1} + + + ))} + + + 📊 Current outputs: {Object.entries(this.state.outputValues).filter(([k, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(', ') || 'all off'} + + + )} this.setState({ open: false })} maxWidth="md" fullWidth> @@ -440,9 +587,21 @@ class ViewManager extends Component { {ch.fillColor && ( - this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}> - - + <> + this.updateFillOpacity(idx, val)} + sx={{ width: 60, ml: 1 }} + title="Fill opacity" + /> + this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}> + + + )} { + 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) => {