700 lines
30 KiB
JavaScript
700 lines
30 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');
|
|
|
|
// 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';
|
|
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);
|
|
}
|
|
|
|
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: '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 (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;
|
|
},
|
|
},
|
|
};
|