1429 lines
52 KiB
JavaScript
1429 lines
52 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
|
|
)
|
|
`);
|
|
|
|
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).
|
|
|
|
// Log only if changed
|
|
let shouldLog = false;
|
|
if (!prev) shouldLog = true;
|
|
else if (prev.state !== val.state || (val.state === 1 && prev.level !== val.level)) shouldLog = true;
|
|
|
|
if (shouldLog) {
|
|
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, offset = 0 } = req.query;
|
|
if (!devName || !port) return res.status(400).json({ error: 'Missing devName or port' });
|
|
|
|
const off = parseInt(offset, 10) || 0;
|
|
let bucketSize; // seconds
|
|
|
|
switch (range) {
|
|
case 'week':
|
|
bucketSize = 15 * 60;
|
|
break;
|
|
case 'month':
|
|
bucketSize = 60 * 60;
|
|
break;
|
|
case 'day': default:
|
|
bucketSize = 3 * 60;
|
|
break;
|
|
}
|
|
|
|
// Calculate time modifiers using offset in seconds
|
|
let durationSec;
|
|
if (range === 'week') durationSec = 7 * 24 * 3600;
|
|
else if (range === 'month') durationSec = 30 * 24 * 3600;
|
|
else durationSec = 24 * 3600; // day
|
|
|
|
const endOffsetSec = off * durationSec;
|
|
const startOffsetSec = (off + 1) * durationSec;
|
|
|
|
const endMod = `-${endOffsetSec} seconds`;
|
|
const startMod = `-${startOffsetSec} seconds`;
|
|
|
|
// Select raw data
|
|
// Select raw data
|
|
const stmt = db.prepare(`
|
|
SELECT strftime('%s', timestamp) as ts, temp_c, humidity, fan_speed
|
|
FROM readings
|
|
WHERE dev_name = ? AND port = ?
|
|
AND timestamp >= datetime('now', ?)
|
|
AND timestamp < datetime('now', ?)
|
|
ORDER BY timestamp ASC
|
|
`);
|
|
|
|
const rows = stmt.all(devName, parseInt(port, 10), startMod, endMod);
|
|
|
|
if (rows.length === 0) return res.json({ start: 0, step: bucketSize, temps: [], hums: [], levels: [] });
|
|
|
|
// Aggregate into buckets
|
|
const startTs = parseInt(rows[0].ts);
|
|
// Align start to bucket
|
|
const roundedStart = Math.floor(startTs / bucketSize) * bucketSize;
|
|
|
|
const buckets = new Map();
|
|
|
|
rows.forEach(r => {
|
|
const ts = parseInt(r.ts);
|
|
const bucketKey = Math.floor(ts / bucketSize) * bucketSize;
|
|
|
|
if (!buckets.has(bucketKey)) {
|
|
buckets.set(bucketKey, { count: 0, tempSum: 0, humSum: 0, levelSum: 0 });
|
|
}
|
|
|
|
const b = buckets.get(bucketKey);
|
|
b.count++;
|
|
if (r.temp_c !== null) b.tempSum += r.temp_c;
|
|
if (r.humidity !== null) b.humSum += r.humidity;
|
|
if (r.fan_speed !== null) b.levelSum += r.fan_speed;
|
|
});
|
|
|
|
const temps = [];
|
|
const hums = [];
|
|
const levels = [];
|
|
|
|
// Fill gaps if strictly needed?
|
|
// For dense array, we need continuous steps from Start.
|
|
// Let's find max TS to know length.
|
|
const lastRow = rows[rows.length - 1];
|
|
const endTs = parseInt(lastRow.ts);
|
|
const roundedEnd = Math.floor(endTs / bucketSize) * bucketSize;
|
|
|
|
const numBuckets = (roundedEnd - roundedStart) / bucketSize + 1;
|
|
|
|
for (let i = 0; i < numBuckets; i++) {
|
|
const currentTs = roundedStart + (i * bucketSize);
|
|
const b = buckets.get(currentTs);
|
|
|
|
if (b && b.count > 0) {
|
|
// Formatting to 1 decimal place
|
|
temps.push(parseFloat((b.tempSum / b.count).toFixed(1)));
|
|
hums.push(parseFloat((b.humSum / b.count).toFixed(1)));
|
|
// Level: round to nearest int? or keep decimal? User said "averaged values".
|
|
// Usually levels are int, but average might be 5.5. Let's keep 1 decimal for smoothness or round?
|
|
// Charts handle decimals.
|
|
levels.push(parseFloat((b.levelSum / b.count).toFixed(1)));
|
|
} else {
|
|
// Gap -> null
|
|
temps.push(null);
|
|
hums.push(null);
|
|
levels.push(null);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
start: roundedStart,
|
|
step: bucketSize,
|
|
temps,
|
|
hums,
|
|
levels
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// API: Output History (Compressed)
|
|
app.get('/api/outputs/history', (req, res) => {
|
|
try {
|
|
const { range, offset = 0 } = req.query;
|
|
const off = parseInt(offset, 10) || 0;
|
|
|
|
// Calculate duration in seconds
|
|
let durationSec;
|
|
if (range === 'week') durationSec = 7 * 24 * 3600;
|
|
else if (range === 'month') durationSec = 30 * 24 * 3600;
|
|
else durationSec = 24 * 3600; // day
|
|
|
|
const endOffsetSec = off * durationSec;
|
|
const startOffsetSec = (off + 1) * durationSec;
|
|
|
|
const endMod = `-${endOffsetSec} seconds`;
|
|
const startMod = `-${startOffsetSec} seconds`;
|
|
|
|
const stmt = db.prepare(`
|
|
SELECT timestamp, dev_name, port, state, level
|
|
FROM output_log
|
|
WHERE timestamp >= datetime('now', ?)
|
|
AND timestamp < datetime('now', ?)
|
|
ORDER BY timestamp ASC
|
|
`);
|
|
|
|
const rows = stmt.all(startMod, endMod);
|
|
|
|
// Compress: Group by "Dev:Port" -> [[ts, state, level], ...]
|
|
const compressed = {};
|
|
|
|
rows.forEach(r => {
|
|
const key = `${r.dev_name}:${r.port}`;
|
|
if (!compressed[key]) compressed[key] = [];
|
|
|
|
// Convert timestamp to epoch ms for client (saves parsing there) or seconds?
|
|
// Client uses `new Date(ts).getTime()`. Let's give them epoch milliseconds to be consistent with other efficient APIs.
|
|
// SQLite 'timestamp' is string "YYYY-MM-DD HH:MM:SS".
|
|
// We can use strftime('%s', timestamp) * 1000 in SQL or parse here.
|
|
// Let's parse here to be safe with timezones if needed, effectively assuming UTC/server time.
|
|
// Actually, querying SQL for epoch is faster.
|
|
// Let's treat the existing ROWs which are strings.
|
|
const ts = new Date(r.timestamp + 'Z').getTime(); // Append Z to force UTC if missing
|
|
|
|
const lvl = r.level === null ? 0 : r.level;
|
|
|
|
// Check last entry for this key
|
|
const lastEntry = compressed[key][compressed[key].length - 1];
|
|
|
|
if (!lastEntry) {
|
|
// First entry, always add
|
|
compressed[key].push([ts, r.state, lvl]);
|
|
} else {
|
|
// Compare with last entry: [ts, state, level]
|
|
const lastState = lastEntry[1];
|
|
const lastLvl = lastEntry[2];
|
|
|
|
if (r.state != lastState || lvl != lastLvl) {
|
|
// State changed, add new point
|
|
compressed[key].push([ts, r.state, lvl]);
|
|
}
|
|
}
|
|
});
|
|
|
|
res.json(compressed);
|
|
} 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);
|
|
});
|