sync rules

This commit is contained in:
sebseb7
2025-12-21 02:28:37 +01:00
parent f8a83efb39
commit 096fc2aa72
6 changed files with 776 additions and 333 deletions

View File

@@ -23,9 +23,9 @@ const updateUserRole = db.prepare('UPDATE users SET role = ? WHERE id = ?');
const updateUserPassword = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?'); const updateUserPassword = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?');
const deleteUser = db.prepare('DELETE FROM users WHERE id = ?'); const deleteUser = db.prepare('DELETE FROM users WHERE id = ?');
console.log('\n╔════════════════════════════════════╗'); console.log('\n╔══════════════════════════════════════╗');
console.log('║ 🔐 User Manager - AC Dashboard ║'); console.log('║ 🔐 User Manager - AC Dashboard ║');
console.log('╚════════════════════════════════════╝\n'); console.log('╚══════════════════════════════════════╝\n');
async function listUsers() { async function listUsers() {
const users = getAllUsers.all(); const users = getAllUsers.all();

158
server.js
View File

@@ -65,6 +65,20 @@ db.exec(`
) )
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_data TEXT NOT NULL,
action_type TEXT NOT NULL,
action_data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed) INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -72,6 +86,19 @@ const insertStmt = db.prepare(`
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?'); const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
// Rules prepared statements
const getAllRules = db.prepare('SELECT * FROM rules ORDER BY 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 (?, ?, ?, ?, ?, ?)
`);
const updateRule = db.prepare(`
UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
// --- AC INFINITY API LOGIC --- // --- AC INFINITY API LOGIC ---
let token = null; let token = null;
@@ -269,6 +296,137 @@ app.get('/api/auth/me', (req, res) => {
} }
}); });
// --- AUTH MIDDLEWARE ---
function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, JWT_SECRET);
} catch (e) {
req.user = null;
}
}
next();
}
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch (e) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
// --- RULES API ---
// Helper to format rule for API response
function formatRule(row) {
const trigger = JSON.parse(row.trigger_data);
const action = JSON.parse(row.action_data);
// Add type back for legacy/action compatibility
if (action.type === undefined && row.action_type) {
action.type = row.action_type;
}
return {
id: row.id,
name: row.name,
enabled: row.enabled === 1,
trigger,
action
};
}
// GET /api/rules - public (guests can view)
app.get('/api/rules', (req, res) => {
try {
const rows = getAllRules.all();
res.json(rows.map(formatRule));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /api/rules - admin only
app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
try {
const { name, enabled, trigger, action } = req.body;
if (!name || !trigger || !action) {
return res.status(400).json({ error: 'name, trigger, and action required' });
}
// Determine trigger type for storage (combined, time, sensor)
let triggerType = 'combined';
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action);
const result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData);
const newRule = getRuleById.get(result.lastInsertRowid);
res.status(201).json(formatRule(newRule));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PUT /api/rules/:id - admin only
app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
try {
const { id } = req.params;
const existing = getRuleById.get(id);
if (!existing) {
return res.status(404).json({ error: 'Rule not found' });
}
const { name, enabled, trigger, action } = req.body;
// Determine trigger type for storage
let triggerType = 'combined';
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
const triggerData = JSON.stringify(trigger);
const actionData = JSON.stringify(action);
updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, id);
const updated = getRuleById.get(id);
res.json(formatRule(updated));
} 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 {
const { id } = req.params;
const existing = getRuleById.get(id);
if (!existing) {
return res.status(404).json({ error: 'Rule not found' });
}
deleteRule.run(id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// API: Devices // API: Devices
app.get('/api/devices', (req, res) => { app.get('/api/devices', (req, res) => {
try { try {

View File

@@ -104,8 +104,8 @@ function AppContent() {
{/* Dashboard is always visible to everyone */} {/* Dashboard is always visible to everyone */}
<Dashboard /> <Dashboard />
{/* Rule Manager only visible to logged-in admins */} {/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
{isAdmin && <RuleManager />} <RuleManager />
</Container> </Container>
{/* Login dialog - shown on demand */} {/* Login dialog - shown on demand */}

View File

@@ -13,70 +13,97 @@ import {
const EditIcon = () => <span style={{ fontSize: '1rem' }}></span>; const EditIcon = () => <span style={{ fontSize: '1rem' }}></span>;
const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑</span>; const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑</span>;
const dayLabels = { const dayLabels = { mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S' };
mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S'
};
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function TriggerSummary({ trigger }) { function TriggerSummary({ trigger }) {
if (trigger.type === 'time') { const parts = [];
// Time range
if (trigger.timeRange) {
const { start, end, days } = trigger.timeRange;
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(
<Box key="time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2">
{start}{end} ({dayText})
</Typography>
</Box>
);
}
// Sensor conditions
if (trigger.sensors && trigger.sensors.length > 0) {
const logic = trigger.sensorLogic || 'and';
const sensorText = trigger.sensors.map((s, i) => (
<span key={i}>
{i > 0 && <Chip label={logic.toUpperCase()} size="small" sx={{ mx: 0.5, bgcolor: logic === 'and' ? '#8ec07c' : '#fabd2f', color: '#282828', fontSize: '0.65rem' }} />}
<strong>{s.sensorLabel || s.sensor}</strong> {s.operator} {s.value}
</span>
));
parts.push(
<Box key="sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2" component="span">
{sensorText}
</Typography>
</Box>
);
}
// Legacy support for old trigger format
if (trigger.type === 'time' && !trigger.timeRange) {
const days = trigger.days || []; const days = trigger.days || [];
const isEveryDay = days.length === 7; const isEveryDay = days.length === 7;
const isWeekdays = days.length === 5 && const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
['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(' ');
let dayText = isEveryDay ? 'Every day' : parts.push(
isWeekdays ? 'Weekdays' : <Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' '); <Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} />
<Typography variant="body2">At {trigger.time} ({dayText})</Typography>
</Box>
);
}
return ( if (trigger.type === 'sensor' && !trigger.sensors) {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> parts.push(
<Chip <Box key="legacy-sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
label="⏰ Time" <Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }} />
size="small"
sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }}
/>
<Typography variant="body2"> <Typography variant="body2">
At <strong>{trigger.time}</strong> {dayText} {trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
</Typography> </Typography>
</Box> </Box>
); );
} }
if (trigger.type === 'sensor') { if (parts.length === 0) return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label="📊 Sensor"
size="small"
sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }}
/>
<Typography variant="body2">
When <strong>{trigger.sensor}</strong> {trigger.operator} <strong>{trigger.value}</strong>
</Typography>
</Box>
);
}
return null; return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{parts}
</Box>
);
} }
function ActionSummary({ action }) { function ActionSummary({ action }) {
if (action.type === 'toggle') { if (action.type === 'toggle') {
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip <Chip
label={action.state ? '🔛 ON' : '🔴 OFF'} label={action.state ? '🔛' : '🔴'}
size="small" size="small"
sx={{ sx={{ bgcolor: action.state ? '#b8bb26' : '#fb4934', color: '#282828', fontWeight: 600, minWidth: 32 }}
bgcolor: action.state ? '#b8bb26' : '#fb4934',
color: '#282828',
fontWeight: 600
}}
/> />
<Typography variant="body2"> <Typography variant="body2">
Turn <strong>{action.target}</strong> {action.state ? 'on' : 'off'} <strong>{action.targetLabel || action.target}</strong> {action.state ? 'ON' : 'OFF'}
</Typography> </Typography>
</Box> </Box>
); );
@@ -84,14 +111,10 @@ function ActionSummary({ action }) {
if (action.type === 'keepOn') { if (action.type === 'keepOn') {
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip <Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
label="⏱️ Timed"
size="small"
sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600 }}
/>
<Typography variant="body2"> <Typography variant="body2">
Keep <strong>{action.target}</strong> on for <strong>{action.duration} min</strong> <strong>{action.targetLabel || action.target}</strong> ON for {action.duration}m
</Typography> </Typography>
</Box> </Box>
); );
@@ -100,7 +123,7 @@ function ActionSummary({ action }) {
return null; return null;
} }
export default function RuleCard({ rule, onEdit, onDelete, onToggle }) { export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) {
return ( return (
<Paper <Paper
sx={{ sx={{
@@ -110,7 +133,7 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle }) {
border: '1px solid', border: '1px solid',
borderColor: rule.enabled ? '#504945' : '#3c3836', borderColor: rule.enabled ? '#504945' : '#3c3836',
'&:hover': { '&:hover': {
transform: 'translateX(4px)', transform: readOnly ? 'none' : 'translateX(4px)',
borderColor: rule.enabled ? '#8ec07c' : '#504945' borderColor: rule.enabled ? '#8ec07c' : '#504945'
} }
}} }}
@@ -122,39 +145,31 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle }) {
{rule.name} {rule.name}
</Typography> </Typography>
{!rule.enabled && ( {!rule.enabled && (
<Chip <Chip label="Disabled" size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
label="Disabled"
size="small"
sx={{ bgcolor: '#504945', fontSize: '0.7rem' }}
/>
)} )}
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<TriggerSummary trigger={rule.trigger} /> <TriggerSummary trigger={rule.trigger} />
<ActionSummary action={rule.action} /> <ActionSummary action={rule.action} />
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> {!readOnly && (
<Tooltip title={rule.enabled ? 'Disable rule' : 'Enable rule'}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Switch <Tooltip title={rule.enabled ? 'Disable' : 'Enable'}>
checked={rule.enabled} <Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
onChange={onToggle} </Tooltip>
color="primary" <Tooltip title="Edit">
/> <IconButton onClick={onEdit} size="small"><EditIcon /></IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Edit rule"> <Tooltip title="Delete">
<IconButton onClick={onEdit} size="small"> <IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
<EditIcon /> <DeleteIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Delete rule"> </Box>
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}> )}
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box> </Box>
</Paper> </Paper>
); );

View File

@@ -17,7 +17,11 @@ import {
Divider, Divider,
Slider, Slider,
Switch, Switch,
FormControlLabel FormControlLabel,
CircularProgress,
IconButton,
Paper,
Chip
} from '@mui/material'; } from '@mui/material';
const DAYS = [ const DAYS = [
@@ -30,109 +34,189 @@ const DAYS = [
{ key: 'sun', label: 'Sun' } { key: 'sun', label: 'Sun' }
]; ];
const SENSORS = ['Temperature', 'Humidity', 'CO2', 'VPD', 'Light Level'];
const OPERATORS = [ const OPERATORS = [
{ value: '>', label: 'Greater than (>)' }, { value: '>', label: '>' },
{ value: '<', label: 'Less than (<)' }, { value: '<', label: '<' },
{ value: '>=', label: 'Greater or equal (≥)' }, { value: '>=', label: '' },
{ value: '<=', label: 'Less or equal (≤)' }, { value: '<=', label: '' },
{ value: '==', label: 'Equal to (=)' } { value: '==', label: '=' }
]; ];
const OUTPUTS = [ // Single sensor condition component
'Workshop Light', function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
'Exhaust Fan', return (
'Heater', <Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
'Humidifier', <FormControl size="small" sx={{ minWidth: 150 }}>
'All Outlets', <InputLabel>Sensor</InputLabel>
'Grow Light', <Select
'Circulation Fan' value={condition.sensor || ''}
]; label="Sensor"
onChange={(e) => onChange({ ...condition, sensor: e.target.value })}
disabled={disabled}
>
{sensors.map(s => (
<MenuItem key={s.id} value={s.id}>{s.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 70 }}>
<Select
value={condition.operator || '>'}
onChange={(e) => onChange({ ...condition, operator: e.target.value })}
disabled={disabled}
>
{OPERATORS.map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
type="number"
value={condition.value ?? ''}
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
sx={{ width: 80 }}
disabled={disabled}
/>
{onRemove && (
<IconButton size="small" onClick={onRemove} disabled={disabled}>
</IconButton>
)}
</Paper>
);
}
export default function RuleEditor({ open, rule, onSave, onClose }) { export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], saving }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [triggerType, setTriggerType] = useState('time');
// Time trigger state // Time range state
const [time, setTime] = useState('08:00'); 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 [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
// Sensor trigger state // Sensor conditions state
const [sensor, setSensor] = useState('Temperature'); const [useSensors, setUseSensors] = useState(false);
const [operator, setOperator] = useState('>'); const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
const [sensorValue, setSensorValue] = useState(25); const [sensorLogic, setSensorLogic] = useState('and'); // 'and' or 'or'
// Action state // Action state
const [actionType, setActionType] = useState('toggle'); const [actionType, setActionType] = useState('toggle');
const [target, setTarget] = useState('Workshop Light'); const [target, setTarget] = useState('');
const [toggleState, setToggleState] = useState(true); const [toggleState, setToggleState] = useState(true);
const [duration, setDuration] = useState(15); const [duration, setDuration] = useState(15);
// Reset form when rule changes // Reset form when rule changes or dialog opens
useEffect(() => { useEffect(() => {
if (rule) { if (rule) {
setName(rule.name); setName(rule.name);
setTriggerType(rule.trigger.type);
if (rule.trigger.type === 'time') { // Parse trigger
setTime(rule.trigger.time); const trigger = rule.trigger || {};
setDays(rule.trigger.days || []); setUseTimeRange(!!trigger.timeRange);
} else { if (trigger.timeRange) {
setSensor(rule.trigger.sensor); setTimeStart(trigger.timeRange.start || '08:00');
setOperator(rule.trigger.operator); setTimeEnd(trigger.timeRange.end || '18:00');
setSensorValue(rule.trigger.value); setDays(trigger.timeRange.days || []);
} }
setActionType(rule.action.type); setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
setTarget(rule.action.target); if (trigger.sensors && trigger.sensors.length > 0) {
if (rule.action.type === 'toggle') { setSensorConditions(trigger.sensors);
setToggleState(rule.action.state); setSensorLogic(trigger.sensorLogic || 'and');
}
// Parse action
setActionType(rule.action?.type || 'toggle');
setTarget(rule.action?.target || '');
if (rule.action?.type === 'toggle') {
setToggleState(rule.action?.state ?? true);
} else { } else {
setDuration(rule.action.duration); setDuration(rule.action?.duration || 15);
} }
} else { } else {
// Reset to defaults for new rule // Reset to defaults
setName(''); setName('');
setTriggerType('time'); setUseTimeRange(true);
setTime('08:00'); setTimeStart('08:00');
setTimeEnd('18:00');
setDays(['mon', 'tue', 'wed', 'thu', 'fri']); setDays(['mon', 'tue', 'wed', 'thu', 'fri']);
setSensor('Temperature'); setUseSensors(false);
setOperator('>'); setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
setSensorValue(25); setSensorLogic('and');
setActionType('toggle'); setActionType('toggle');
setTarget('Workshop Light'); setTarget(outputs[0]?.id || '');
setToggleState(true); setToggleState(true);
setDuration(15); setDuration(15);
} }
}, [rule, open]); }, [rule, open, sensors, outputs]);
// Set default sensor/output when lists load
useEffect(() => {
if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
}
if (!target && outputs.length > 0) setTarget(outputs[0].id);
}, [sensors, outputs, sensorConditions, target]);
const handleDaysChange = (event, newDays) => { const handleDaysChange = (event, newDays) => {
if (newDays.length > 0) { if (newDays.length > 0) setDays(newDays);
setDays(newDays); };
const addSensorCondition = () => {
setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
};
const updateSensorCondition = (index, newCondition) => {
const updated = [...sensorConditions];
updated[index] = newCondition;
setSensorConditions(updated);
};
const removeSensorCondition = (index) => {
if (sensorConditions.length > 1) {
setSensorConditions(sensorConditions.filter((_, i) => i !== index));
} }
}; };
const handleSave = () => { const handleSave = () => {
const selectedOutput = outputs.find(o => o.id === target);
// Build trigger object
const trigger = {};
if (useTimeRange) {
trigger.timeRange = { start: timeStart, end: timeEnd, days };
}
if (useSensors && sensorConditions.length > 0) {
trigger.sensors = sensorConditions.map(c => ({
...c,
sensorLabel: sensors.find(s => s.id === c.sensor)?.label
}));
trigger.sensorLogic = sensorLogic;
}
const ruleData = { const ruleData = {
name, name,
trigger: triggerType === 'time' trigger,
? { type: 'time', time, days }
: { type: 'sensor', sensor, operator, value: sensorValue },
action: actionType === 'toggle' action: actionType === 'toggle'
? { type: 'toggle', target, state: toggleState } ? { type: 'toggle', target, targetLabel: selectedOutput?.label, state: toggleState }
: { type: 'keepOn', target, duration } : { type: 'keepOn', target, targetLabel: selectedOutput?.label, duration }
}; };
onSave(ruleData); onSave(ruleData);
}; };
const isValid = name.trim().length > 0 && const isValid = name.trim().length > 0 &&
(triggerType !== 'time' || days.length > 0); (useTimeRange || useSensors) &&
(!useTimeRange || days.length > 0) &&
(!useSensors || sensorConditions.every(c => c.sensor)) &&
target;
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
maxWidth="sm" maxWidth="md"
fullWidth fullWidth
PaperProps={{ PaperProps={{
sx: { sx: {
@@ -153,108 +237,161 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
fullWidth fullWidth
placeholder="e.g., Morning Lights" placeholder="e.g., Daytime High Humidity Fan"
disabled={saving}
/> />
{/* Trigger Section */} {/* TRIGGERS SECTION */}
<Box> <Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom> <Typography variant="subtitle2" color="text.secondary" gutterBottom>
TRIGGER (When to activate) TRIGGERS (When to activate - conditions are combined with AND)
</Typography> </Typography>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<FormControl fullWidth sx={{ mb: 2 }}> {/* Time Range Trigger */}
<InputLabel>Trigger Type</InputLabel> <Paper sx={{ p: 2, mb: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
<Select <FormControlLabel
value={triggerType} control={
label="Trigger Type" <Switch
onChange={(e) => setTriggerType(e.target.value)} checked={useTimeRange}
> onChange={(e) => setUseTimeRange(e.target.checked)}
<MenuItem value="time"> Time-based</MenuItem> disabled={saving}
<MenuItem value="sensor">📊 Sensor Value</MenuItem>
</Select>
</FormControl>
{triggerType === 'time' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Days of Week
</Typography>
<ToggleButtonGroup
value={days}
onChange={handleDaysChange}
size="small"
sx={{ flexWrap: 'wrap' }}
>
{DAYS.map(day => (
<ToggleButton
key={day.key}
value={day.key}
sx={{
'&.Mui-selected': {
bgcolor: '#8ec07c',
color: '#282828',
'&:hover': { bgcolor: '#98c98a' }
}
}}
>
{day.label}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
</Box>
)}
{triggerType === 'sensor' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl fullWidth>
<InputLabel>Sensor</InputLabel>
<Select
value={sensor}
label="Sensor"
onChange={(e) => setSensor(e.target.value)}
>
{SENSORS.map(s => (
<MenuItem key={s} value={s}>{s}</MenuItem>
))}
</Select>
</FormControl>
<Box sx={{ display: 'flex', gap: 2 }}>
<FormControl sx={{ minWidth: 180 }}>
<InputLabel>Condition</InputLabel>
<Select
value={operator}
label="Condition"
onChange={(e) => setOperator(e.target.value)}
>
{OPERATORS.map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Value"
type="number"
value={sensorValue}
onChange={(e) => setSensorValue(Number(e.target.value))}
fullWidth
/> />
}
label={<Typography fontWeight={600}> Time Range</Typography>}
/>
{useTimeRange && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="From"
type="time"
value={timeStart}
onChange={(e) => setTimeStart(e.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
disabled={saving}
/>
<Typography>to</Typography>
<TextField
label="Until"
type="time"
value={timeEnd}
onChange={(e) => setTimeEnd(e.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
disabled={saving}
/>
</Box>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Days
</Typography>
<ToggleButtonGroup
value={days}
onChange={handleDaysChange}
size="small"
disabled={saving}
>
{DAYS.map(day => (
<ToggleButton
key={day.key}
value={day.key}
sx={{
'&.Mui-selected': {
bgcolor: '#8ec07c',
color: '#282828',
'&:hover': { bgcolor: '#98c98a' }
}
}}
>
{day.label}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
</Box> </Box>
</Box> )}
)} </Paper>
{/* Sensor Conditions Trigger */}
<Paper sx={{ p: 2, bgcolor: useSensors ? 'action.selected' : 'background.default' }}>
<FormControlLabel
control={
<Switch
checked={useSensors}
onChange={(e) => setUseSensors(e.target.checked)}
disabled={saving || sensors.length === 0}
/>
}
label={
<Typography fontWeight={600}>
📊 Sensor Conditions {sensors.length === 0 && '(no sensors available)'}
</Typography>
}
/>
{useSensors && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{sensorConditions.length > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">Combine conditions with:</Typography>
<ToggleButtonGroup
value={sensorLogic}
exclusive
onChange={(e, v) => v && setSensorLogic(v)}
size="small"
disabled={saving}
>
<ToggleButton value="and" sx={{ '&.Mui-selected': { bgcolor: '#8ec07c', color: '#282828' } }}>
AND
</ToggleButton>
<ToggleButton value="or" sx={{ '&.Mui-selected': { bgcolor: '#fabd2f', color: '#282828' } }}>
OR
</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{sensorConditions.map((cond, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{i > 0 && (
<Chip
label={sensorLogic.toUpperCase()}
size="small"
sx={{
bgcolor: sensorLogic === 'and' ? '#8ec07c' : '#fabd2f',
color: '#282828',
fontWeight: 600,
minWidth: 45
}}
/>
)}
<SensorCondition
condition={cond}
sensors={sensors}
onChange={(newCond) => updateSensorCondition(i, newCond)}
onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
disabled={saving}
/>
</Box>
))}
<Button
size="small"
onClick={addSensorCondition}
disabled={saving}
sx={{ alignSelf: 'flex-start' }}
>
+ Add Condition
</Button>
</Box>
)}
</Paper>
</Box> </Box>
{/* Action Section */} {/* ACTION SECTION */}
<Box> <Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom> <Typography variant="subtitle2" color="text.secondary" gutterBottom>
ACTION (What to do) ACTION (What to do)
@@ -267,6 +404,7 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
value={actionType} value={actionType}
label="Action Type" label="Action Type"
onChange={(e) => setActionType(e.target.value)} onChange={(e) => setActionType(e.target.value)}
disabled={saving}
> >
<MenuItem value="toggle">🔛 Toggle On/Off</MenuItem> <MenuItem value="toggle">🔛 Toggle On/Off</MenuItem>
<MenuItem value="keepOn"> Keep On for X Minutes</MenuItem> <MenuItem value="keepOn"> Keep On for X Minutes</MenuItem>
@@ -279,9 +417,10 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
value={target} value={target}
label="Target Output" label="Target Output"
onChange={(e) => setTarget(e.target.value)} onChange={(e) => setTarget(e.target.value)}
disabled={saving}
> >
{OUTPUTS.map(o => ( {outputs.map(o => (
<MenuItem key={o} value={o}>{o}</MenuItem> <MenuItem key={o.id} value={o.id}>{o.label}</MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
@@ -293,6 +432,7 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
checked={toggleState} checked={toggleState}
onChange={(e) => setToggleState(e.target.checked)} onChange={(e) => setToggleState(e.target.checked)}
color="primary" color="primary"
disabled={saving}
/> />
} }
label={toggleState ? 'Turn ON' : 'Turn OFF'} label={toggleState ? 'Turn ON' : 'Turn OFF'}
@@ -316,6 +456,7 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
{ value: 120, label: '2h' } { value: 120, label: '2h' }
]} ]}
valueLabelDisplay="auto" valueLabelDisplay="auto"
disabled={saving}
/> />
</Box> </Box>
)} )}
@@ -324,13 +465,13 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}> <DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit"> <Button onClick={onClose} color="inherit" disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
variant="contained" variant="contained"
disabled={!isValid} disabled={!isValid || saving}
sx={{ sx={{
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)', background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
'&:hover': { '&:hover': {
@@ -338,7 +479,14 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
} }
}} }}
> >
{rule ? 'Save Changes' : 'Create Rule'} {saving ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="inherit" />
Saving...
</Box>
) : (
rule ? 'Save Changes' : 'Create Rule'
)}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -1,68 +1,123 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Box, Box,
Typography, Typography,
Button, Button,
Paper, Paper,
Divider Divider,
Alert,
CircularProgress
} from '@mui/material'; } from '@mui/material';
import RuleCard from './RuleCard'; import RuleCard from './RuleCard';
import RuleEditor from './RuleEditor'; import RuleEditor from './RuleEditor';
import { useAuth } from './AuthContext';
// Initial mock rules for demonstration
const initialRules = [
{
id: 1,
name: 'Morning Light',
enabled: true,
trigger: {
type: 'time',
time: '06:30',
days: ['mon', 'tue', 'wed', 'thu', 'fri']
},
action: {
type: 'toggle',
target: 'Workshop Light',
state: true
}
},
{
id: 2,
name: 'High Humidity Fan',
enabled: true,
trigger: {
type: 'sensor',
sensor: 'Humidity',
operator: '>',
value: 70
},
action: {
type: 'keepOn',
target: 'Exhaust Fan',
duration: 15
}
},
{
id: 3,
name: 'Evening Shutdown',
enabled: false,
trigger: {
type: 'time',
time: '18:00',
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
},
action: {
type: 'toggle',
target: 'All Outlets',
state: false
}
}
];
export default function RuleManager() { export default function RuleManager() {
const [rules, setRules] = useState(initialRules); const { isAdmin } = useAuth();
const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editingRule, setEditingRule] = useState(null); const [editingRule, setEditingRule] = useState(null);
const [devices, setDevices] = useState([]);
const [saving, setSaving] = useState(false);
// Get auth token from localStorage
const getAuthHeaders = useCallback(() => {
const token = localStorage.getItem('authToken');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
}, []);
// Fetch rules from server (public endpoint)
const fetchRules = useCallback(async () => {
try {
const res = await fetch('api/rules');
if (!res.ok) {
throw new Error('Failed to fetch rules');
}
const data = await res.json();
setRules(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
// Fetch devices for sensor/output selection
const fetchDevices = useCallback(async () => {
try {
const res = await fetch('api/devices');
if (res.ok) {
const data = await res.json();
setDevices(data);
}
} catch (err) {
console.error('Failed to fetch devices:', err);
}
}, []);
useEffect(() => {
fetchRules();
fetchDevices();
}, [fetchRules, fetchDevices]);
// Build available sensors
// - Environment sensors (Temp, Humidity) are per DEVICE
// - Port values (Fan Speed, Brightness, CO2, etc.) are per PORT
const availableSensors = [];
const seenDevices = new Set();
devices.forEach(d => {
// Add environment sensors once per device
if (!seenDevices.has(d.dev_name)) {
seenDevices.add(d.dev_name);
availableSensors.push({
id: `${d.dev_name}:temp`,
label: `${d.dev_name} - Temperature`,
type: 'temperature'
});
availableSensors.push({
id: `${d.dev_name}:humidity`,
label: `${d.dev_name} - Humidity`,
type: 'humidity'
});
}
// Add each port as a sensor (Fan Speed, Brightness, CO2, etc.)
availableSensors.push({
id: `${d.dev_name}:${d.port}:level`,
label: `${d.dev_name} - ${d.port_name} Level`,
type: d.port_name.toLowerCase()
});
});
// Build available outputs: Tapo plugs + device fans/lights
const availableOutputs = [
{ 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'
}))
];
const handleAddRule = () => { const handleAddRule = () => {
setEditingRule(null); setEditingRule(null);
@@ -74,33 +129,76 @@ export default function RuleManager() {
setEditorOpen(true); setEditorOpen(true);
}; };
const handleDeleteRule = (ruleId) => { const handleDeleteRule = async (ruleId) => {
setRules(rules.filter(r => r.id !== ruleId)); if (!confirm('Delete this rule?')) return;
};
const handleToggleRule = (ruleId) => { setSaving(true);
setRules(rules.map(r => try {
r.id === ruleId ? { ...r, enabled: !r.enabled } : r const res = await fetch(`api/rules/${ruleId}`, {
)); method: 'DELETE',
}; headers: getAuthHeaders()
});
const handleSaveRule = (ruleData) => { if (!res.ok) throw new Error('Failed to delete rule');
if (editingRule) { setRules(rules.filter(r => r.id !== ruleId));
// Update existing rule } catch (err) {
setRules(rules.map(r => setError(err.message);
r.id === editingRule.id ? { ...r, ...ruleData } : r } finally {
)); setSaving(false);
} else { }
// Add new rule };
const newRule = {
...ruleData, const handleToggleRule = async (ruleId) => {
id: Math.max(0, ...rules.map(r => r.id)) + 1, const rule = rules.find(r => r.id === ruleId);
enabled: true if (!rule) return;
};
setRules([...rules, newRule]); setSaving(true);
try {
const res = await fetch(`api/rules/${ruleId}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ ...rule, enabled: !rule.enabled })
});
if (!res.ok) throw new Error('Failed to update rule');
const updated = await res.json();
setRules(rules.map(r => r.id === ruleId ? updated : r));
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleSaveRule = async (ruleData) => {
setSaving(true);
try {
if (editingRule) {
// Update existing rule
const res = await fetch(`api/rules/${editingRule.id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(ruleData)
});
if (!res.ok) throw new Error('Failed to update rule');
const updated = await res.json();
setRules(rules.map(r => r.id === editingRule.id ? updated : r));
} else {
// Create new rule
const res = await fetch('api/rules', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ ...ruleData, enabled: true })
});
if (!res.ok) throw new Error('Failed to create rule');
const newRule = await res.json();
setRules([...rules, newRule]);
}
setEditorOpen(false);
setEditingRule(null);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
} }
setEditorOpen(false);
setEditingRule(null);
}; };
const handleCloseEditor = () => { const handleCloseEditor = () => {
@@ -108,6 +206,15 @@ export default function RuleManager() {
setEditingRule(null); setEditingRule(null);
}; };
if (loading) {
return (
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
<Typography sx={{ mt: 2 }}>Loading rules...</Typography>
</Paper>
);
}
return ( return (
<Paper <Paper
sx={{ sx={{
@@ -123,29 +230,38 @@ export default function RuleManager() {
Automation Rules Automation Rules
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Configure triggers and actions for home automation {isAdmin ? 'Configure triggers and actions for home automation' : 'View automation rules (read-only)'}
</Typography> </Typography>
</Box> </Box>
<Button {isAdmin && (
variant="contained" <Button
onClick={handleAddRule} variant="contained"
sx={{ onClick={handleAddRule}
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)', disabled={saving}
'&:hover': { sx={{
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)', background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
} '&:hover': {
}} background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
> }
+ Add Rule }}
</Button> >
+ Add Rule
</Button>
)}
</Box> </Box>
<Divider sx={{ mb: 3 }} /> <Divider sx={{ mb: 3 }} />
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{rules.length === 0 ? ( {rules.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}> <Box sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary"> <Typography color="text.secondary">
No rules configured. Click "Add Rule" to create one. No rules configured. {isAdmin && 'Click "Add Rule" to create one.'}
</Typography> </Typography>
</Box> </Box>
) : ( ) : (
@@ -154,20 +270,26 @@ export default function RuleManager() {
<RuleCard <RuleCard
key={rule.id} key={rule.id}
rule={rule} rule={rule}
onEdit={() => handleEditRule(rule)} onEdit={isAdmin ? () => handleEditRule(rule) : null}
onDelete={() => handleDeleteRule(rule.id)} onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
onToggle={() => handleToggleRule(rule.id)} onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
readOnly={!isAdmin}
/> />
))} ))}
</Box> </Box>
)} )}
<RuleEditor {isAdmin && (
open={editorOpen} <RuleEditor
rule={editingRule} open={editorOpen}
onSave={handleSaveRule} rule={editingRule}
onClose={handleCloseEditor} onSave={handleSaveRule}
/> onClose={handleCloseEditor}
sensors={availableSensors}
outputs={availableOutputs}
saving={saving}
/>
)}
</Paper> </Paper>
); );
} }