This commit is contained in:
sebseb7
2025-12-21 05:13:39 +01:00
parent 739b6fe54f
commit 077e033d2e
9 changed files with 855 additions and 144 deletions

528
server.js
View File

@@ -99,6 +99,19 @@ db.exec(`
)
`);
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) { }
@@ -184,40 +197,353 @@ async function sendTelegramNotification(message) {
// Simple state tracking to avoid spamming: alarmId -> lastTriggeredTime
const alarmStates = new Map();
function evaluateAlarms(readings) {
// --- 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;
// Skip if recently triggered (debounce 5 mins)
// Debounce
const lastTrigger = alarmStates.get(alarm.id);
if (lastTrigger && (Date.now() - lastTrigger) < 5 * 60 * 1000) {
return;
}
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) {
// 1. Output State Change Triggers
if (trigger.outputChange && outputChanges.length > 0) {
// config: { target: "Dev:1", toState: "on" }
// or "Any"
const target = trigger.outputChange.target; // "Dev:1:out" or "any"
const curState = trigger.outputChange.state; // "on" or "off"
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 (Existing Logic)
if (!triggered && trigger.sensors && trigger.sensors.length > 0) {
const results = trigger.sensors.map(cond => {
const sensorId = cond.sensor; // e.g., "Wall Display:temp"
const sensorId = cond.sensor;
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 type = parts.length === 2 ? parts[1] : parts[2];
const reading = readings.find(r => r.devName === devName);
if (!reading) return false;
@@ -226,7 +552,6 @@ function evaluateAlarms(readings) {
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;
@@ -234,7 +559,6 @@ function evaluateAlarms(readings) {
if (value === null || value === undefined) return false;
// Numeric comparison
switch (operator) {
case '>': return value > threshold;
case '<': return value < threshold;
@@ -252,15 +576,11 @@ function evaluateAlarms(readings) {
}
}
// 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'}`;
const msg = `🚨 ALARM: ${alarm.name}\n\n${action.message || 'No message'}`;
sendTelegramNotification(msg);
}
} catch (err) {
@@ -374,64 +694,84 @@ async function poll() {
};
for (const portInfo of ports) {
// Filter by online status
// 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) {
const port = portInfo.port;
const settings = await getDeviceModeSettings(token, device.devId, port);
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;
if (settings) {
const tempC = settings.temperature ? settings.temperature / 100 : null;
const hum = settings.humidity ? settings.humidity / 100 : null;
const vpd = settings.vpdnums ? settings.vpdnums / 100 : null;
// Some devices report environment at "device" level, others at port.
// We'll capture first valid env reading as device reading for simplicity
if (tempC !== null && deviceReadings.temp_c === null) deviceReadings.temp_c = tempC;
if (hum !== null && deviceReadings.humidity === null) deviceReadings.humidity = hum;
// 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`);
// 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 Alarms
evaluateAlarms(currentReadings);
// Evaluate Rules
const changes = evaluateRules(currentReadings);
// Evaluate Alarms (with outputs changes)
evaluateAlarms(currentReadings, changes);
} catch (error) {
console.error('Polling error:', error.message);
@@ -684,6 +1024,35 @@ app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
}
});
// 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) => {
@@ -824,6 +1193,21 @@ app.get('/api/history', (req, res) => {
}
});
// 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.