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 ) `); db.exec(` CREATE TABLE IF NOT EXISTS output_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, dev_name TEXT, port INTEGER, state INTEGER, -- 0 or 1 level INTEGER, -- 1-10 or NULL rule_id INTEGER, rule_name TEXT ) `); // 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(); // --- RULE ENGINE LOGIC --- const ruleStates = new Map(); // Track keepOn durations: target -> { activeUntil: timestamp, ruleId: id } let lastOutputState = new Map(); // Track previous state for "State Change" alarms: "dev:port" -> { state, level } // Initialize lastOutputState from DB (recover state after restart) try { const rows = db.prepare(` SELECT dev_name, port, state, level, rule_id, rule_name FROM output_log WHERE timestamp > datetime('now', '-24 hours') GROUP BY dev_name, port HAVING MAX(timestamp) `).all(); rows.forEach(row => { const key = `${row.dev_name}:${row.port}`; lastOutputState.set(key, { devName: row.dev_name, port: row.port, state: row.state, level: row.level, ruleId: row.rule_id, ruleName: row.rule_name }); }); console.log(`Recovered ${lastOutputState.size} output states from DB.`); } catch (err) { console.error('Failed to recover output state from DB:', err); } function evaluateRules(readings) { const rules = getAllRules.all(); const currentOutputs = new Map(); // "dev:port" -> { state, level, ruleId, ruleName } // 1. Initialize Outputs based STRICTLY on Enabled Rules // User Requirement: "only OUTPUT FROM RULES should go there" // Logic: Identify all unique targets from Enabled Rules. Only these devices are "Outputs". const targetKeys = new Set(); rules.forEach(rule => { if (!rule.enabled) return; try { const action = JSON.parse(rule.action_data); if (action && action.target) { // Normalize keys (remove :out) const key = action.target.replace(':out', ''); targetKeys.add(key); } } catch (e) { } }); targetKeys.forEach(key => { // Hydrate state from Physical Readings if available // Key format: "devName:port" const parts = key.split(':'); const devName = parts[0]; const port = parseInt(parts[1], 10); // Default State let currentVal = 0; let currentLevel = 0; // Try to find physical reading for this target const reading = readings.find(r => r.devName === devName); if (reading) { const p = reading.ports.find(rp => rp.port === port); // If needed, we could read p.speak here. // But existing logic resets to 0 before applying rules, so we follow that pattern. } currentOutputs.set(key, { devName: devName, port: port, state: 0, level: 0, ruleId: null, ruleName: 'Default' }); }); // 2. Evaluate Rules const now = new Date(); const currentTime = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); // HH:MM const currentDay = now.toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(); // mon, tue... rules.forEach(rule => { if (!rule.enabled) return; let triggered = false; try { const trigger = JSON.parse(rule.trigger_data); // --- Trigger Eval --- // A. Scheduled Time (Time Point) if (trigger.scheduledTime) { // This is an "event" trigger, not a "state" trigger. // It usually sets a 'keepOn' state. // We check if we are in the minute of the schedule. // To avoid double triggering, we could track last trigger day. // For simplified "polling" engine, exact match might miss if poll > 1 min // But for now assuming poll ~1 min. if (trigger.scheduledTime.time === currentTime && trigger.scheduledTime.days.includes(currentDay)) { triggered = true; } } // B. Time Range (Window) else if (trigger.timeRange) { const { start, end, days } = trigger.timeRange; if (days.includes(currentDay)) { // Handle overnight ranges like 22:00 - 06:00 if (start <= end) { triggered = (currentTime >= start && currentTime <= end); } else { triggered = (currentTime >= start || currentTime <= end); } } } // C. Sensors else if (trigger.sensors && trigger.sensors.length > 0) { const results = trigger.sensors.map(cond => { // Logic mostly same as Alarms, but with 'output-state' support? // Rule triggers are usually Env sensors. // If a rule triggers on "Output State", it requires distinct ordering (chaining). // We process rules in order, so we can check `currentOutputs`. const sensorId = cond.sensor; // e.g., "Wall Display:temp" OR "tapo-001:state" const operator = cond.operator; const threshold = cond.value; // Check if it's an output state check // sensorId format for output: "dev:port:out" or "tapo:state"? // From Editor: "dev:port:out" or "tapo...". // Actually, Editor for SENSORS sends "dev:port:level" or "dev:temp". // Let's support checking computed `currentOutputs` if needed? // For now, assume mainly Sensor Readings. const parts = sensorId.split(':'); const devName = parts[0]; const type = parts.length === 2 ? parts[1] : parts[2]; let value = null; // Check against Readings first const reading = readings.find(r => r.devName === devName); if (reading) { if (type === 'temp') value = reading.temp_c; if (type === 'humidity') value = reading.humidity; if (type === 'level') { const p = reading.ports.find(rp => rp.port === parseInt(parts[1])); if (p) value = p.speak; } } // Numeric comparison if (value !== null) { 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; } } return false; }); if (trigger.sensorLogic === 'or') { triggered = results.some(r => r); } else { triggered = results.every(r => r); } } // --- Action Logic --- if (triggered) { const action = JSON.parse(rule.action_data); const target = action.target; // "dev:port:out" or "tapo..." // Track KeepOn if (action.type === 'keepOn') { // Set expiration const durationMs = (action.duration || 15) * 60 * 1000; ruleStates.set(rule.id, { activeUntil: Date.now() + durationMs, target }); } // Apply immediate state // Only if type is toggle OR keepOn is just starting // Actually, for "Window" (Time Range / Sensor), we strictly follow "Active = ON". // For "Event" (Scheduled), we strictly follow KeepOn logic. // Apply to Current Outputs map // Parse Target: "DevName:Port:out" if (target.includes(':out') || target.includes('virtual')) { // Find matching key in currentOutputs (fuzzy match for virtual/tapo if we supported them fully) // Actest only has Controllers currently? // "Wall Display:1:out" const targetKey = target.replace(':out', ''); // "Wall Display:1" let newState = 0; let newLevel = 0; if (action.type === 'toggle') { // If toggle ON -> State 1. If toggle OFF -> State 0. // (Rule active implies enforcement of this state) newState = (action.state === true || action.state === undefined) ? 1 : 0; newLevel = action.level || 10; } else if (action.type === 'keepOn') { newState = 1; newLevel = action.level || 10; } // Update Map if (currentOutputs.has(targetKey)) { currentOutputs.set(targetKey, { ...currentOutputs.get(targetKey), state: newState, level: newState ? newLevel : 0, ruleId: rule.id, ruleName: rule.name }); } else { // Add new/virtual output const parts = targetKey.split(':'); currentOutputs.set(targetKey, { devName: parts[0], port: parts.length > 1 ? parseInt(parts[1]) : 0, state: newState, level: newState ? newLevel : 0, ruleId: rule.id, ruleName: rule.name }); } } } } catch (e) { console.error(`Rule Eval Error (${rule.name}):`, e); } }); // 3. Process Active KeepOn Rules (overrides) const nowMs = Date.now(); ruleStates.forEach((state, ruleId) => { if (state.activeUntil > nowMs) { // Still active const targetKey = state.target.replace(':out', ''); if (currentOutputs.has(targetKey)) { // Re-apply ON state currentOutputs.set(targetKey, { ...currentOutputs.get(targetKey), state: 1, level: 10, // Default or fetch from original rule if we stored it ruleId: ruleId, ruleName: "KeepOn Rule" }); } else { // Add new/virtual output for keepOn const parts = targetKey.split(':'); currentOutputs.set(targetKey, { devName: parts[0], port: parts.length > 1 ? parseInt(parts[1]) : 0, state: 1, level: 10, ruleId: ruleId, ruleName: "KeepOn Rule" }); } } else { // Expired ruleStates.delete(ruleId); } }); // 4. Log Changes & Detect State Changes const changes = []; currentOutputs.forEach((val, key) => { const prev = lastOutputState.get(key); // Save to DB log if changed or periodically? // Log "Target State" calculation. // We only insert if changed to save DB space, OR insert every X mins? // Requirement: "Store these target states in output_log" // Let's insert every poll to have history graph? Or just changes? // Graphs need continuous data. Let's insert every poll for now (small scale). db.prepare('INSERT INTO output_log (dev_name, port, state, level, rule_id, rule_name) VALUES (?, ?, ?, ?, ?, ?)') .run(val.devName || 'Unknown', val.port || 0, val.state, val.level, val.ruleId, val.ruleName); // Detect Change for Alarms if (prev) { if (prev.state !== val.state) { changes.push({ key, ...val, prev: prev.state }); } } lastOutputState.set(key, val); }); return changes; } function evaluateAlarms(readings, outputChanges = []) { const alarms = getAllAlarms.all(); alarms.forEach(alarm => { if (!alarm.enabled) return; // Debounce 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; // 1. Output State Change Triggers if (trigger.outputChange && outputChanges.length > 0) { const target = trigger.outputChange.target; const curState = trigger.outputChange.state; const match = outputChanges.find(c => { const k = `${c.devName}:${c.port}`; const targetMatch = (target === 'any' || target.replace(':out', '') === k); const stateMatch = (curState === 'on' && c.state === 1) || (curState === 'off' && c.state === 0); return targetMatch && stateMatch; }); if (match) triggered = true; } // 2. Sensor Triggers (with DB fallback for external sensors like CO2) let sensorDetails = []; if (!triggered && trigger.sensors && trigger.sensors.length > 0) { const results = trigger.sensors.map(cond => { const sensorId = cond.sensor; const operator = cond.operator; const threshold = cond.value; const parts = sensorId.split(':'); const devName = parts[0]; const portNum = parts.length > 1 ? parseInt(parts[1]) : null; const type = parts.length === 2 ? parts[1] : parts[2]; let value = null; // First try live readings from API const reading = readings.find(r => r.devName === devName); if (type === 'temp' && reading) value = reading.temp_c; if (type === 'humidity' && reading) value = reading.humidity; if (type === 'level' && reading && portNum !== null) { const p = reading.ports.find(rp => rp.port === portNum); if (p) value = p.speak; } // Fallback: Query database for recent readings (for external sensors like CO2) if (value === null && portNum !== null) { try { const dbReading = db.prepare(` SELECT level FROM readings WHERE dev_name = ? AND port = ? ORDER BY timestamp DESC LIMIT 1 `).get(devName, portNum); if (dbReading && dbReading.level !== null) { value = dbReading.level; } } catch (e) { console.error('DB fallback read error:', e); } } // Track sensor value for logging sensorDetails.push({ sensor: sensorId, value: value, operator, threshold }); if (value === null || value === undefined) return false; 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); } } if (triggered) { const sensorInfo = sensorDetails.map(s => `${s.sensor}: ${s.value === null ? 'NULL' : s.value} (${s.operator} ${s.threshold})` ).join(', '); console.log(`ALARM TRIGGERED: ${alarm.name} | Sensors: ${sensorInfo}`); 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\n📊 Sensor Values:\n${sensorDetails.map(s => `• ${s.sensor}: ${s.value === null ? 'NULL' : s.value} (threshold: ${s.operator} ${s.threshold})` ).join('\n')}`; 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) { // Process Port (Online OR Offline) // We want to track even offline ports so they appear in output/rule logic (as OFF) const port = portInfo.port; let settings = null; if (portInfo.online === 1) { 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; // Capture device-level env from first valid port 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`); } else { // Offline or Failed to get settings -> Treat as present but OFF/No Data // We need to know Port Name though. `portInfo` usually has it. let portName = portInfo.portName || `Port ${port}`; // We DO NOT insert into 'readings' table to avoid polluting sensor history with nulls? // OR we insert with nulls? Typically users want to see breaks in graphs. deviceReadings.ports.push({ port, portName, speak: 0, // Assume OFF if offline temp: null, hum: null }); // Console log optional // console.log(`Device ${device.devName} Port ${port} is Offline/Skipped.`); } } currentReadings.push(deviceReadings); } // Evaluate Rules const changes = evaluateRules(currentReadings); // Evaluate Alarms (with outputs changes) evaluateAlarms(currentReadings, changes); } 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 }); } }); // GET /api/rules/active - return list of active rule IDs app.get('/api/rules/active', (req, res) => { try { const activeRules = []; // Check currentOutputs (lastOutputState) for any active states lastOutputState.forEach(val => { if (val.state > 0 && val.ruleId) { activeRules.push({ id: val.ruleId, level: val.level, state: val.state, val: val // debugging }); } }); // Also check memory 'ruleStates' for keepOn rules ruleStates.forEach((val, keyId) => { if (!activeRules.find(r => r.id === keyId)) { activeRules.push({ id: keyId, level: 10, state: 1, type: 'keepOn' }); } }); res.json(activeRules); } 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 }); } }); // API: Output History (New) app.get('/api/outputs/history', (req, res) => { try { const stmt = db.prepare(` SELECT * FROM output_log WHERE timestamp > datetime('now', '-24 hours') ORDER BY timestamp ASC `); const rows = stmt.all(); res.json(rows); } catch (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); });