alarms
This commit is contained in:
319
server.js
319
server.js
@@ -8,6 +8,7 @@ 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);
|
||||
@@ -20,6 +21,7 @@ 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 = {
|
||||
@@ -81,13 +83,28 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
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) { /* column already exists */ }
|
||||
try {
|
||||
db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL');
|
||||
} catch (e) { /* column already exists */ }
|
||||
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)
|
||||
@@ -111,6 +128,147 @@ 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;
|
||||
|
||||
@@ -197,6 +355,8 @@ async function poll() {
|
||||
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 : [];
|
||||
|
||||
@@ -205,6 +365,14 @@ async function poll() {
|
||||
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) {
|
||||
@@ -216,6 +384,11 @@ async function poll() {
|
||||
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 ')) {
|
||||
@@ -242,11 +415,24 @@ async function poll() {
|
||||
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
|
||||
@@ -281,7 +467,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({ token, user: { username: user.username, role: user.role } });
|
||||
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' });
|
||||
@@ -299,7 +485,12 @@ app.get('/api/auth/me', (req, res) => {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
res.json({ user: { username: decoded.username, role: decoded.role } });
|
||||
// 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' });
|
||||
@@ -308,6 +499,21 @@ app.get('/api/auth/me', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -478,6 +684,103 @@ app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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 {
|
||||
|
||||
Reference in New Issue
Block a user