rule log
This commit is contained in:
454
server.js
454
server.js
@@ -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,18 +694,21 @@ async function poll() {
|
||||
};
|
||||
|
||||
for (const portInfo of ports) {
|
||||
// Filter by online status
|
||||
if (portInfo.online === 1) {
|
||||
// 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;
|
||||
const settings = await getDeviceModeSettings(token, device.devId, 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;
|
||||
|
||||
// Some devices report environment at "device" level, others at port.
|
||||
// We'll capture first valid env reading as device reading for simplicity
|
||||
// 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;
|
||||
|
||||
@@ -393,9 +716,7 @@ async function poll() {
|
||||
let portName = portInfo.portName;
|
||||
if (!portName || portName.startsWith('Port ')) {
|
||||
const typeName = DEVICE_TYPES[settings.atType];
|
||||
if (typeName) {
|
||||
portName = typeName;
|
||||
}
|
||||
if (typeName) portName = typeName;
|
||||
}
|
||||
|
||||
insertStmt.run(
|
||||
@@ -424,14 +745,33 @@ async function poll() {
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -85,11 +85,18 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], colorTags: availableColorTags = [], saving }) {
|
||||
export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
|
||||
// Trigger Mode: 'sensor' or 'output'
|
||||
const [triggerMode, setTriggerMode] = useState('sensor');
|
||||
|
||||
// Output Change Config
|
||||
const [outputTarget, setOutputTarget] = useState('');
|
||||
const [outputState, setOutputState] = useState('on');
|
||||
|
||||
// Scheduled time (not commonly used for alarms, but keeping parity with rules engine if needed)
|
||||
// Actually, alarms are usually condition-based (Value > X). Time-based alarms remind you to do something?
|
||||
// Let's keep it simple: SENSORS ONLY for now described in plan ("triggers (based on sensors, time, etc.)")
|
||||
@@ -130,10 +137,16 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
||||
setTimeRangeDays(trigger.timeRange.days || []);
|
||||
}
|
||||
|
||||
if (trigger.sensors && trigger.sensors.length > 0) {
|
||||
if (trigger.outputChange) {
|
||||
setTriggerMode('output');
|
||||
setOutputTarget(trigger.outputChange.target || '');
|
||||
setOutputState(trigger.outputChange.state || 'on');
|
||||
} else if (trigger.sensors && trigger.sensors.length > 0) {
|
||||
setTriggerMode('sensor');
|
||||
setSensorConditions(trigger.sensors);
|
||||
setSensorLogic(trigger.sensorLogic || 'and');
|
||||
} else {
|
||||
setTriggerMode('sensor');
|
||||
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
||||
}
|
||||
|
||||
@@ -144,6 +157,9 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
||||
} else {
|
||||
setName('');
|
||||
setSelectedTags([]);
|
||||
setTriggerMode('sensor');
|
||||
setOutputTarget('');
|
||||
setOutputState('on');
|
||||
setUseTimeRange(false);
|
||||
setTimeStart('08:00');
|
||||
setTimeEnd('18:00');
|
||||
@@ -184,15 +200,22 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
||||
// Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too)
|
||||
// Let's assume user wants to monitor something.
|
||||
|
||||
if (triggerMode === 'output') {
|
||||
trigger.outputChange = {
|
||||
target: outputTarget,
|
||||
state: outputState
|
||||
};
|
||||
} else {
|
||||
// Sensor Mode
|
||||
if (useTimeRange) {
|
||||
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
|
||||
}
|
||||
|
||||
trigger.sensors = sensorConditions.map(c => ({
|
||||
...c,
|
||||
sensorLabel: sensors.find(s => s.id === c.sensor)?.label
|
||||
}));
|
||||
trigger.sensorLogic = sensorLogic;
|
||||
}
|
||||
|
||||
const action = {
|
||||
type: 'telegram',
|
||||
@@ -204,7 +227,8 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0 &&
|
||||
sensorConditions.every(c => c.sensor) &&
|
||||
((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) ||
|
||||
(triggerMode === 'output' && outputTarget)) &&
|
||||
message.trim().length > 0;
|
||||
|
||||
return (
|
||||
@@ -263,6 +287,53 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
||||
<Typography variant="subtitle2" color="text.secondary">TRIGGER CONDITIONS</Typography>
|
||||
<Divider />
|
||||
|
||||
{/* Trigger Mode Selection */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Trigger Type</InputLabel>
|
||||
<Select
|
||||
value={triggerMode}
|
||||
label="Trigger Type"
|
||||
onChange={(e) => setTriggerMode(e.target.value)}
|
||||
>
|
||||
<MenuItem value="sensor">📊 Sensor Value Threshold</MenuItem>
|
||||
<MenuItem value="output">🔌 Output Turn ON/OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{triggerMode === 'output' ? (
|
||||
<Paper sx={{ p: 2, bgcolor: 'action.selected', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Alert severity="info" icon={false} sx={{ py: 0 }}>
|
||||
Trigger when a filtered rule changes a device state.
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Target Output</InputLabel>
|
||||
<Select
|
||||
value={outputTarget}
|
||||
label="Target Output"
|
||||
onChange={(e) => setOutputTarget(e.target.value)}
|
||||
>
|
||||
<MenuItem value="any">Any Output</MenuItem>
|
||||
{outputs.map(o => (
|
||||
<MenuItem key={o.id} value={o.id}>{o.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ minWidth: 120 }}>
|
||||
<InputLabel>State</InputLabel>
|
||||
<Select
|
||||
value={outputState}
|
||||
label="State"
|
||||
onChange={(e) => setOutputState(e.target.value)}
|
||||
>
|
||||
<MenuItem value="on">Turns ON</MenuItem>
|
||||
<MenuItem value="off">Turns OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
{/* Time Window */}
|
||||
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
||||
<FormControlLabel
|
||||
@@ -320,6 +391,8 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ACTION */}
|
||||
<Typography variant="subtitle2" color="text.secondary">ACTION (Telelegram Notification)</Typography>
|
||||
|
||||
@@ -93,6 +93,15 @@ export default function AlarmManager() {
|
||||
});
|
||||
});
|
||||
|
||||
// Build available outputs
|
||||
const availableOutputs = [];
|
||||
devices.forEach(d => {
|
||||
availableOutputs.push({
|
||||
id: `${d.dev_name}:${d.port}:out`,
|
||||
label: `${d.dev_name} - ${d.port_name} (Output)`
|
||||
});
|
||||
});
|
||||
|
||||
const handleAddAlarm = () => {
|
||||
setEditingAlarm(null);
|
||||
setEditorOpen(true);
|
||||
@@ -286,6 +295,7 @@ export default function AlarmManager() {
|
||||
onSave={handleSaveAlarm}
|
||||
onClose={() => { setEditorOpen(false); setEditingAlarm(null); }}
|
||||
sensors={availableSensors}
|
||||
outputs={availableOutputs}
|
||||
colorTags={COLOR_TAGS}
|
||||
saving={saving}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
|
||||
import ControllerCard from './ControllerCard';
|
||||
import OutputChart from './OutputChart';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useI18n();
|
||||
const [groupedDevices, setGroupedDevices] = useState({});
|
||||
const [allDevices, setAllDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [range, setRange] = useState('day'); // 'day', 'week', 'month'
|
||||
@@ -31,6 +33,7 @@ export default function Dashboard() {
|
||||
}, {});
|
||||
|
||||
setGroupedDevices(grouped);
|
||||
setAllDevices(devices);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -82,6 +85,8 @@ export default function Dashboard() {
|
||||
range={range}
|
||||
/>
|
||||
))}
|
||||
|
||||
<OutputChart range={range} devices={allDevices} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
208
src/client/OutputChart.js
Normal file
208
src/client/OutputChart.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export default function OutputChart({ range, devices = [] }) {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch('api/outputs/history');
|
||||
const logs = await res.json();
|
||||
setData(logs);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch output history', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
// Poll for updates every minute
|
||||
const interval = setInterval(fetchData, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (loading) return <CircularProgress size={20} />;
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
// Group data by "Device:Port"
|
||||
const groupedData = {};
|
||||
data.forEach(log => {
|
||||
const key = `${log.dev_name}:${log.port}`;
|
||||
if (!groupedData[key]) groupedData[key] = [];
|
||||
groupedData[key].push(log);
|
||||
});
|
||||
|
||||
// Gruvbox Palette
|
||||
const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374'];
|
||||
|
||||
// Generate datasets
|
||||
const datasets = Object.keys(groupedData).map((key, index) => {
|
||||
const logs = groupedData[key];
|
||||
const color = gruvboxColors[index % gruvboxColors.length];
|
||||
|
||||
// Resolve Label
|
||||
let label = key;
|
||||
const isTapo = key.includes('tapo') || key.includes('Plug'); // Simple check
|
||||
|
||||
if (devices && devices.length > 0) {
|
||||
const [dName, pNum] = key.split(':');
|
||||
const portNum = parseInt(pNum);
|
||||
const device = devices.find(d => d.dev_name === dName && d.port === portNum);
|
||||
if (device) {
|
||||
label = `${device.dev_name} - ${device.port_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback friendly name for Tapo if not found in devices list (which comes from readings)
|
||||
// Check if key looks like "tapo-xxx:0"
|
||||
if (label === key && key.startsWith('tapo-')) {
|
||||
// "tapo-001:0" -> "Tapo Plug 001"
|
||||
const parts2 = key.split(':');
|
||||
const tapoId = parts2[0].replace('tapo-', '');
|
||||
label = `Tapo Plug ${tapoId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
label: label,
|
||||
data: logs.map(d => ({
|
||||
x: new Date(d.timestamp).getTime(),
|
||||
y: d.state === 0 ? 0 : (d.level || 10)
|
||||
})),
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
stepped: true,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
// Custom property to identify binary devices in tooltip
|
||||
isBinary: isTapo
|
||||
};
|
||||
});
|
||||
|
||||
// Create a time axis based on the data range
|
||||
const allTimestamps = [...new Set(data.map(d => d.timestamp))].sort();
|
||||
|
||||
// We need to normalize data to these timestamps for "Line" chart
|
||||
const chartLabels = allTimestamps.map(ts => {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
|
||||
// We need to map *each* dataset's data to align with `chartLabels`.
|
||||
const alignedDatasets = datasets.map((ds, index) => {
|
||||
const alignedData = [];
|
||||
let lastValue = 0; // Default off
|
||||
const offset = index * 0.15; // Small offset to avoid overlap
|
||||
|
||||
// Populate alignedData matching `allTimestamps`
|
||||
allTimestamps.forEach(ts => {
|
||||
// Find if we have a log at this specific timestamp
|
||||
const timeMs = new Date(ts).getTime();
|
||||
const exactLog = ds.data.find(d => Math.abs(d.x - timeMs) < 1000); // 1s tolerance
|
||||
|
||||
if (exactLog) {
|
||||
lastValue = exactLog.y;
|
||||
}
|
||||
// Apply offset to the value for visualization
|
||||
// If value is 0 (OFF), we might still want to offset it?
|
||||
// Or only if ON? The user said "levels are on top of each other".
|
||||
// Even OFF lines might overlap. Let's offset everything.
|
||||
alignedData.push(lastValue + offset);
|
||||
});
|
||||
|
||||
return {
|
||||
...ds,
|
||||
data: alignedData
|
||||
};
|
||||
});
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 12, // Increased max to accommodate offsets
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (val) => {
|
||||
const rVal = Math.round(val);
|
||||
if (rVal === 0) return 'OFF';
|
||||
if (rVal === 10) return 'MAX/ON';
|
||||
return rVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: { color: '#ebdbb2' }
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Output History (Levels & States)',
|
||||
color: '#ebdbb2'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
// Round down to ignore the offset
|
||||
const val = Math.floor(context.raw);
|
||||
const ds = context.dataset;
|
||||
|
||||
if (val === 0) return `${ds.label}: OFF`;
|
||||
// If it's binary (Tapo) or max level, show ON
|
||||
if (val === 10 || ds.isBinary) return `${ds.label}: ON`;
|
||||
|
||||
return `${ds.label}: Level ${val}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mt: 3, bgcolor: '#282828', color: '#ebdbb2' }}>
|
||||
<Box sx={{ height: 300 }}>
|
||||
<Line data={{ labels: chartLabels, datasets: alignedDatasets }} options={options} />
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ function ActionSummary({ action }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly }) {
|
||||
export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly, activeInfo }) {
|
||||
const { t } = useI18n();
|
||||
// Get list of tag colors for this rule (handle array or backwards-compat single value)
|
||||
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
||||
@@ -168,9 +168,10 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o
|
||||
p: 2,
|
||||
opacity: rule.enabled ? 1 : 0.6,
|
||||
transition: 'opacity 0.2s, transform 0.2s',
|
||||
border: '1px solid',
|
||||
border: '1px solid', // Standard border
|
||||
borderColor: rule.enabled ? '#504945' : '#3c3836',
|
||||
borderLeft: ruleTags.length > 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
|
||||
boxShadow: activeInfo ? '0 0 0 2px #b8bb26' : 'none', // Active highlight
|
||||
'&:hover': {
|
||||
transform: readOnly ? 'none' : 'translateX(4px)',
|
||||
borderColor: rule.enabled ? '#8ec07c' : '#504945'
|
||||
@@ -180,8 +181,21 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{rule.name}
|
||||
{/* ACTIVE INDICATOR */}
|
||||
{activeInfo && (
|
||||
<Chip
|
||||
label={`Active: Lvl ${activeInfo.level}`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: '#b8bb26',
|
||||
color: '#282828',
|
||||
fontWeight: 'bold',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
{ruleTags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function RuleManager() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState([]);
|
||||
const [activeRuleIds, setActiveRuleIds] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -63,7 +64,7 @@ export default function RuleManager() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [setRules, setLoading, setError]);
|
||||
|
||||
// Fetch devices for sensor/output selection
|
||||
const fetchDevices = useCallback(async () => {
|
||||
@@ -78,10 +79,25 @@ export default function RuleManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchActiveRules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('api/rules/active');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setActiveRuleIds(data); // "ids" is now a list of objects {id, level, state}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch active rules:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules();
|
||||
fetchDevices();
|
||||
}, [fetchRules, fetchDevices]);
|
||||
fetchActiveRules();
|
||||
const interval = setInterval(fetchActiveRules, 5000); // 5s poll
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchRules, fetchDevices, fetchActiveRules]);
|
||||
|
||||
// Build available sensors
|
||||
// - Environment sensors (Temp, Humidity) are per DEVICE
|
||||
@@ -361,6 +377,7 @@ export default function RuleManager() {
|
||||
onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => handleMoveRule(rule.id, 'down') : null}
|
||||
colorTags={COLOR_TAGS}
|
||||
readOnly={!isAdmin}
|
||||
activeInfo={activeRuleIds.find(r => r.id === rule.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"port": "Anschluss"
|
||||
},
|
||||
"rules": {
|
||||
"title": "⚙️ Automatisierungsregeln",
|
||||
"title": "⚙️ Automatisierungsregeln (Simulation)",
|
||||
"adminDescription": "Trigger und Aktionen für die Hausautomatisierung konfigurieren",
|
||||
"guestDescription": "Automatisierungsregeln ansehen (nur Lesen)",
|
||||
"addRule": "+ Regel hinzufügen",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"port": "Port"
|
||||
},
|
||||
"rules": {
|
||||
"title": "⚙️ Automation Rules",
|
||||
"title": "⚙️ Automation Rules (simulated)",
|
||||
"adminDescription": "Configure triggers and actions for home automation",
|
||||
"guestDescription": "View automation rules (read-only)",
|
||||
"addRule": "+ Add Rule",
|
||||
|
||||
Reference in New Issue
Block a user