diff --git a/server.js b/server.js
index 734b5fc..cb10653 100644
--- a/server.js
+++ b/server.js
@@ -74,11 +74,21 @@ db.exec(`
trigger_data TEXT NOT NULL,
action_type TEXT NOT NULL,
action_data TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0,
+ color_tag TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
+// 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) { /* column already exists */ }
+try {
+ db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL');
+} catch (e) { /* column already exists */ }
+
const insertStmt = db.prepare(`
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -87,17 +97,19 @@ const insertStmt = db.prepare(`
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
// Rules prepared statements
-const getAllRules = db.prepare('SELECT * FROM rules ORDER BY id');
+const getAllRules = db.prepare('SELECT * FROM rules ORDER BY sort_order, id');
const getRuleById = db.prepare('SELECT * FROM rules WHERE id = ?');
const insertRule = db.prepare(`
- INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data)
- VALUES (?, ?, ?, ?, ?, ?)
+ INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const updateRule = db.prepare(`
- UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, updated_at = CURRENT_TIMESTAMP
+ UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, color_tag = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
+const updateRuleOrder = db.prepare('UPDATE rules SET sort_order = ? WHERE id = ?');
const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
+const getMaxSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM rules');
// --- AC INFINITY API LOGIC ---
let token = null;
@@ -342,10 +354,23 @@ function formatRule(row) {
action.type = row.action_type;
}
+ // Parse colorTags (stored as JSON array)
+ let colorTags = [];
+ if (row.color_tag) {
+ try {
+ colorTags = JSON.parse(row.color_tag);
+ } catch (e) {
+ // Backwards compat: single tag as string
+ colorTags = [row.color_tag];
+ }
+ }
+
return {
id: row.id,
name: row.name,
enabled: row.enabled === 1,
+ sortOrder: row.sort_order || 0,
+ colorTags,
trigger,
action
};
@@ -364,7 +389,7 @@ app.get('/api/rules', (req, res) => {
// POST /api/rules - admin only
app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
try {
- const { name, enabled, trigger, action } = req.body;
+ const { name, enabled, trigger, action, colorTags } = req.body;
if (!name || !trigger || !action) {
return res.status(400).json({ error: 'name, trigger, and action required' });
}
@@ -374,9 +399,14 @@ app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
+ // Get next sort order
+ const maxOrder = getMaxSortOrder.get().max_order;
+ const sortOrder = maxOrder + 1;
+
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action);
- const result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData);
+ const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
+ const result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, sortOrder, colorTagsData);
const newRule = getRuleById.get(result.lastInsertRowid);
res.status(201).json(formatRule(newRule));
@@ -394,7 +424,7 @@ app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
return res.status(404).json({ error: 'Rule not found' });
}
- const { name, enabled, trigger, action } = req.body;
+ const { name, enabled, trigger, action, colorTags } = req.body;
// Determine trigger type for storage
let triggerType = 'combined';
@@ -403,7 +433,8 @@ app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action);
- updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, id);
+ const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
+ updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, colorTagsData, id);
const updated = getRuleById.get(id);
res.json(formatRule(updated));
@@ -412,6 +443,26 @@ app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
}
});
+// PUT /api/rules/reorder - admin only (reorder rules)
+app.put('/api/rules/reorder', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { ruleIds } = req.body; // Array of rule IDs in new order
+ if (!Array.isArray(ruleIds)) {
+ return res.status(400).json({ error: 'ruleIds array required' });
+ }
+
+ // Update sort_order for each rule
+ ruleIds.forEach((ruleId, index) => {
+ updateRuleOrder.run(index, ruleId);
+ });
+
+ const rows = getAllRules.all();
+ res.json(rows.map(formatRule));
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// DELETE /api/rules/:id - admin only
app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
try {
diff --git a/src/client/RuleCard.js b/src/client/RuleCard.js
index 647cfb0..d686836 100644
--- a/src/client/RuleCard.js
+++ b/src/client/RuleCard.js
@@ -19,7 +19,25 @@ const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function TriggerSummary({ trigger }) {
const parts = [];
- // Time range
+ // Scheduled time (trigger at exact time)
+ if (trigger.scheduledTime) {
+ const { time, days } = trigger.scheduledTime;
+ const isEveryDay = days?.length === 7;
+ const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
+ let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' :
+ dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
+
+ parts.push(
+
+
+
+ At {time} ({dayText})
+
+
+ );
+ }
+
+ // Time range (active during window)
if (trigger.timeRange) {
const { start, end, days } = trigger.timeRange;
const isEveryDay = days?.length === 7;
@@ -95,26 +113,29 @@ function TriggerSummary({ trigger }) {
function ActionSummary({ action }) {
if (action.type === 'toggle') {
+ // Check if it's a level or binary action
+ const hasLevel = action.level !== undefined;
return (
- → {action.targetLabel || action.target} {action.state ? 'ON' : 'OFF'}
+ → {action.targetLabel || action.target} {hasLevel ? `Level ${action.level}` : (action.state ? 'ON' : 'OFF')}
);
}
if (action.type === 'keepOn') {
+ const hasLevel = action.level !== undefined;
return (
- → {action.targetLabel || action.target} ON for {action.duration}m
+ → {action.targetLabel || action.target} {hasLevel ? `Level ${action.level}` : 'ON'} for {action.duration}m
);
@@ -123,7 +144,11 @@ function ActionSummary({ action }) {
return null;
}
-export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) {
+export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly }) {
+ // Get list of tag colors for this rule (handle array or backwards-compat single value)
+ const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
+ const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
+
return (
0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
'&:hover': {
transform: readOnly ? 'none' : 'translateX(4px)',
borderColor: rule.enabled ? '#8ec07c' : '#504945'
@@ -144,6 +170,19 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly })
{rule.name}
+ {ruleTags.length > 0 && (
+
+ {ruleTags.map(tag => (
+
+ ))}
+
+ )}
{!rule.enabled && (
)}
@@ -156,7 +195,34 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly })
{!readOnly && (
-
+
+ {/* Move buttons */}
+
+
+
+
+ ▲
+
+
+
+
+
+
+ ▼
+
+
+
+
diff --git a/src/client/RuleEditor.js b/src/client/RuleEditor.js
index 522054a..b96dda2 100644
--- a/src/client/RuleEditor.js
+++ b/src/client/RuleEditor.js
@@ -44,14 +44,29 @@ const OPERATORS = [
// Single sensor condition component
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
+ const selectedSensor = sensors.find(s => s.id === condition.sensor);
+ const isStateSensor = selectedSensor?.type === 'output-state';
+
+ // When sensor changes, reset operator to appropriate default
+ const handleSensorChange = (newSensorId) => {
+ const newSensor = sensors.find(s => s.id === newSensorId);
+ const newIsState = newSensor?.type === 'output-state';
+ onChange({
+ ...condition,
+ sensor: newSensorId,
+ operator: newIsState ? '==' : '>',
+ value: newIsState ? 1 : (condition.value ?? 25)
+ });
+ };
+
return (
-
+
Sensor
-
-
-
- onChange({ ...condition, value: Number(e.target.value) })}
- sx={{ width: 80 }}
- disabled={disabled}
- />
+
+ {isStateSensor ? (
+ // State sensor: is on / is off
+
+
+
+ ) : (
+ // Value sensor: numeric comparison
+ <>
+
+
+
+ onChange({ ...condition, value: Number(e.target.value) })}
+ sx={{ width: 80 }}
+ disabled={disabled}
+ />
+ >
+ )}
+
{onRemove && (
❌
@@ -87,14 +121,20 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
);
}
-export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], saving }) {
+export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
const [name, setName] = useState('');
+ const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
- // Time range state
+ // Scheduled time state (trigger at specific time)
+ const [useScheduledTime, setUseScheduledTime] = useState(false);
+ const [scheduledTime, setScheduledTime] = useState('08:00');
+ const [scheduledDays, setScheduledDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
+
+ // Time range state (active during window)
const [useTimeRange, setUseTimeRange] = useState(false);
const [timeStart, setTimeStart] = useState('08:00');
const [timeEnd, setTimeEnd] = useState('18:00');
- const [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
+ const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
// Sensor conditions state
const [useSensors, setUseSensors] = useState(false);
@@ -105,20 +145,37 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
const [actionType, setActionType] = useState('toggle');
const [target, setTarget] = useState('');
const [toggleState, setToggleState] = useState(true);
+ const [outputLevel, setOutputLevel] = useState(5); // 1-10 for port outputs
const [duration, setDuration] = useState(15);
+ // Check if target is a binary (on/off) output or level (1-10) output
+ const selectedOutput = outputs.find(o => o.id === target);
+ const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
+
// Reset form when rule changes or dialog opens
useEffect(() => {
if (rule) {
setName(rule.name);
+ // colorTags can be array or single value for backwards compat
+ const tags = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
+ setSelectedTags(Array.isArray(tags) ? tags : []);
// Parse trigger
const trigger = rule.trigger || {};
+
+ // Scheduled time
+ setUseScheduledTime(!!trigger.scheduledTime);
+ if (trigger.scheduledTime) {
+ setScheduledTime(trigger.scheduledTime.time || '08:00');
+ setScheduledDays(trigger.scheduledTime.days || []);
+ }
+
+ // Time range
setUseTimeRange(!!trigger.timeRange);
if (trigger.timeRange) {
setTimeStart(trigger.timeRange.start || '08:00');
setTimeEnd(trigger.timeRange.end || '18:00');
- setDays(trigger.timeRange.days || []);
+ setTimeRangeDays(trigger.timeRange.days || []);
}
setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
@@ -132,22 +189,28 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
setTarget(rule.action?.target || '');
if (rule.action?.type === 'toggle') {
setToggleState(rule.action?.state ?? true);
+ setOutputLevel(rule.action?.level ?? 5);
} else {
setDuration(rule.action?.duration || 15);
}
} else {
// Reset to defaults
setName('');
- setUseTimeRange(true);
+ setSelectedTags([]);
+ setUseScheduledTime(true);
+ setScheduledTime('08:00');
+ setScheduledDays(['mon', 'tue', 'wed', 'thu', 'fri']);
+ setUseTimeRange(false);
setTimeStart('08:00');
setTimeEnd('18:00');
- setDays(['mon', 'tue', 'wed', 'thu', 'fri']);
+ setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setUseSensors(false);
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
setSensorLogic('and');
setActionType('toggle');
setTarget(outputs[0]?.id || '');
setToggleState(true);
+ setOutputLevel(5);
setDuration(15);
}
}, [rule, open, sensors, outputs]);
@@ -160,8 +223,12 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
if (!target && outputs.length > 0) setTarget(outputs[0].id);
}, [sensors, outputs, sensorConditions, target]);
- const handleDaysChange = (event, newDays) => {
- if (newDays.length > 0) setDays(newDays);
+ const handleScheduledDaysChange = (event, newDays) => {
+ if (newDays.length > 0) setScheduledDays(newDays);
+ };
+
+ const handleTimeRangeDaysChange = (event, newDays) => {
+ if (newDays.length > 0) setTimeRangeDays(newDays);
};
const addSensorCondition = () => {
@@ -185,8 +252,11 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
// Build trigger object
const trigger = {};
+ if (useScheduledTime) {
+ trigger.scheduledTime = { time: scheduledTime, days: scheduledDays };
+ }
if (useTimeRange) {
- trigger.timeRange = { start: timeStart, end: timeEnd, days };
+ trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
}
if (useSensors && sensorConditions.length > 0) {
trigger.sensors = sensorConditions.map(c => ({
@@ -196,19 +266,35 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
trigger.sensorLogic = sensorLogic;
}
- const ruleData = {
- name,
- trigger,
- action: actionType === 'toggle'
- ? { type: 'toggle', target, targetLabel: selectedOutput?.label, state: toggleState }
- : { type: 'keepOn', target, targetLabel: selectedOutput?.label, duration }
- };
+ const isBinaryTarget = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
+
+ // Build action object based on output type
+ let action;
+ if (actionType === 'toggle') {
+ action = {
+ type: 'toggle',
+ target,
+ targetLabel: selectedOutput?.label,
+ ...(isBinaryTarget ? { state: toggleState } : { level: outputLevel })
+ };
+ } else {
+ action = {
+ type: 'keepOn',
+ target,
+ targetLabel: selectedOutput?.label,
+ duration,
+ ...(isBinaryTarget ? {} : { level: outputLevel })
+ };
+ }
+
+ const ruleData = { name, trigger, action, colorTags: selectedTags };
onSave(ruleData);
};
const isValid = name.trim().length > 0 &&
- (useTimeRange || useSensors) &&
- (!useTimeRange || days.length > 0) &&
+ (useScheduledTime || useTimeRange || useSensors) &&
+ (!useScheduledTime || scheduledDays.length > 0) &&
+ (!useTimeRange || timeRangeDays.length > 0) &&
(!useSensors || sensorConditions.every(c => c.sensor)) &&
target;
@@ -241,14 +327,119 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
disabled={saving}
/>
+ {/* Color Tag Picker (multi-select) */}
+
+ Tags:
+ setSelectedTags([])}
+ sx={{
+ width: 24,
+ height: 24,
+ borderRadius: '50%',
+ bgcolor: '#504945',
+ cursor: 'pointer',
+ border: selectedTags.length === 0 ? '3px solid #ebdbb2' : '2px solid #504945',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: '0.7rem',
+ '&:hover': { opacity: 0.8 }
+ }}
+ >
+ ✕
+
+ {availableColorTags.map(tag => (
+ {
+ if (selectedTags.includes(tag.id)) {
+ setSelectedTags(selectedTags.filter(t => t !== tag.id));
+ } else {
+ setSelectedTags([...selectedTags, tag.id]);
+ }
+ }}
+ sx={{
+ width: 24,
+ height: 24,
+ borderRadius: '50%',
+ bgcolor: tag.color,
+ cursor: 'pointer',
+ border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ '&:hover': { opacity: 0.8 }
+ }}
+ >
+ {selectedTags.includes(tag.id) && ✓}
+
+ ))}
+
+
{/* TRIGGERS SECTION */}
- TRIGGERS (When to activate - conditions are combined with AND)
+ TRIGGERS (When to activate)
- {/* Time Range Trigger */}
+ {/* Scheduled Time Trigger (fires at exact time) */}
+
+ setUseScheduledTime(e.target.checked)}
+ disabled={saving}
+ />
+ }
+ label={🕐 Scheduled Time (trigger at exact time)}
+ />
+
+ {useScheduledTime && (
+
+ setScheduledTime(e.target.value)}
+ InputLabelProps={{ shrink: true }}
+ size="small"
+ sx={{ width: 150 }}
+ disabled={saving}
+ />
+
+
+ Days
+
+
+ {DAYS.map(day => (
+
+ {day.label}
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Time Range Trigger (active within window) */}
}
- label={⏰ Time Range}
+ label={⏰ Time Range (active during window)}
/>
{useTimeRange && (
@@ -289,8 +480,8 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
Days
@@ -425,39 +616,91 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
+ {/* Toggle action controls */}
{actionType === 'toggle' && (
- setToggleState(e.target.checked)}
- color="primary"
- disabled={saving}
+
+ {isBinaryOutput ? (
+ // Binary output: On/Off switch
+ setToggleState(e.target.checked)}
+ color="primary"
+ disabled={saving}
+ />
+ }
+ label={toggleState ? 'Turn ON' : 'Turn OFF'}
/>
- }
- label={toggleState ? 'Turn ON' : 'Turn OFF'}
- />
+ ) : (
+ // Level output: 1-10 slider
+
+
+ Set Level: {outputLevel}
+
+ setOutputLevel(val)}
+ min={1}
+ max={10}
+ step={1}
+ marks={[
+ { value: 1, label: '1' },
+ { value: 5, label: '5' },
+ { value: 10, label: '10' }
+ ]}
+ valueLabelDisplay="auto"
+ disabled={saving}
+ />
+
+ )}
+
)}
+ {/* Keep On action controls */}
{actionType === 'keepOn' && (
-
-
- Duration: {duration} minutes
-
- setDuration(val)}
- min={1}
- max={120}
- marks={[
- { value: 1, label: '1m' },
- { value: 30, label: '30m' },
- { value: 60, label: '1h' },
- { value: 120, label: '2h' }
- ]}
- valueLabelDisplay="auto"
- disabled={saving}
- />
+
+ {!isBinaryOutput && (
+ // Level for port outputs
+
+
+ Set Level: {outputLevel}
+
+ setOutputLevel(val)}
+ min={1}
+ max={10}
+ step={1}
+ marks={[
+ { value: 1, label: '1' },
+ { value: 5, label: '5' },
+ { value: 10, label: '10' }
+ ]}
+ valueLabelDisplay="auto"
+ disabled={saving}
+ />
+
+ )}
+
+
+ Duration: {duration} minutes
+
+ setDuration(val)}
+ min={1}
+ max={120}
+ marks={[
+ { value: 1, label: '1m' },
+ { value: 30, label: '30m' },
+ { value: 60, label: '1h' },
+ { value: 120, label: '2h' }
+ ]}
+ valueLabelDisplay="auto"
+ disabled={saving}
+ />
+
)}
@@ -489,6 +732,6 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
)}
-
+
);
}
diff --git a/src/client/RuleManager.js b/src/client/RuleManager.js
index da0190f..fe08a38 100644
--- a/src/client/RuleManager.js
+++ b/src/client/RuleManager.js
@@ -6,12 +6,26 @@ import {
Paper,
Divider,
Alert,
- CircularProgress
+ CircularProgress,
+ Chip,
+ IconButton
} from '@mui/material';
import RuleCard from './RuleCard';
import RuleEditor from './RuleEditor';
import { useAuth } from './AuthContext';
+// 8 color tags
+const COLOR_TAGS = [
+ { id: 'red', label: 'Red', color: '#fb4934' },
+ { id: 'orange', label: 'Orange', color: '#fe8019' },
+ { id: 'yellow', label: 'Yellow', color: '#fabd2f' },
+ { id: 'green', label: 'Green', color: '#b8bb26' },
+ { id: 'teal', label: 'Teal', color: '#8ec07c' },
+ { id: 'blue', label: 'Blue', color: '#83a598' },
+ { id: 'purple', label: 'Purple', color: '#d3869b' },
+ { id: 'gray', label: 'Gray', color: '#928374' }
+];
+
export default function RuleManager() {
const { isAdmin } = useAuth();
const [rules, setRules] = useState([]);
@@ -21,6 +35,7 @@ export default function RuleManager() {
const [editingRule, setEditingRule] = useState(null);
const [devices, setDevices] = useState([]);
const [saving, setSaving] = useState(false);
+ const [filterTag, setFilterTag] = useState(null); // null = show all
// Get auth token from localStorage
const getAuthHeaders = useCallback(() => {
@@ -73,7 +88,7 @@ export default function RuleManager() {
const seenDevices = new Set();
devices.forEach(d => {
- // Add environment sensors once per device
+ // Add environment sensors once per device (temp, humidity)
if (!seenDevices.has(d.dev_name)) {
seenDevices.add(d.dev_name);
availableSensors.push({
@@ -88,7 +103,7 @@ export default function RuleManager() {
});
}
- // Add each port as a sensor (Fan Speed, Brightness, CO2, etc.)
+ // Add each port as a sensor (Fan Speed 0-10, Brightness 0-10, CO2, etc.)
availableSensors.push({
id: `${d.dev_name}:${d.port}:level`,
label: `${d.dev_name} - ${d.port_name} Level`,
@@ -96,29 +111,36 @@ export default function RuleManager() {
});
});
- // Build available outputs: Tapo plugs + device fans/lights
+ // Build available outputs: Tapo plugs + ALL device ports + 4 virtual channels
const availableOutputs = [
+ // Tapo smart plugs
{ id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
{ id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
{ id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
{ id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
{ id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
- ...devices
- .filter(d => d.port_name === 'Fan')
- .map(d => ({
- id: `${d.dev_name}:fan:${d.port}`,
- label: `${d.dev_name} - Fan`,
- type: 'fan'
- })),
- ...devices
- .filter(d => d.port_name === 'Light')
- .map(d => ({
- id: `${d.dev_name}:light:${d.port}`,
- label: `${d.dev_name} - Light`,
- type: 'light'
- }))
+ // All device ports as outputs
+ ...devices.map(d => ({
+ id: `${d.dev_name}:${d.port}:out`,
+ label: `${d.dev_name} - ${d.port_name}`,
+ type: d.port_name.toLowerCase()
+ })),
+ // 4 virtual channels
+ { id: 'virtual-1', label: 'Virtual Channel 1', type: 'virtual' },
+ { id: 'virtual-2', label: 'Virtual Channel 2', type: 'virtual' },
+ { id: 'virtual-3', label: 'Virtual Channel 3', type: 'virtual' },
+ { id: 'virtual-4', label: 'Virtual Channel 4', type: 'virtual' }
];
+ // Add Tapo and virtual channels as sensors (on/off state)
+ [...availableOutputs.filter(o => o.type === 'plug' || o.type === 'virtual')].forEach(o => {
+ availableSensors.push({
+ id: `${o.id}:state`,
+ label: `${o.label} (State)`,
+ type: 'output-state'
+ });
+ });
+
const handleAddRule = () => {
setEditingRule(null);
setEditorOpen(true);
@@ -172,11 +194,11 @@ export default function RuleManager() {
setSaving(true);
try {
if (editingRule) {
- // Update existing rule
+ // Update existing rule - preserve enabled state
const res = await fetch(`api/rules/${editingRule.id}`, {
method: 'PUT',
headers: getAuthHeaders(),
- body: JSON.stringify(ruleData)
+ body: JSON.stringify({ ...ruleData, enabled: editingRule.enabled })
});
if (!res.ok) throw new Error('Failed to update rule');
const updated = await res.json();
@@ -206,6 +228,36 @@ export default function RuleManager() {
setEditingRule(null);
};
+ // Move rule up or down
+ const handleMoveRule = async (ruleId, direction) => {
+ const idx = rules.findIndex(r => r.id === ruleId);
+ if (idx === -1) return;
+ if (direction === 'up' && idx === 0) return;
+ if (direction === 'down' && idx === rules.length - 1) return;
+
+ const newRules = [...rules];
+ const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
+ [newRules[idx], newRules[swapIdx]] = [newRules[swapIdx], newRules[idx]];
+ setRules(newRules);
+
+ // Save new order to server
+ try {
+ const ruleIds = newRules.map(r => r.id);
+ await fetch('api/rules/reorder', {
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ ruleIds })
+ });
+ } catch (err) {
+ setError('Failed to save order');
+ }
+ };
+
+ // Filter rules by color tag
+ const filteredRules = filterTag
+ ? rules.filter(r => (r.colorTags || []).includes(filterTag))
+ : rules;
+
if (loading) {
return (
@@ -250,7 +302,34 @@ export default function RuleManager() {
)}
-
+
+
+ {/* Color tag filter */}
+
+ Filter:
+ setFilterTag(null)}
+ sx={{
+ bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
+ color: filterTag === null ? '#282828' : '#ebdbb2'
+ }}
+ />
+ {COLOR_TAGS.map(tag => (
+ setFilterTag(filterTag === tag.id ? null : tag.id)}
+ sx={{
+ bgcolor: filterTag === tag.id ? tag.color : '#504945',
+ color: filterTag === tag.id ? '#282828' : tag.color,
+ border: `2px solid ${tag.color}`,
+ '&:hover': { bgcolor: tag.color, color: '#282828' }
+ }}
+ />
+ ))}
+
{error && (
setError(null)}>
@@ -258,21 +337,27 @@ export default function RuleManager() {
)}
- {rules.length === 0 ? (
+ {filteredRules.length === 0 ? (
- No rules configured. {isAdmin && 'Click "Add Rule" to create one.'}
+ {rules.length === 0
+ ? (isAdmin ? 'No rules configured. Click "Add Rule" to create one.' : 'No rules configured.')
+ : 'No rules match the selected filter.'
+ }
) : (
- {rules.map(rule => (
+ {filteredRules.map((rule, idx) => (
handleEditRule(rule) : null}
onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
+ onMoveUp={isAdmin && idx > 0 ? () => handleMoveRule(rule.id, 'up') : null}
+ onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => handleMoveRule(rule.id, 'down') : null}
+ colorTags={COLOR_TAGS}
readOnly={!isAdmin}
/>
))}
@@ -287,6 +372,7 @@ export default function RuleManager() {
onClose={handleCloseEditor}
sensors={availableSensors}
outputs={availableOutputs}
+ colorTags={COLOR_TAGS}
saving={saving}
/>
)}