From 077e033d2eca9bdc9c9adfc05abc394e28804d3e Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Sun, 21 Dec 2025 05:13:39 +0100 Subject: [PATCH] rule log --- server.js | 528 ++++++++++++++++++++++++++++++++----- src/client/AlarmEditor.js | 203 +++++++++----- src/client/AlarmManager.js | 10 + src/client/Dashboard.js | 5 + src/client/OutputChart.js | 208 +++++++++++++++ src/client/RuleCard.js | 20 +- src/client/RuleManager.js | 21 +- src/client/i18n/de.json | 2 +- src/client/i18n/en.json | 2 +- 9 files changed, 855 insertions(+), 144 deletions(-) create mode 100644 src/client/OutputChart.js diff --git a/server.js b/server.js index fa5c438..5a459af 100644 --- a/server.js +++ b/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,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. diff --git a/src/client/AlarmEditor.js b/src/client/AlarmEditor.js index 39ea0e7..4bafc82 100644 --- a/src/client/AlarmEditor.js +++ b/src/client/AlarmEditor.js @@ -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,16 +200,23 @@ 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 (useTimeRange) { - trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays }; + 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; } - trigger.sensors = sensorConditions.map(c => ({ - ...c, - sensorLabel: sensors.find(s => s.id === c.sensor)?.label - })); - trigger.sensorLogic = sensorLogic; - const action = { type: 'telegram', message, @@ -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,63 +287,112 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [] TRIGGER CONDITIONS - {/* Time Window */} - - setUseTimeRange(e.target.checked)} disabled={saving} /> - } - label="Active Time Window (Optional)" - /> - {useTimeRange && ( - - setTimeStart(e.target.value)} - InputLabelProps={{ shrink: true }} size="small" - /> - to - setTimeEnd(e.target.value)} - InputLabelProps={{ shrink: true }} size="small" - /> - - )} - + {/* Trigger Mode Selection */} + + Trigger Type + + - {/* Sensors */} - - 📊 Sensor Thresholds - - {sensorConditions.length > 1 && ( - - Logic: - v && setSensorLogic(v)} size="small" + {triggerMode === 'output' ? ( + + + Trigger when a filtered rule changes a device state. + + + + Target Output + + + + State + + + + + ) : ( + <> + {/* Time Window */} + + setUseTimeRange(e.target.checked)} disabled={saving} /> + } + label="Active Time Window (Optional)" /> - ))} - - - + {useTimeRange && ( + + setTimeStart(e.target.value)} + InputLabelProps={{ shrink: true }} size="small" + /> + to + setTimeEnd(e.target.value)} + InputLabelProps={{ shrink: true }} size="small" + /> + + )} + + + {/* Sensors */} + + 📊 Sensor Thresholds + + {sensorConditions.length > 1 && ( + + Logic: + v && setSensorLogic(v)} size="small" + > + AND + OR + + + )} + + {sensorConditions.map((cond, i) => ( + updateSensorCondition(i, newCond)} + onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null} + disabled={saving} + /> + ))} + + + + + )} {/* ACTION */} ACTION (Telelegram Notification) diff --git a/src/client/AlarmManager.js b/src/client/AlarmManager.js index 5da52f6..17ac0ff 100644 --- a/src/client/AlarmManager.js +++ b/src/client/AlarmManager.js @@ -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} /> diff --git a/src/client/Dashboard.js b/src/client/Dashboard.js index bd3c708..8b8d7c2 100644 --- a/src/client/Dashboard.js +++ b/src/client/Dashboard.js @@ -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} /> ))} + + ); } diff --git a/src/client/OutputChart.js b/src/client/OutputChart.js new file mode 100644 index 0000000..82034df --- /dev/null +++ b/src/client/OutputChart.js @@ -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 ; + 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 ( + + + + + + ); +} diff --git a/src/client/RuleCard.js b/src/client/RuleCard.js index be7bc68..e1ffdb7 100644 --- a/src/client/RuleCard.js +++ b/src/client/RuleCard.js @@ -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 - + {rule.name} + {/* ACTIVE INDICATOR */} + {activeInfo && ( + + )} {ruleTags.length > 0 && ( diff --git a/src/client/RuleManager.js b/src/client/RuleManager.js index 50d9f5a..8d14e0d 100644 --- a/src/client/RuleManager.js +++ b/src/client/RuleManager.js @@ -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)} /> ))} diff --git a/src/client/i18n/de.json b/src/client/i18n/de.json index 668a75a..78eac4b 100644 --- a/src/client/i18n/de.json +++ b/src/client/i18n/de.json @@ -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", diff --git a/src/client/i18n/en.json b/src/client/i18n/en.json index 06ca8ea..e1f5c49 100644 --- a/src/client/i18n/en.json +++ b/src/client/i18n/en.json @@ -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",