Files
wolfDash/server.js
sebseb7 739b6fe54f alarms
2025-12-21 04:05:54 +01:00

868 lines
31 KiB
JavaScript

import 'dotenv/config';
import express from 'express';
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import config from './webpack.config.js';
import { Telegraf } from 'telegraf';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compiler = webpack(config);
// --- CONFIGURATION ---
const BASE_URL = 'http://www.acinfinityserver.com';
const USER_AGENT = 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2';
const POLL_INTERVAL_MS = 60000; // 60 seconds
const DB_FILE = 'ac_data.db';
const PORT = 3905;
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
// Device Type Mapping
const DEVICE_TYPES = {
1: 'Outlet',
3: 'Fan',
7: 'Light'
};
// Check Credentials
if (!process.env.AC_EMAIL || !process.env.AC_PASSWORD) {
console.error('Error: AC_EMAIL and AC_PASSWORD must be set in .env file');
process.exit(1);
}
// --- DATABASE SETUP ---
// Note: Opened in Read/Write mode (default)
const db = new Database(DB_FILE);
db.exec(`
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
dev_id TEXT,
dev_name TEXT,
port INTEGER,
port_name TEXT,
temp_c REAL,
humidity REAL,
vpd REAL,
fan_speed INTEGER,
on_speed INTEGER,
off_speed INTEGER
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_data TEXT NOT NULL,
action_type TEXT NOT NULL,
action_data TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
color_tag TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS alarms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_data TEXT NOT NULL,
action_type TEXT NOT NULL DEFAULT 'telegram',
action_data TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
color_tag TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Migration: Add sort_order and color_tag if they don't exist
try { db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { }
try { db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { }
try { db.exec('ALTER TABLE users ADD COLUMN telegram_id TEXT DEFAULT NULL'); } catch (e) { }
try { db.exec('ALTER TABLE alarms ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { }
try { db.exec('ALTER TABLE alarms ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { }
const insertStmt = db.prepare(`
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
// Rules prepared statements
const getAllRules = db.prepare('SELECT * FROM rules ORDER BY sort_order, id');
const getRuleById = db.prepare('SELECT * FROM rules WHERE id = ?');
const insertRule = db.prepare(`
INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const updateRule = db.prepare(`
UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, color_tag = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const updateRuleOrder = db.prepare('UPDATE rules SET sort_order = ? WHERE id = ?');
const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
const getMaxSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM rules');
// Alarms prepared statements
const getAllAlarms = db.prepare('SELECT * FROM alarms ORDER BY sort_order, id');
const getAlarmById = db.prepare('SELECT * FROM alarms WHERE id = ?');
const insertAlarm = db.prepare(`
INSERT INTO alarms (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const updateAlarm = db.prepare(`
UPDATE alarms SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, color_tag = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const updateAlarmOrder = db.prepare('UPDATE alarms SET sort_order = ? WHERE id = ?');
const deleteAlarm = db.prepare('DELETE FROM alarms WHERE id = ?');
const getMaxAlarmSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM alarms');
// User Profile statements
const updateUserTelegramId = db.prepare('UPDATE users SET telegram_id = ? WHERE id = ?');
const getAllTelegramUsers = db.prepare("SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL AND telegram_id != ''");
// --- TELEGRAM BOT ---
let bot = null;
if (TELEGRAM_BOT_TOKEN) {
bot = new Telegraf(TELEGRAM_BOT_TOKEN);
// Simple start handler to give user their ID
bot.start((ctx) => {
const userId = ctx.from.id;
ctx.reply(`Hello! Your Telegram ID is: ${userId}\n\nPlease copy this ID and paste it into your profile on the Dashboard.`);
});
bot.launch().catch(err => console.error('Telegram Bot Launch Error:', err));
console.log('Telegram Bot initialized.');
// Graceful stop
process.once('SIGINT', () => bot.stop('SIGINT'));
process.once('SIGTERM', () => bot.stop('SIGTERM'));
} else {
console.warn('TELEGRAM_BOT_TOKEN not set. Alarms will not send notifications.');
}
async function sendTelegramNotification(message) {
if (!bot) return;
try {
const users = getAllTelegramUsers.all();
for (const u of users) {
await bot.telegram.sendMessage(u.telegram_id, message);
}
} catch (err) {
console.error('Failed to send Telegram notification:', err);
}
}
// --- ALARM LOGIC ---
// Simple state tracking to avoid spamming: alarmId -> lastTriggeredTime
const alarmStates = new Map();
function evaluateAlarms(readings) {
const alarms = getAllAlarms.all();
alarms.forEach(alarm => {
if (!alarm.enabled) return;
// Skip if recently triggered (debounce 5 mins)
const lastTrigger = alarmStates.get(alarm.id);
if (lastTrigger && (Date.now() - lastTrigger) < 5 * 60 * 1000) {
return;
}
try {
const trigger = JSON.parse(alarm.trigger_data);
let triggered = false;
// Check Sensors
if (trigger.sensors && trigger.sensors.length > 0) {
const results = trigger.sensors.map(cond => {
const sensorId = cond.sensor; // e.g., "Wall Display:temp"
const operator = cond.operator;
const threshold = cond.value;
// Find matching reading
// sensorId format: "DevName:temp" or "DevName:humid" or "DevName:Port:level"
const parts = sensorId.split(':');
const devName = parts[0];
const type = parts.length === 2 ? parts[1] : parts[2]; // temp/humid OR level
// Find the latest reading for this device+type
// readings is an array of objects passed from poll()
// But poll inserts individually. We need to aggregate or pass the full context.
// Implementation Detail: poll() inserts one by one.
// Better approach: poll() collects all readings, THEN calls evaluateAlarms(allReadings).
const reading = readings.find(r => r.devName === devName);
if (!reading) return false;
let value = null;
if (type === 'temp') value = reading.temp_c;
if (type === 'humidity') value = reading.humidity;
if (type === 'level') {
// reading.ports is array of {port, temp, hum, speak...}
const portNum = parseInt(parts[1]);
const p = reading.ports.find(rp => rp.port === portNum);
if (p) value = p.speak;
}
if (value === null || value === undefined) return false;
// Numeric comparison
switch (operator) {
case '>': return value > threshold;
case '<': return value < threshold;
case '>=': return value >= threshold;
case '<=': return value <= threshold;
case '==': return value == threshold;
default: return false;
}
});
if (trigger.sensorLogic === 'or') {
triggered = results.some(r => r);
} else {
triggered = results.every(r => r);
}
}
// Time triggers are handled differently (cron-like), skipping for now as per "sensor trigger" focus in plan,
// but structure supports it. If time trigger is ONLY trigger, we'd need a separate loop.
// Assuming sensor triggers for now based on context of "readings".
if (triggered) {
console.log(`ALARM TRIGGERED: ${alarm.name}`);
alarmStates.set(alarm.id, Date.now());
const action = JSON.parse(alarm.action_data);
const msg = `🚨 ALARM: ${alarm.name}\n\n${action.message || 'No message'}\n\nSeverity: ${action.severity || 'Info'}`;
sendTelegramNotification(msg);
}
} catch (err) {
console.error(`Error evaluating alarm ${alarm.id}:`, err);
}
});
}
// --- AC INFINITY API LOGIC ---
let token = null;
async function login() {
console.log('Logging in...');
const params = new URLSearchParams();
params.append('appEmail', process.env.AC_EMAIL);
params.append('appPasswordl', process.env.AC_PASSWORD);
try {
const response = await fetch(`${BASE_URL}/api/user/appUserLogin`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT
},
body: params
});
const data = await response.json();
if (data.code === 200) {
console.log('Login successful.');
return data.data.appId;
} else {
throw new Error(`Login failed: ${data.msg} (${data.code})`);
}
} catch (error) {
console.error('Login error:', error.message);
throw error;
}
}
async function getDeviceList(authToken) {
const params = new URLSearchParams();
params.append('userId', authToken);
const response = await fetch(`${BASE_URL}/api/user/devInfoListAll`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT,
'token': authToken,
'phoneType': '1',
'appVersion': '1.9.7'
},
body: params
});
const data = await response.json();
if (data.code === 200) return data.data || [];
throw new Error(`Get device list failed: ${data.msg}`);
}
async function getDeviceModeSettings(authToken, devId, port) {
const params = new URLSearchParams();
params.append('devId', devId);
params.append('port', port.toString());
const response = await fetch(`${BASE_URL}/api/dev/getdevModeSettingList`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT,
'token': authToken,
'phoneType': '1',
'appVersion': '1.9.7',
'minversion': '3.5'
},
body: params
});
const data = await response.json();
if (data.code === 200) return data.data;
console.warn(`Failed to get settings for ${devId}: ${data.msg}`);
return null;
}
async function poll() {
try {
if (!token) {
token = await login();
}
const devices = await getDeviceList(token);
console.log(`[${new Date().toISOString()}] Data Fetch: Found ${devices.length} controllers.`);
const currentReadings = []; // Collect for alarm evaluation
for (const device of devices) {
const ports = device.deviceInfo && device.deviceInfo.ports ? device.deviceInfo.ports : [];
if (ports.length === 0) {
console.warn(`Device ${device.devName} has no ports info.`);
continue;
}
const deviceReadings = {
devName: device.devName,
devId: device.devId,
temp_c: null, // Device level temp (some controllers have it)
humidity: null,
ports: []
};
for (const portInfo of ports) {
// Filter by online status
if (portInfo.online === 1) {
const port = portInfo.port;
const settings = await getDeviceModeSettings(token, device.devId, port);
if (settings) {
const tempC = settings.temperature ? settings.temperature / 100 : null;
const hum = settings.humidity ? settings.humidity / 100 : null;
const vpd = settings.vpdnums ? settings.vpdnums / 100 : null;
// Some devices report environment at "device" level, others at port.
// We'll capture first valid env reading as device reading for simplicity
if (tempC !== null && deviceReadings.temp_c === null) deviceReadings.temp_c = tempC;
if (hum !== null && deviceReadings.humidity === null) deviceReadings.humidity = hum;
// Determine Port Name
let portName = portInfo.portName;
if (!portName || portName.startsWith('Port ')) {
const typeName = DEVICE_TYPES[settings.atType];
if (typeName) {
portName = typeName;
}
}
insertStmt.run(
device.devId,
device.devName,
port,
portName,
tempC,
hum,
vpd,
settings.speak,
settings.onSpead,
settings.offSpead
);
let label = 'Level';
if (portName === 'Fan') label = 'Fan Speed';
if (portName === 'Light') label = 'Brightness';
deviceReadings.ports.push({
port,
portName,
speak: settings.speak,
temp: tempC,
hum
});
console.log(`Saved reading for ${device.devName} (${portName}): ${tempC}°C, ${hum}%, ${label}: ${settings.speak}/10`);
}
}
}
currentReadings.push(deviceReadings);
}
// Evaluate Alarms
evaluateAlarms(currentReadings);
} catch (error) {
console.error('Polling error:', error.message);
token = null; // Reset token to force re-login
}
}
// --- EXPRESS SERVER ---
const app = express();
app.use(express.json());
// Auth: Login
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
const user = getUserByUsername.get(username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({ token, user: { username: user.username, role: user.role, telegramId: user.telegram_id } });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Auth: Get current user
app.get('/api/auth/me', (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
// Fetch fresh user data to include Telegram ID
const user = db.prepare('SELECT username, role, telegram_id FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ user: { username: user.username, role: user.role, telegramId: user.telegram_id } });
} catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Invalid or expired token' });
}
res.status(500).json({ error: 'Internal server error' });
}
});
// PUT /api/auth/profile - user updates their own profile
app.put('/api/auth/profile', requireAuth, (req, res) => {
try {
const { telegramId } = req.body;
if (telegramId === undefined) {
return res.status(400).json({ error: 'telegramId is required' });
}
updateUserTelegramId.run(telegramId, req.user.id);
res.json({ success: true, telegramId });
} catch (err) {
console.error('Profile update error:', err);
res.status(500).json({ error: err.message });
}
});
// --- AUTH MIDDLEWARE ---
function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, JWT_SECRET);
} catch (e) {
req.user = null;
}
}
next();
}
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch (e) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
// --- RULES API ---
// Helper to format rule for API response
function formatRule(row) {
const trigger = JSON.parse(row.trigger_data);
const action = JSON.parse(row.action_data);
// Add type back for legacy/action compatibility
if (action.type === undefined && row.action_type) {
action.type = row.action_type;
}
// Parse colorTags (stored as JSON array)
let colorTags = [];
if (row.color_tag) {
try {
colorTags = JSON.parse(row.color_tag);
} catch (e) {
// Backwards compat: single tag as string
colorTags = [row.color_tag];
}
}
return {
id: row.id,
name: row.name,
enabled: row.enabled === 1,
sortOrder: row.sort_order || 0,
colorTags,
trigger,
action
};
}
// GET /api/rules - public (guests can view)
app.get('/api/rules', (req, res) => {
try {
const rows = getAllRules.all();
res.json(rows.map(formatRule));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /api/rules - admin only
app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
try {
const { name, enabled, trigger, action, colorTags } = req.body;
if (!name || !trigger || !action) {
return res.status(400).json({ error: 'name, trigger, and action required' });
}
// Determine trigger type for storage (combined, time, sensor)
let triggerType = 'combined';
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
// Get next sort order
const maxOrder = getMaxSortOrder.get().max_order;
const sortOrder = maxOrder + 1;
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action);
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
const result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, sortOrder, colorTagsData);
const newRule = getRuleById.get(result.lastInsertRowid);
res.status(201).json(formatRule(newRule));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PUT /api/rules/:id - admin only
app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
try {
const { id } = req.params;
const existing = getRuleById.get(id);
if (!existing) {
return res.status(404).json({ error: 'Rule not found' });
}
const { name, enabled, trigger, action, colorTags } = req.body;
// Determine trigger type for storage
let triggerType = 'combined';
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action);
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, colorTagsData, id);
const updated = getRuleById.get(id);
res.json(formatRule(updated));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PUT /api/rules/reorder - admin only (reorder rules)
app.put('/api/rules/reorder', requireAuth, requireAdmin, (req, res) => {
try {
const { ruleIds } = req.body; // Array of rule IDs in new order
if (!Array.isArray(ruleIds)) {
return res.status(400).json({ error: 'ruleIds array required' });
}
// Update sort_order for each rule
ruleIds.forEach((ruleId, index) => {
updateRuleOrder.run(index, ruleId);
});
const rows = getAllRules.all();
res.json(rows.map(formatRule));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE /api/rules/:id - admin only
app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
try {
const { id } = req.params;
const existing = getRuleById.get(id);
if (!existing) {
return res.status(404).json({ error: 'Rule not found' });
}
deleteRule.run(id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- ALARMS API ---
// GET /api/alarms - public (guests can view)
app.get('/api/alarms', (req, res) => {
try {
const rows = getAllAlarms.all();
res.json(rows.map(formatRule)); // formatRule works for alarms too as schema is compatible
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /api/alarms - admin only
app.post('/api/alarms', requireAuth, requireAdmin, (req, res) => {
try {
const { name, enabled, trigger, action, colorTags } = req.body;
if (!name || !trigger || !action) {
return res.status(400).json({ error: 'name, trigger, and action required' });
}
// Trigger Type Logic
let triggerType = 'combined';
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
const maxOrder = getMaxAlarmSortOrder.get().max_order;
const sortOrder = maxOrder + 1;
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action); // action is { message: "...", severity: "..." }
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
// action_type fixed to 'telegram'
const result = insertAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, sortOrder, colorTagsData);
const newAlarm = getAlarmById.get(result.lastInsertRowid);
res.status(201).json(formatRule(newAlarm));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PUT /api/alarms/:id - admin only
app.put('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => {
try {
const { id } = req.params;
const existing = getAlarmById.get(id);
if (!existing) return res.status(404).json({ error: 'Alarm not found' });
const { name, enabled, trigger, action, colorTags } = req.body;
let triggerType = 'combined';
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action);
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
updateAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, colorTagsData, id);
const updated = getAlarmById.get(id);
res.json(formatRule(updated));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PUT /api/alarms/reorder - admin only
app.put('/api/alarms/reorder', requireAuth, requireAdmin, (req, res) => {
try {
const { alarmIds } = req.body;
if (!Array.isArray(alarmIds)) return res.status(400).json({ error: 'alarmIds array required' });
alarmIds.forEach((id, index) => {
updateAlarmOrder.run(index, id);
});
const rows = getAllAlarms.all();
res.json(rows.map(formatRule));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE /api/alarms/:id - admin only
app.delete('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => {
try {
const { id } = req.params;
const existing = getAlarmById.get(id);
if (!existing) return res.status(404).json({ error: 'Alarm not found' });
deleteAlarm.run(id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// API: Devices
app.get('/api/devices', (req, res) => {
try {
const stmt = db.prepare(`
SELECT DISTINCT dev_name, port, port_name
FROM readings
ORDER BY dev_name, port
`);
const rows = stmt.all();
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// API: History
app.get('/api/history', (req, res) => {
try {
const { devName, port, range } = req.query;
if (!devName || !port) return res.status(400).json({ error: 'Missing devName or port' });
let timeFilter;
switch (range) {
case 'week': timeFilter = "-7 days"; break;
case 'month': timeFilter = "-30 days"; break;
case 'day': default: timeFilter = "-24 hours"; break;
}
const stmt = db.prepare(`
SELECT timestamp || 'Z' as timestamp, temp_c, humidity, vpd, fan_speed, on_speed
FROM readings
WHERE dev_name = ? AND port = ? AND timestamp >= datetime('now', ?)
ORDER BY timestamp ASC
`);
const rows = stmt.all(devName, parseInt(port, 10), timeFilter);
res.json(rows);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
});
// Webpack Middleware
// NOTE: We override publicPath to '/' here because Nginx strips the '/ac/' prefix.
// The incoming request for '/ac/bundle.js' becomes '/bundle.js' at this server.
const devMiddleware = webpackDevMiddleware(compiler, {
publicPath: '/',
writeToDisk: false,
headers: (req, res, context) => {
// Set cache headers for hashed bundle files (immutable)
if (req.url && req.url.match(/\.[a-f0-9]{8,}\.(js|css)$/i)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
});
app.use(devMiddleware);
// Serve index.html for root request (SPA Fallback-ish)
app.get('/', (req, res) => {
// Access index.html from the memory filesystem
// We attempt to read it from the middleware's outputFileSystem
const indexFile = path.join(config.output.path, 'index.html');
const fs = devMiddleware.context.outputFileSystem;
// Simple wait/retry logic could be added here, but usually startup takes a second.
if (fs && fs.existsSync(indexFile)) {
const html = fs.readFileSync(indexFile);
res.set('Content-Type', 'text/html');
res.send(html);
} else {
res.status(202).send('Building... Please refresh in a moment.');
}
});
// Start Server & Daemon
app.listen(PORT, '127.0.0.1', () => {
console.log(`Dashboard Server running at http://127.0.0.1:${PORT}`);
// Start Polling Loop
console.log(`Starting AC Infinity Poll Loop (Interval: ${POLL_INTERVAL_MS}ms)`);
// poll(); // Initial run (optional)
setInterval(poll, POLL_INTERVAL_MS);
});