sync rules
This commit is contained in:
@@ -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
158
server.js
@@ -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 {
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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' :
|
||||||
|
|
||||||
let dayText = isEveryDay ? 'Every day' :
|
|
||||||
isWeekdays ? 'Weekdays' :
|
|
||||||
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
|
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
|
||||||
|
|
||||||
return (
|
parts.push(
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Chip
|
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} />
|
||||||
label="⏰ Time"
|
<Typography variant="body2">At {trigger.time} ({dayText})</Typography>
|
||||||
size="small"
|
</Box>
|
||||||
sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }}
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
|
if (trigger.type === 'sensor' && !trigger.sensors) {
|
||||||
|
parts.push(
|
||||||
|
<Box key="legacy-sensor" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Chip label="📊" size="small" sx={{ bgcolor: '#fabd2f', 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 (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
<Chip
|
{parts}
|
||||||
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Tooltip title={rule.enabled ? 'Disable rule' : 'Enable rule'}>
|
<Tooltip title={rule.enabled ? 'Disable' : 'Enable'}>
|
||||||
<Switch
|
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
|
||||||
checked={rule.enabled}
|
|
||||||
onChange={onToggle}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Edit rule">
|
<Tooltip title="Edit">
|
||||||
<IconButton onClick={onEdit} size="small">
|
<IconButton onClick={onEdit} size="small"><EditIcon /></IconButton>
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Delete rule">
|
<Tooltip title="Delete">
|
||||||
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
|
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,47 +237,62 @@ 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
|
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
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>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
Days of Week
|
Days
|
||||||
</Typography>
|
</Typography>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={days}
|
value={days}
|
||||||
onChange={handleDaysChange}
|
onChange={handleDaysChange}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ flexWrap: 'wrap' }}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{DAYS.map(day => (
|
{DAYS.map(day => (
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -214,47 +313,85 @@ export default function RuleEditor({ open, rule, onSave, onClose }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
{triggerType === 'sensor' && (
|
{/* Sensor Conditions Trigger */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Paper sx={{ p: 2, bgcolor: useSensors ? 'action.selected' : 'background.default' }}>
|
||||||
<FormControl fullWidth>
|
<FormControlLabel
|
||||||
<InputLabel>Sensor</InputLabel>
|
control={
|
||||||
<Select
|
<Switch
|
||||||
value={sensor}
|
checked={useSensors}
|
||||||
label="Sensor"
|
onChange={(e) => setUseSensors(e.target.checked)}
|
||||||
onChange={(e) => setSensor(e.target.value)}
|
disabled={saving || sensors.length === 0}
|
||||||
>
|
|
||||||
{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
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
}
|
||||||
|
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>
|
</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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
if (!confirm('Delete this rule?')) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/rules/${ruleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete rule');
|
||||||
setRules(rules.filter(r => r.id !== ruleId));
|
setRules(rules.filter(r => r.id !== ruleId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleRule = (ruleId) => {
|
const handleToggleRule = async (ruleId) => {
|
||||||
setRules(rules.map(r =>
|
const rule = rules.find(r => r.id === ruleId);
|
||||||
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
|
if (!rule) return;
|
||||||
));
|
|
||||||
|
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 = (ruleData) => {
|
const handleSaveRule = async (ruleData) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
if (editingRule) {
|
if (editingRule) {
|
||||||
// Update existing rule
|
// Update existing rule
|
||||||
setRules(rules.map(r =>
|
const res = await fetch(`api/rules/${editingRule.id}`, {
|
||||||
r.id === editingRule.id ? { ...r, ...ruleData } : r
|
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 {
|
} else {
|
||||||
// Add new rule
|
// Create new rule
|
||||||
const newRule = {
|
const res = await fetch('api/rules', {
|
||||||
...ruleData,
|
method: 'POST',
|
||||||
id: Math.max(0, ...rules.map(r => r.id)) + 1,
|
headers: getAuthHeaders(),
|
||||||
enabled: true
|
body: JSON.stringify({ ...ruleData, enabled: true })
|
||||||
};
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create rule');
|
||||||
|
const newRule = await res.json();
|
||||||
setRules([...rules, newRule]);
|
setRules([...rules, newRule]);
|
||||||
}
|
}
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
setEditingRule(null);
|
setEditingRule(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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,12 +230,14 @@ 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>
|
||||||
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleAddRule}
|
onClick={handleAddRule}
|
||||||
|
disabled={saving}
|
||||||
sx={{
|
sx={{
|
||||||
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -138,14 +247,21 @@ export default function RuleManager() {
|
|||||||
>
|
>
|
||||||
+ Add Rule
|
+ Add Rule
|
||||||
</Button>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
<RuleEditor
|
<RuleEditor
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
rule={editingRule}
|
rule={editingRule}
|
||||||
onSave={handleSaveRule}
|
onSave={handleSaveRule}
|
||||||
onClose={handleCloseEditor}
|
onClose={handleCloseEditor}
|
||||||
|
sensors={availableSensors}
|
||||||
|
outputs={availableOutputs}
|
||||||
|
saving={saving}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user