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
+
-
- )}
-
- {sensorConditions.map((cond, i) => (
- updateSensorCondition(i, newCond)}
- onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
- disabled={saving}
+
+ {outputs.map(o => (
+
+ ))}
+
+
+
+ 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",