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}`); } 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' }, }; // ============================================= // WebSocket Server for Agents (port 3962) // ============================================= // Track authenticated clients by devicePrefix const agentClients = new Map(); // devicePrefix -> Set 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); 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) { const success = sendCommandToDevicePrefix(`${binding.device}:`, { device: binding.channel, action: 'set_state', value: 1 }); 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); // 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'], }, ], }, resolve: { extensions: ['.js', '.jsx'], }, plugins: [ new HtmlWebpackPlugin({ template: './public/index.html', }), ], devServer: { port: 3905, historyApiFallback: true, hot: true, allowedHosts: 'all', 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); 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 = ?'); const info = stmt.run(req.params.id); if (info.changes > 0) { 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 { 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) { 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 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 /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 - 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(); const lastStmt = db.prepare(` SELECT id, value FROM output_events WHERE channel = ? ORDER BY timestamp DESC LIMIT 1 `); const last = lastStmt.get(channel); if (channel === 'CircFanLevel') { console.log('[RuleRunner] Debug Bindings:', JSON.stringify(OUTPUT_BINDINGS['CircFanLevel'])); } 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); } } // 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; }, }, };