Files
tischlerCtrl/uiserver/webpack.config.js
sebseb7 1dfa59ae13 u
2025-12-26 01:23:33 +01:00

1275 lines
50 KiB
JavaScript

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const Database = require('better-sqlite3');
const { config } = require('dotenv');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { WebSocketServer } = require('ws');
// Load env vars
config();
// Database connection for Dev Server API
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../server/data/sensors.db');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-me';
const WS_PORT = process.env.WS_PORT || 3962;
let db;
try {
db = new Database(dbPath);
console.log(`[UI Server] Connected to database at ${dbPath}`);
// Create changelog table
db.exec(`
CREATE TABLE IF NOT EXISTS changelog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
user TEXT,
text TEXT NOT NULL
)
`);
// Helper to insert changelog entry
global.insertChangelog = (user, text) => {
try {
if (!db) return;
const stmt = db.prepare('INSERT INTO changelog (date, user, text) VALUES (?, ?, ?)');
stmt.run(new Date().toISOString(), user || 'system', text);
console.log(`[Changelog] ${user || 'system'}: ${text}`);
} catch (err) {
console.error('[Changelog] Error inserting entry:', err.message);
}
};
} catch (err) {
console.error(`[UI Server] Failed to connect to database at ${dbPath}:`, err.message);
}
// Output bindings: map virtual outputs to physical devices
// Format: outputChannel -> { device, channel, type }
const OUTPUT_BINDINGS = {
'BigDehumid': { device: 'tapo', channel: 'r0', type: 'switch' },
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' },
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' },
'CircFanLevel': { device: 'ac', channel: 'tent:fan', type: 'level' },
'RoomExhaust': { device: 'ac', channel: 'wall-fan', type: 'level' },
};
// =============================================
// WebSocket Server for Agents (port 3962)
// =============================================
// Track authenticated clients by devicePrefix
const agentClients = new Map(); // devicePrefix -> Set<ws>
function validateApiKey(apiKey) {
if (!db) return null;
try {
const stmt = db.prepare('SELECT id, name, device_prefix FROM api_keys WHERE key = ?');
const result = stmt.get(apiKey);
if (result) {
// Update last_used_at timestamp
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(result.id);
}
return result || null;
} catch (err) {
console.error('[WS] Error validating API key:', err.message);
return null;
}
}
function insertReadingsSmart(devicePrefix, readings) {
if (!db) throw new Error('Database not connected');
const isoTimestamp = new Date().toISOString();
const stmtLast = db.prepare(`
SELECT id, value, data, data_type
FROM sensor_events
WHERE device = ? AND channel = ?
ORDER BY timestamp DESC
LIMIT 1
`);
const stmtUpdate = db.prepare(`
UPDATE sensor_events SET until = ? WHERE id = ?
`);
const stmtInsert = db.prepare(`
INSERT INTO sensor_events (timestamp, until, device, channel, value, data, data_type)
VALUES (?, NULL, ?, ?, ?, ?, ?)
`);
const transaction = db.transaction((items) => {
let inserted = 0;
let updated = 0;
for (const reading of items) {
const fullDevice = `${devicePrefix}${reading.device}`;
const channel = reading.channel;
// Determine type and values
let dataType = 'number';
let value = null;
let data = null;
if (reading.value !== undefined && reading.value !== null) {
dataType = 'number';
value = reading.value;
} else if (reading.data !== undefined) {
dataType = 'json';
data = typeof reading.data === 'string' ? reading.data : JSON.stringify(reading.data);
} else {
continue; // Skip invalid
}
// Check last reading for RLE
const last = stmtLast.get(fullDevice, channel);
let isDuplicate = false;
if (last && last.data_type === dataType) {
if (dataType === 'number') {
if (Math.abs(last.value - value) < Number.EPSILON) {
isDuplicate = true;
}
} else {
// Compare JSON strings
if (last.data === data) {
isDuplicate = true;
}
}
}
if (isDuplicate) {
stmtUpdate.run(isoTimestamp, last.id);
updated++;
} else {
stmtInsert.run(isoTimestamp, fullDevice, channel, value, data, dataType);
inserted++;
}
}
return { inserted, updated };
});
return transaction(readings);
}
function createAgentWebSocketServer() {
const wss = new WebSocketServer({ port: WS_PORT });
wss.on('connection', (ws, req) => {
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
console.log(`[WS] Client connected: ${clientId}`);
const clientState = {
authenticated: false,
devicePrefix: null,
name: null,
lastPong: Date.now()
};
// Set up ping/pong for keepalive
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
clientState.lastPong = Date.now();
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleAgentMessage(ws, message, clientState, clientId);
} catch (err) {
console.error(`[WS] Error parsing message from ${clientId}:`, err.message);
ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
}
});
ws.on('close', () => {
console.log(`[WS] Client disconnected: ${clientId} (${clientState.name || 'unauthenticated'})`);
if (clientState.devicePrefix && agentClients.has(clientState.devicePrefix)) {
agentClients.get(clientState.devicePrefix).delete(ws);
}
});
ws.on('error', (err) => {
console.error(`[WS] Error for ${clientId}:`, err.message);
});
});
// Ping interval to detect dead connections
const pingInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
console.log('[WS] Terminating unresponsive client');
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => {
clearInterval(pingInterval);
});
console.log(`[WS] WebSocket server listening on port ${WS_PORT}`);
return wss;
}
function handleAgentMessage(ws, message, clientState, clientId) {
const { type } = message;
switch (type) {
case 'auth':
const { apiKey } = message;
if (!apiKey) {
ws.send(JSON.stringify({ type: 'auth', success: false, error: 'Missing apiKey' }));
return;
}
const keyInfo = validateApiKey(apiKey);
if (!keyInfo) {
ws.send(JSON.stringify({ type: 'auth', success: false, error: 'Invalid API key' }));
return;
}
clientState.authenticated = true;
clientState.devicePrefix = keyInfo.device_prefix;
clientState.name = keyInfo.name;
// Track this connection
if (!agentClients.has(keyInfo.device_prefix)) {
agentClients.set(keyInfo.device_prefix, new Set());
}
agentClients.get(keyInfo.device_prefix).add(ws);
console.log(`[WS] Client authenticated: ${keyInfo.name} (prefix: ${keyInfo.device_prefix})`);
ws.send(JSON.stringify({ type: 'auth', success: true, devicePrefix: keyInfo.device_prefix, name: keyInfo.name }));
break;
case 'pong':
// Keepalive from agent - just update timestamp
clientState.lastPong = Date.now();
break;
case 'data':
if (!clientState.authenticated) {
ws.send(JSON.stringify({ type: 'error', error: 'Not authenticated' }));
return;
}
const { readings } = message;
if (!Array.isArray(readings) || readings.length === 0) {
ws.send(JSON.stringify({ type: 'error', error: 'Invalid readings' }));
return;
}
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);
ws.send(JSON.stringify({ type: 'error', error: 'Failed to insert readings' }));
}
break;
default:
ws.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${type}` }));
}
}
// Send command to all agents with the given device prefix
function sendCommandToDevicePrefix(devicePrefix, command) {
const clients = agentClients.get(devicePrefix);
if (!clients || clients.size === 0) {
console.log(`[WS] No connected agents for prefix: ${devicePrefix}`);
return false;
}
const message = JSON.stringify({ type: 'command', ...command });
let sent = 0;
for (const ws of clients) {
if (ws.readyState === 1) { // OPEN
ws.send(message);
sent++;
}
}
console.log(`[WS] Sent command to ${sent} agent(s) with prefix ${devicePrefix}:`, command);
return sent > 0;
}
// Periodic sync: push non-zero output states to agents every 60s
function syncOutputStates() {
if (!db) return;
try {
// Get current output values
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();
for (const row of rows) {
// Only sync non-zero values
if (row.value > 0) {
const binding = OUTPUT_BINDINGS[row.channel];
if (binding) {
let commandValue = row.value;
if (binding.type === 'switch') {
commandValue = row.value > 0 ? 1 : 0;
}
const success = sendCommandToDevicePrefix(`${binding.device}:`, {
device: binding.channel,
action: 'set_state',
value: commandValue
});
if (!success) {
console.error(`[Sync] ERROR: Cannot deliver 'on' command for ${row.channel} -> ${binding.device}:${binding.channel} (no agent connected)`);
}
}
}
}
} catch (err) {
console.error('[Sync] Error syncing output states:', err.message);
}
}
// 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' },
{ channel: 'RoomExhaust', type: 'number', min: 0, max: 10, description: 'Room 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);
let target = value;
if (value && typeof value === 'object' && value.type === 'dynamic') {
const targetSensorVal = getSensorValue(value.channel) || 0;
target = (targetSensorVal * (value.factor || 1)) + (value.offset || 0);
}
return compareValues(sensorValue, operator, target);
}
case 'output': {
const outputValue = getOutputValue(channel);
return compareValues(outputValue, operator, value);
}
default:
console.warn(`[RuleRunner] Unknown condition type: ${type}`);
return false;
}
}
// Global set to track currently active rule IDs
const activeRuleIds = new Set();
// Run all rules
function runRules() {
if (!db) return;
try {
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
// Clear active rules list at start of run
activeRuleIds.clear();
// 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 - add to active list
activeRuleIds.add(rule.id);
// Rule matches - set output (later rules override)
if (action.channel && action.value !== undefined) {
let finalValue = action.value;
// Handle calculated value
if (action.value && typeof action.value === 'object' && action.value.type === 'calculated') {
const valA = getSensorValue(action.value.sensorA) || 0;
const valB = action.value.sensorB ? (getSensorValue(action.value.sensorB) || 0) : 0;
const diff = valA - valB;
finalValue = (diff * (action.value.factor || 1)) + (action.value.offset || 0);
}
desiredOutputs[action.channel] = finalValue;
}
}
} 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);
// Start the WebSocket server
const agentWss = createAgentWebSocketServer();
module.exports = {
entry: './src/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
clean: true,
},
mode: 'development',
devtool: 'source-map',
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
// Fix for ESM modules in node_modules (MUI X Charts v8)
test: /\.m?js$/,
resolve: {
fullySpecified: false,
},
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
devServer: {
port: 3905,
historyApiFallback: true,
hot: true,
allowedHosts: 'all',
client: {
webSocketURL: 'auto://0.0.0.0:0/ws',
progress: true,
},
setupMiddlewares: (middlewares, devServer) => {
if (!devServer) {
throw new Error('webpack-dev-server is not defined');
}
// API Endpoints
const app = devServer.app;
const bodyParser = require('body-parser');
app.use(bodyParser.json());
// --- Auth API ---
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
try {
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
const user = stmt.get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({
id: user.id,
username: user.username,
role: user.role
}, JWT_SECRET, { expiresIn: '24h' });
res.json({ token, role: user.role, username: user.username });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Middleware to check auth (Optional for read, required for write)
const checkAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(' ')[1];
jwt.verify(token, JWT_SECRET, (err, user) => {
if (user) req.user = user;
next();
});
} else {
next();
}
};
const requireAdmin = (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
app.use('/api/views', checkAuth);
// --- Views API ---
app.post('/api/views', requireAdmin, (req, res) => {
const { name, config } = req.body;
try {
const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)');
const info = stmt.run(name, JSON.stringify(config), req.user.id);
global.insertChangelog(req.user.username, `Created view "${name}"`);
res.json({ id: info.lastInsertRowid, name, config });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Publicly list views
app.get('/api/views', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
const rows = stmt.all();
const views = rows.map(row => {
try {
return { ...row, config: JSON.parse(row.config) };
} catch (e) {
return row;
}
});
res.json(views);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/views/:id', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM views WHERE id = ?');
const view = stmt.get(req.params.id);
if (view) {
view.config = JSON.parse(view.config);
res.json(view);
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Delete View
app.delete('/api/views/:id', requireAdmin, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM views WHERE id = ?');
// Get name before delete for logging
const viewName = db.prepare('SELECT name FROM views WHERE id = ?').get(req.params.id)?.name || 'Unknown View';
const info = stmt.run(req.params.id);
if (info.changes > 0) {
global.insertChangelog(req.user.username, `Deleted view "${viewName}" (ID: ${req.params.id})`);
res.json({ success: true });
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Update View
app.put('/api/views/:id', requireAdmin, (req, res) => {
const { name, config } = req.body;
try {
// Get old view for comparison
const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id);
if (!oldView) {
return res.status(404).json({ error: 'View not found' });
}
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
const info = stmt.run(name, JSON.stringify(config), req.params.id);
if (info.changes > 0) {
// Build detailed changelog
const changes = [];
// Check name change
if (oldView.name !== name) {
changes.push(`renamed: "${oldView.name}" → "${name}"`);
}
// Parse configs for comparison
let oldConfig = {};
try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { }
const newConfig = config || {};
// Compare channels
const oldChannels = (oldConfig.channels || []).map(ch =>
typeof ch === 'string' ? ch : ch.channel
);
const newChannels = (newConfig.channels || []).map(ch =>
typeof ch === 'string' ? ch : ch.channel
);
const added = newChannels.filter(ch => !oldChannels.includes(ch));
const removed = oldChannels.filter(ch => !newChannels.includes(ch));
if (added.length > 0) {
changes.push(`added channels: ${added.join(', ')}`);
}
if (removed.length > 0) {
changes.push(`removed channels: ${removed.join(', ')}`);
}
// Check for color/fill changes
const oldChannelConfigs = {};
(oldConfig.channels || []).forEach(ch => {
if (typeof ch === 'object') {
oldChannelConfigs[ch.channel] = ch;
}
});
const newChannelConfigs = {};
(newConfig.channels || []).forEach(ch => {
if (typeof ch === 'object') {
newChannelConfigs[ch.channel] = ch;
}
});
const colorChanges = [];
for (const ch of newChannels) {
const oldCh = oldChannelConfigs[ch] || {};
const newCh = newChannelConfigs[ch] || {};
if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) {
colorChanges.push(ch.split(':').pop()); // Just the channel name
}
}
if (colorChanges.length > 0) {
changes.push(`colors changed for: ${colorChanges.join(', ')}`);
}
// Check order change
if (added.length === 0 && removed.length === 0 &&
JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) {
changes.push('channel order changed');
}
const changeText = changes.length > 0
? `Updated view "${name}": ${changes.join('; ')}`
: `Updated view "${name}" (no significant changes)`;
global.insertChangelog(req.user.username, changeText);
res.json({ id: req.params.id, name, config });
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Reorder Views
app.post('/api/views/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
console.log('[API] Reorder request:', order);
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
console.log('[API] Updating view', item.id, 'to position', item.position);
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
console.log('[API] Reorder successful');
res.json({ success: true });
} catch (err) {
console.error('[API] Reorder error:', err);
res.status(500).json({ error: err.message });
}
});
// =============================================
// RULES API
// =============================================
// Apply checkAuth middleware to rules API routes
app.use('/api/rules', checkAuth);
// Virtual output channel definitions
// Virtual output channel definitions - MOVED TO GLOBAL SCOPE
// 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/outputs/commands - Get desired states for bound devices
// Agents poll this to get commands. Returns { "device:channel": { state: 0|1 } }
app.get('/api/outputs/commands', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
// Get current output values
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();
const outputValues = {};
rows.forEach(row => {
outputValues[row.channel] = row.value;
});
// Map to device commands
const commands = {};
for (const [outputChannel, binding] of Object.entries(OUTPUT_BINDINGS)) {
const value = outputValues[outputChannel] ?? 0;
const deviceKey = `${binding.device}:${binding.channel}`;
commands[deviceKey] = {
state: value > 0 ? 1 : 0,
source: outputChannel
};
}
res.json(commands);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/rules/status - Get currently active rule IDs
app.get('/api/rules/status', (req, res) => {
res.json({ activeIds: Array.from(activeRuleIds) });
});
// 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
);
runRules(); // Trigger rules immediately
global.insertChangelog(req.user?.username || 'admin', `Created rule "${name}"`);
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 {
// Get old rule for comparison
const oldRule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id);
if (!oldRule) {
return res.status(404).json({ error: 'Rule not found' });
}
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) {
runRules(); // Trigger rules immediately
// Build detailed changelog
const changes = [];
if (oldRule.name !== name) {
changes.push(`name: "${oldRule.name}" → "${name}"`);
}
if (!!oldRule.enabled !== !!enabled) {
changes.push(`enabled: ${oldRule.enabled ? 'on' : 'off'}${enabled ? 'on' : 'off'}`);
}
const oldConditions = oldRule.conditions || '{}';
const newConditions = JSON.stringify(conditions);
if (oldConditions !== newConditions) {
changes.push('conditions changed');
}
const oldAction = oldRule.action || '{}';
const newAction = JSON.stringify(action);
if (oldAction !== newAction) {
// Parse to show what changed in action
try {
const oldA = JSON.parse(oldAction);
const newA = action;
if (oldA.channel !== newA.channel) {
changes.push(`action channel: ${oldA.channel}${newA.channel}`);
}
if (JSON.stringify(oldA.value) !== JSON.stringify(newA.value)) {
changes.push(`action value: ${JSON.stringify(oldA.value)}${JSON.stringify(newA.value)}`);
}
} catch (e) {
changes.push('action changed');
}
}
const changeText = changes.length > 0
? `Updated rule "${name}": ${changes.join(', ')}`
: `Updated rule "${name}" (no changes)`;
global.insertChangelog(req.user?.username || 'admin', changeText);
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 = ?');
// Get name before delete
const ruleName = db.prepare('SELECT name FROM rules WHERE id = ?').get(req.params.id)?.name || 'Unknown Rule';
const info = stmt.run(req.params.id);
if (info.changes > 0) {
runRules(); // Trigger rules immediately
global.insertChangelog(req.user?.username || 'admin', `Deleted rule "${ruleName}" (ID: ${req.params.id})`);
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);
runRules(); // Trigger rules immediately
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// =============================================
// RULE RUNNER (Background Job)
// =============================================
// Rule Engine functions moved to global scope
// 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 (sensors + outputs)
app.get('/api/devices', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
// Get sensor channels
const sensorStmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel");
const sensorRows = sensorStmt.all();
// Add output channels with 'output' as device
const outputRows = OUTPUT_CHANNELS.map(ch => ({
device: 'output',
channel: ch.channel
}));
res.json([...sensorRows, ...outputRows]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/readings
// Query params: devices (comma sep), channels (comma sep), since (timestamp)
// Actually, user wants "Last 24h".
// We can accept `since` or valid ISO string.
// Expected params: `?device=x&channel=y` (single) or query for multiple?
// User asked for "chart that is refreshed once a minute... display the last 24 hours with the devices/channels previously selected"
// Efficient query: select * from sensor_events where timestamp > ? and (device,channel) IN (...)
// For simplicity, let's allow fetching by multiple devices/channels or just all for last 24h and filter client side?
// No, database filtering is better.
// Let's support ?since=ISO_DATE
app.get('/api/readings', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const { since, until } = req.query;
const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const endTime = until || new Date().toISOString();
const requestedSensorChannels = []; // [{device, channel}]
const requestedOutputChannels = []; // [channel]
if (req.query.selection) {
const selections = req.query.selection.split(',');
selections.forEach(s => {
const lastColonIndex = s.lastIndexOf(':');
if (lastColonIndex !== -1) {
const d = s.substring(0, lastColonIndex);
const c = s.substring(lastColonIndex + 1);
if (d === 'output') {
requestedOutputChannels.push(c);
} else {
requestedSensorChannels.push({ device: d, channel: c });
}
}
});
}
const result = {};
// 1. Fetch sensor data
if (requestedSensorChannels.length > 0) {
let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? ';
const params = [startTime, endTime];
const placeholders = [];
requestedSensorChannels.forEach(ch => {
placeholders.push('(device = ? AND channel = ?)');
params.push(ch.device, ch.channel);
});
if (placeholders.length > 0) {
sql += `AND (${placeholders.join(' OR ')}) `;
}
sql += 'ORDER BY timestamp ASC';
const rows = db.prepare(sql).all(...params);
// Backfill for sensors
const backfillStmt = db.prepare(`
SELECT * FROM sensor_events
WHERE device = ? AND channel = ?
AND timestamp <= ?
AND (until >= ? OR until IS NULL)
ORDER BY timestamp DESC LIMIT 1
`);
const backfillRows = [];
requestedSensorChannels.forEach(ch => {
const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime);
if (prev) backfillRows.push(prev);
});
[...backfillRows, ...rows].forEach(row => {
const key = `${row.device}:${row.channel}`;
if (!result[key]) result[key] = [];
const pt = [row.timestamp, row.value];
if (row.until) pt.push(row.until);
result[key].push(pt);
});
}
// 2. Fetch output data
if (requestedOutputChannels.length > 0) {
let sql = 'SELECT * FROM output_events WHERE timestamp > ? AND timestamp <= ? ';
const params = [startTime, endTime];
const placeholders = requestedOutputChannels.map(() => 'channel = ?');
sql += `AND (${placeholders.join(' OR ')}) `;
params.push(...requestedOutputChannels);
sql += 'ORDER BY timestamp ASC';
const rows = db.prepare(sql).all(...params);
// Backfill for outputs
const backfillStmt = db.prepare(`
SELECT * FROM output_events
WHERE channel = ?
AND timestamp <= ?
AND (until >= ? OR until IS NULL)
ORDER BY timestamp DESC LIMIT 1
`);
const backfillRows = [];
requestedOutputChannels.forEach(ch => {
const prev = backfillStmt.get(ch, startTime, startTime);
if (prev) {
backfillRows.push(prev);
} else {
// No data at all - add default 0 value at startTime
backfillRows.push({ channel: ch, timestamp: startTime, value: 0, until: null });
}
});
[...backfillRows, ...rows].forEach(row => {
const key = `output:${row.channel}`;
if (!result[key]) result[key] = [];
const pt = [row.timestamp, row.value];
if (row.until) pt.push(row.until);
result[key].push(pt);
});
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
return middlewares;
},
},
};