This commit is contained in:
sebseb7
2025-12-25 03:28:12 +01:00
parent acbf168218
commit ce87faa551
6 changed files with 1183 additions and 13 deletions

View File

@@ -5,12 +5,14 @@ import { ThemeProvider, createTheme } from '@mui/material/styles';
import SettingsIcon from '@mui/icons-material/Settings';
import ShowChartIcon from '@mui/icons-material/ShowChart';
import DashboardIcon from '@mui/icons-material/Dashboard';
import RuleIcon from '@mui/icons-material/Rule';
import Settings from './components/Settings';
import Chart from './components/Chart';
import Login from './components/Login';
import ViewManager from './components/ViewManager';
import ViewDisplay from './components/ViewDisplay';
import RuleEditor from './components/RuleEditor';
const darkTheme = createTheme({
palette: {
@@ -98,6 +100,9 @@ export default class App extends Component {
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>
<Button color="inherit" component={Link} to="/live" startIcon={<ShowChartIcon />}>Live</Button>
{user && user.role === 'admin' && (
<Button color="inherit" component={Link} to="/rules" startIcon={<RuleIcon />}>Rules</Button>
)}
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
{user ? (
@@ -111,6 +116,7 @@ export default class App extends Component {
<Routes>
<Route path="/" element={<ViewManager user={user} />} />
<Route path="/views/:id" element={<ViewDisplay />} />
<Route path="/rules" element={<RuleEditor user={user} />} />
<Route path="/live" element={
<Chart
selectedChannels={selectedChannels}

View File

@@ -233,6 +233,7 @@ export default class Chart extends Component {
let yAxisKey = 'left';
let color = undefined;
let fillColor = undefined;
let fillOpacity = 0.5;
if (channelConfig) {
const item = channelConfig.find(c => c.id === id);
if (item) {
@@ -240,6 +241,7 @@ export default class Chart extends Component {
if (item.yAxis) yAxisKey = item.yAxis;
if (item.color) color = item.color;
if (item.fillColor) fillColor = item.fillColor;
if (item.fillOpacity !== undefined) fillOpacity = item.fillOpacity;
}
}
@@ -251,15 +253,10 @@ export default class Chart extends Component {
yAxisKey: yAxisKey,
};
if (color) sObj.color = color;
// Enable area fill if fillColor is set (with 50% transparency)
// Enable area fill if fillColor is set (with configurable opacity)
if (fillColor) {
sObj.area = true;
// Convert hex to rgba with 50% opacity
const hex = fillColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
sObj.areaColor = `rgba(${r}, ${g}, ${b}, 0.5)`;
sObj.fillOpacity = fillOpacity;
}
return sObj;
});
@@ -310,7 +307,7 @@ export default class Chart extends Component {
strokeWidth: 3,
},
'& .MuiAreaElement-root': {
fillOpacity: 0.5,
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5,
},
}}
/>

View File

@@ -0,0 +1,651 @@
import React, { Component } from 'react';
import {
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, InputLabel, Select, MenuItem, Box, IconButton, Switch,
FormControlLabel, Chip, Divider, Tooltip
} from '@mui/material';
import RuleIcon from '@mui/icons-material/Rule';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
// Condition operators by type
const CONDITION_OPERATORS = {
time: [
{ value: 'between', label: 'Between' },
{ value: '=', label: '=' },
{ value: '<', label: '<' },
{ value: '>', label: '>' }
],
date: [
{ value: 'before', label: 'Before' },
{ value: 'after', label: 'After' },
{ value: 'between', label: 'Between' }
],
sensor: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
{ value: '<', label: '<' },
{ value: '>', label: '>' },
{ value: '<=', label: '<=' },
{ value: '>=', label: '>=' }
],
output: [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
{ value: '<', label: '<' },
{ value: '>', label: '>' },
{ value: '<=', label: '<=' },
{ value: '>=', label: '>=' }
]
};
class RuleEditor extends Component {
constructor(props) {
super(props);
this.state = {
rules: [],
outputChannels: [],
devices: [],
outputValues: {},
// Dialog state
open: false,
editingId: null,
ruleName: '',
ruleEnabled: true,
conditions: { operator: 'AND', conditions: [] },
action: { channel: '', value: 0 }
};
}
componentDidMount() {
this.refreshRules();
this.loadOutputChannels();
this.loadDevices();
this.loadOutputValues();
// Refresh output values every 10s
this.refreshInterval = setInterval(() => this.loadOutputValues(), 10000);
}
componentWillUnmount() {
if (this.refreshInterval) clearInterval(this.refreshInterval);
}
isAdmin() {
const { user } = this.props;
return user && user.role === 'admin';
}
refreshRules = () => {
fetch('/api/rules')
.then(res => res.json())
.then(rules => this.setState({ rules }))
.catch(console.error);
};
loadOutputChannels = () => {
fetch('/api/outputs')
.then(res => res.json())
.then(outputChannels => this.setState({ outputChannels }))
.catch(console.error);
};
loadDevices = () => {
fetch('/api/devices')
.then(res => res.json())
.then(devices => this.setState({ devices }))
.catch(console.error);
};
loadOutputValues = () => {
fetch('/api/outputs/values')
.then(res => res.json())
.then(outputValues => this.setState({ outputValues }))
.catch(console.error);
};
// Dialog handlers
handleOpenCreate = () => {
this.setState({
editingId: null,
ruleName: '',
ruleEnabled: true,
conditions: { operator: 'AND', conditions: [] },
action: { channel: this.state.outputChannels[0]?.channel || '', value: 0 },
open: true
});
};
handleOpenEdit = (rule, e) => {
e.stopPropagation();
this.setState({
editingId: rule.id,
ruleName: rule.name,
ruleEnabled: !!rule.enabled,
conditions: rule.conditions || { operator: 'AND', conditions: [] },
action: rule.action || { channel: '', value: 0 },
open: true
});
};
handleDelete = async (id, e) => {
e.stopPropagation();
if (!window.confirm("Delete this rule?")) return;
const { user } = this.props;
await fetch(`/api/rules/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${user.token}` }
});
this.refreshRules();
};
moveRule = async (idx, dir) => {
const newRules = [...this.state.rules];
const target = idx + dir;
if (target < 0 || target >= newRules.length) return;
[newRules[idx], newRules[target]] = [newRules[target], newRules[idx]];
this.setState({ rules: newRules });
const order = newRules.map((r, i) => ({ id: r.id, position: i }));
const { user } = this.props;
try {
await fetch('/api/rules/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({ order })
});
} catch (err) {
console.error("Failed to save order", err);
}
};
handleSave = async () => {
const { ruleName, ruleEnabled, conditions, action, editingId } = this.state;
const { user } = this.props;
if (!ruleName || !action.channel) {
alert('Please fill in all required fields');
return;
}
const url = editingId ? `/api/rules/${editingId}` : '/api/rules';
const method = editingId ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({
name: ruleName,
enabled: ruleEnabled,
conditions,
action
})
});
if (res.ok) {
this.setState({ open: false });
this.refreshRules();
} else {
const err = await res.json();
alert('Failed to save rule: ' + err.error);
}
} catch (err) {
console.error(err);
}
};
toggleRuleEnabled = async (rule) => {
const { user } = this.props;
try {
await fetch(`/api/rules/${rule.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({
...rule,
enabled: !rule.enabled
})
});
this.refreshRules();
} catch (err) {
console.error(err);
}
};
// Condition editing
addCondition = (parentPath = []) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (const idx of parentPath) {
target = target.conditions[idx];
}
target.conditions.push({
type: 'sensor',
operator: '>',
channel: '',
value: 0
});
return { conditions: newConditions };
});
};
addConditionGroup = (parentPath = [], groupType = 'AND') => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (const idx of parentPath) {
target = target.conditions[idx];
}
target.conditions.push({
operator: groupType,
conditions: []
});
return { conditions: newConditions };
});
};
updateCondition = (path, updates) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (let i = 0; i < path.length - 1; i++) {
target = target.conditions[path[i]];
}
const idx = path[path.length - 1];
target.conditions[idx] = { ...target.conditions[idx], ...updates };
return { conditions: newConditions };
});
};
removeCondition = (path) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (let i = 0; i < path.length - 1; i++) {
target = target.conditions[path[i]];
}
const idx = path[path.length - 1];
target.conditions.splice(idx, 1);
return { conditions: newConditions };
});
};
toggleGroupOperator = (path) => {
this.setState(prev => {
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
let target = newConditions;
for (const idx of path) {
target = target.conditions[idx];
}
if (path.length === 0) {
// Root level
newConditions.operator = newConditions.operator === 'AND' ? 'OR' : 'AND';
} else {
target.operator = target.operator === 'AND' ? 'OR' : 'AND';
}
return { conditions: newConditions };
});
};
// Render a condition group recursively
renderConditionGroup = (group, path = []) => {
const { devices, outputChannels } = this.state;
const isRoot = path.length === 0;
// Build sensor channels list
const sensorChannels = devices.map(d => `${d.device}:${d.channel}`);
return (
<Box sx={{
pl: isRoot ? 0 : 2,
borderLeft: isRoot ? 'none' : '2px solid',
borderColor: group.operator === 'AND' ? '#83a598' : '#fabd2f',
ml: isRoot ? 0 : 1,
mb: 1
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Chip
label={group.operator}
size="small"
color={group.operator === 'AND' ? 'primary' : 'warning'}
onClick={this.isAdmin() ? () => this.toggleGroupOperator(path) : undefined}
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }}
/>
{this.isAdmin() && (
<>
<Button size="small" onClick={() => this.addCondition(path)}>+ Condition</Button>
<Button size="small" onClick={() => this.addConditionGroup(path, 'AND')}>+ AND Group</Button>
<Button size="small" onClick={() => this.addConditionGroup(path, 'OR')}>+ OR Group</Button>
</>
)}
</Box>
{group.conditions?.map((cond, idx) => {
const condPath = [...path, idx];
// Nested group
if (cond.operator === 'AND' || cond.operator === 'OR') {
return (
<Box key={idx} sx={{ mb: 1 }}>
{this.renderConditionGroup(cond, condPath)}
{this.isAdmin() && (
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
);
}
// Single condition
return (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
<Select
size="small"
value={cond.type || 'sensor'}
onChange={e => this.updateCondition(condPath, { type: e.target.value, operator: CONDITION_OPERATORS[e.target.value][0].value })}
disabled={!this.isAdmin()}
sx={{ minWidth: 100 }}
>
<MenuItem value="time">Time</MenuItem>
<MenuItem value="date">Date</MenuItem>
<MenuItem value="sensor">Sensor</MenuItem>
<MenuItem value="output">Output</MenuItem>
</Select>
{(cond.type === 'sensor' || cond.type === 'output') && (
<Select
size="small"
value={cond.channel || ''}
onChange={e => this.updateCondition(condPath, { channel: e.target.value })}
disabled={!this.isAdmin()}
displayEmpty
sx={{ minWidth: 180 }}
>
<MenuItem value=""><em>Select Channel</em></MenuItem>
{(cond.type === 'sensor' ? sensorChannels : outputChannels.map(c => c.channel))
.map(ch => <MenuItem key={ch} value={ch}>{ch}</MenuItem>)}
</Select>
)}
<Select
size="small"
value={cond.operator || '='}
onChange={e => this.updateCondition(condPath, { operator: e.target.value })}
disabled={!this.isAdmin()}
sx={{ minWidth: 80 }}
>
{(CONDITION_OPERATORS[cond.type] || CONDITION_OPERATORS.sensor).map(op => (
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
))}
</Select>
{cond.operator === 'between' ? (
<>
<TextField
size="small"
type={cond.type === 'time' ? 'time' : 'date'}
value={Array.isArray(cond.value) ? cond.value[0] : ''}
onChange={e => this.updateCondition(condPath, { value: [e.target.value, (cond.value?.[1] || '')] })}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
<Typography>to</Typography>
<TextField
size="small"
type={cond.type === 'time' ? 'time' : 'date'}
value={Array.isArray(cond.value) ? cond.value[1] : ''}
onChange={e => this.updateCondition(condPath, { value: [(cond.value?.[0] || ''), e.target.value] })}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
</>
) : (
<TextField
size="small"
type={cond.type === 'time' ? 'time' : (cond.type === 'date' ? 'date' : 'number')}
value={cond.value ?? ''}
onChange={e => this.updateCondition(condPath, {
value: cond.type === 'sensor' || cond.type === 'output'
? parseFloat(e.target.value) || 0
: e.target.value
})}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
)}
{this.isAdmin() && (
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
);
})}
</Box>
);
};
formatConditionSummary = (condition) => {
if (!condition) return '';
if (condition.operator === 'AND' || condition.operator === 'OR') {
const parts = (condition.conditions || []).map(c => this.formatConditionSummary(c)).filter(Boolean);
return parts.length > 0 ? `(${parts.join(` ${condition.operator} `)})` : '';
}
const { type, channel, operator, value } = condition;
let formatted = '';
switch (type) {
case 'time':
formatted = operator === 'between'
? `${value?.[0] || '?'} - ${value?.[1] || '?'}`
: `time ${operator} ${value}`;
break;
case 'date':
formatted = operator === 'between'
? `date ${value?.[0] || '?'} to ${value?.[1] || '?'}`
: `date ${operator} ${value}`;
break;
case 'sensor':
case 'output':
formatted = `${channel || '?'} ${operator} ${value}`;
break;
default:
formatted = JSON.stringify(condition);
}
return formatted;
};
render() {
const { rules, outputChannels, outputValues, open, editingId, ruleName, ruleEnabled, conditions, action } = this.state;
const isAdmin = this.isAdmin();
return (
<Container maxWidth="xl" sx={{ mt: 4 }}>
<Paper sx={{ p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h5">
<RuleIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Rule Editor
</Typography>
{isAdmin && (
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
Create Rule
</Button>
)}
</Paper>
{/* Current Output Values */}
<Paper sx={{ p: 2, mb: 4 }}>
<Typography variant="h6" gutterBottom>Current Output Values</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{outputChannels.map(ch => (
<Chip
key={ch.channel}
label={`${ch.description}: ${outputValues[ch.channel] ?? 0}`}
color={outputValues[ch.channel] > 0 ? 'success' : 'default'}
variant="outlined"
/>
))}
</Box>
</Paper>
{/* Rules List */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Rules (Priority Order)</Typography>
<List>
{rules.map((rule, idx) => (
<ListItem
key={rule.id}
sx={{
bgcolor: rule.enabled ? 'transparent' : 'rgba(0,0,0,0.2)',
borderRadius: 1,
mb: 1,
border: '1px solid #504945'
}}
>
<ListItemIcon>
{isAdmin ? (
<IconButton onClick={() => this.toggleRuleEnabled(rule)}>
{rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />}
</IconButton>
) : (
rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />
)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">{rule.name}</Typography>
<Chip size="small" label={rule.type || 'static'} />
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
When: {this.formatConditionSummary(rule.conditions)}
</Typography>
<Typography variant="body2" color="text.secondary">
Then: Set {rule.action?.channel} = {rule.action?.value}
</Typography>
</Box>
}
/>
{isAdmin && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton size="small" onClick={() => this.moveRule(idx, -1)} disabled={idx === 0}>
<ArrowUpwardIcon />
</IconButton>
<IconButton size="small" onClick={() => this.moveRule(idx, 1)} disabled={idx === rules.length - 1}>
<ArrowDownwardIcon />
</IconButton>
<IconButton onClick={(e) => this.handleOpenEdit(rule, e)}>
<EditIcon />
</IconButton>
<IconButton color="error" onClick={(e) => this.handleDelete(rule.id, e)}>
<DeleteIcon />
</IconButton>
</Box>
)}
</ListItem>
))}
{rules.length === 0 && (
<Typography color="text.secondary" sx={{ p: 2 }}>
No rules defined. {isAdmin ? 'Click "Create Rule" to add one.' : ''}
</Typography>
)}
</List>
</Paper>
{/* Edit/Create Dialog */}
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Edit Rule' : 'Create New Rule'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', gap: 2, mb: 2, mt: 1 }}>
<TextField
label="Rule Name"
value={ruleName}
onChange={e => this.setState({ ruleName: e.target.value })}
fullWidth
/>
<FormControlLabel
control={
<Switch
checked={ruleEnabled}
onChange={e => this.setState({ ruleEnabled: e.target.checked })}
/>
}
label="Enabled"
/>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1" gutterBottom>Conditions (When)</Typography>
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}>
{this.renderConditionGroup(conditions)}
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1" gutterBottom>Action (Then)</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Typography>Set</Typography>
<Select
size="small"
value={action.channel}
onChange={e => this.setState({ action: { ...action, channel: e.target.value } })}
sx={{ minWidth: 200 }}
>
{outputChannels.map(ch => (
<MenuItem key={ch.channel} value={ch.channel}>
{ch.description} ({ch.channel})
</MenuItem>
))}
</Select>
<Typography>to</Typography>
<TextField
size="small"
type="number"
value={action.value}
onChange={e => this.setState({ action: { ...action, value: parseFloat(e.target.value) || 0 } })}
inputProps={{
min: outputChannels.find(c => c.channel === action.channel)?.min || 0,
max: outputChannels.find(c => c.channel === action.channel)?.max || 10
}}
sx={{ width: 100 }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
<Button onClick={this.handleSave} variant="contained">Save</Button>
</DialogActions>
</Dialog>
</Container>
);
}
}
export default RuleEditor;

View File

@@ -3,7 +3,7 @@ import {
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton,
ToggleButton, ToggleButtonGroup
ToggleButton, ToggleButtonGroup, Slider
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import AddIcon from '@mui/icons-material/Add';
@@ -42,6 +42,8 @@ class ViewManager extends Component {
super(props);
this.state = {
views: [],
rules: [],
outputValues: {},
open: false,
colorPickerOpen: false,
colorPickerMode: 'line',
@@ -69,6 +71,13 @@ class ViewManager extends Component {
componentDidMount() {
this.refreshViews();
this.loadRules();
this.loadOutputValues();
// Refresh rules and outputs every 30s
this.rulesInterval = setInterval(() => {
this.loadRules();
this.loadOutputValues();
}, 30000);
if (this.isAdmin()) {
fetch('/api/devices')
.then(res => res.json())
@@ -77,6 +86,10 @@ class ViewManager extends Component {
}
}
componentWillUnmount() {
if (this.rulesInterval) clearInterval(this.rulesInterval);
}
componentDidUpdate(prevProps) {
if (prevProps.user !== this.props.user) {
this.refreshViews();
@@ -101,6 +114,20 @@ class ViewManager extends Component {
.catch(console.error);
};
loadRules = () => {
fetch('/api/rules')
.then(res => res.json())
.then(rules => this.setState({ rules }))
.catch(console.error);
};
loadOutputValues = () => {
fetch('/api/outputs/values')
.then(res => res.json())
.then(outputValues => this.setState({ outputValues }))
.catch(console.error);
};
parseViewData(view) {
let channels = [];
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
@@ -131,6 +158,73 @@ class ViewManager extends Component {
return { channels, axes };
}
// Emoji for rule based on action channel
getRuleEmoji = (rule) => {
const channel = rule.action?.channel || '';
const emojis = {
'CircFanLevel': '🌀',
'TentLightLevel': '💡',
'TentExhaustLevel': '💨',
'RoomExhaustLevel': '🌬️',
'CO2Valve': '🫧',
'BigDehumid': '💧',
'TentDehumid': '💦'
};
return emojis[channel] || '⚡';
};
// Format conditions for display
formatRuleConditions = (condition) => {
if (!condition) return '(always)';
if (condition.operator === 'AND' || condition.operator === 'OR') {
const parts = (condition.conditions || []).map(c => this.formatRuleConditions(c)).filter(Boolean);
if (parts.length === 0) return '(always)';
const sep = condition.operator === 'AND' ? ' & ' : ' | ';
return parts.join(sep);
}
const { type, channel, operator, value } = condition;
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
const op = opSymbols[operator] || operator;
switch (type) {
case 'time':
if (operator === 'between' && Array.isArray(value)) {
return `🕐 ${value[0]} - ${value[1]}`;
}
return `🕐 ${op} ${value}`;
case 'date':
if (operator === 'between' && Array.isArray(value)) {
return `📅 ${value[0]} to ${value[1]}`;
}
return `📅 ${operator} ${value}`;
case 'sensor':
const sensorName = channel?.split(':').pop() || channel;
return `📡 ${sensorName} ${op} ${value}`;
case 'output':
return `⚙️ ${channel} ${op} ${value}`;
default:
return '?';
}
};
// Format action for display
formatRuleAction = (action) => {
if (!action?.channel) return '?';
const channelNames = {
'CircFanLevel': '🌀 Circ Fan',
'TentLightLevel': '💡 Tent Light',
'TentExhaustLevel': '💨 Tent Exhaust',
'RoomExhaustLevel': '🌬️ Room Exhaust',
'CO2Valve': '🫧 CO2',
'BigDehumid': '💧 Big Dehumid',
'TentDehumid': '💦 Tent Dehumid'
};
const name = channelNames[action.channel] || action.channel;
return `${name} = ${action.value}`;
};
getNextColor(idx) {
return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length];
}
@@ -305,6 +399,17 @@ class ViewManager extends Component {
clearFillColor = (idx) => {
const newConfig = [...this.state.viewConfig];
delete newConfig[idx].fillColor;
delete newConfig[idx].fillOpacity;
this.setState({ viewConfig: newConfig });
};
updateFillOpacity = (idx, value) => {
const newConfig = this.state.viewConfig.map((ch, i) => {
if (i === idx) {
return { ...ch, fillOpacity: value };
}
return ch;
});
this.setState({ viewConfig: newConfig });
};
@@ -391,7 +496,8 @@ class ViewManager extends Component {
alias: c.alias,
yAxis: c.yAxis || 'left',
color: c.color,
fillColor: c.fillColor
fillColor: c.fillColor,
fillOpacity: c.fillOpacity
}))}
axisConfig={axes}
windowEnd={windowEnd}
@@ -402,6 +508,47 @@ class ViewManager extends Component {
);
})}
{views.length === 0 && <Typography>No views available.</Typography>}
{/* Rules Summary */}
{this.state.rules.length > 0 && (
<Paper sx={{ p: 2, mt: 4 }}>
<Typography variant="h5" sx={{ mb: 2 }}>🤖 Active Rules</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{this.state.rules.filter(r => r.enabled).map((rule, idx) => (
<Box
key={rule.id}
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px solid #504945',
display: 'flex',
alignItems: 'center',
gap: 2
}}
>
<Typography sx={{ fontSize: '1.2em' }}>
{this.getRuleEmoji(rule)}
</Typography>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{rule.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{this.formatRuleConditions(rule.conditions)} {this.formatRuleAction(rule.action)}
</Typography>
</Box>
<Typography sx={{ fontSize: '0.85em', color: 'text.secondary' }}>
#{idx + 1}
</Typography>
</Box>
))}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, textAlign: 'center' }}>
📊 Current outputs: {Object.entries(this.state.outputValues).filter(([k, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(', ') || 'all off'}
</Typography>
</Paper>
)}
</Box>
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
@@ -440,9 +587,21 @@ class ViewManager extends Component {
<Box sx={{ width: 20, height: 20, bgcolor: ch.fillColor || 'transparent', borderRadius: '50%', border: ch.fillColor ? '2px solid #fff' : '2px dashed #666' }} />
</IconButton>
{ch.fillColor && (
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
<DeleteIcon sx={{ fontSize: 14 }} />
</IconButton>
<>
<Slider
size="small"
value={ch.fillOpacity ?? 0.5}
min={0.1}
max={1}
step={0.1}
onChange={(e, val) => this.updateFillOpacity(idx, val)}
sx={{ width: 60, ml: 1 }}
title="Fill opacity"
/>
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
<DeleteIcon sx={{ fontSize: 14 }} />
</IconButton>
</>
)}
<ListItemText
primary={ch.alias}

View File

@@ -216,6 +216,334 @@ module.exports = {
}
});
// =============================================
// RULES API
// =============================================
// Apply checkAuth middleware to rules API routes
app.use('/api/rules', checkAuth);
// Virtual output channel definitions
const OUTPUT_CHANNELS = [
{ channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation Fan Level' },
{ channel: 'TentLightLevel', type: 'number', min: 0, max: 10, description: 'Tent Light Level' },
{ channel: 'TentExhaustLevel', type: 'number', min: 0, max: 10, description: 'Tent Exhaust Fan Level' },
{ channel: 'RoomExhaustLevel', type: 'number', min: 0, max: 10, description: 'Room Exhaust Fan Level' },
{ channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' },
{ channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
{ channel: 'TentDehumid', type: 'boolean', min: 0, max: 1, description: 'Tent Dehumidifier' },
];
// GET /api/outputs - List output channel definitions
app.get('/api/outputs', (req, res) => {
res.json(OUTPUT_CHANNELS);
});
// GET /api/outputs/values - Get current output values
app.get('/api/outputs/values', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const result = {};
const stmt = db.prepare(`
SELECT channel, value FROM output_events
WHERE id IN (
SELECT MAX(id) FROM output_events GROUP BY channel
)
`);
const rows = stmt.all();
rows.forEach(row => {
result[row.channel] = row.value;
});
// Fill in defaults for missing channels
OUTPUT_CHANNELS.forEach(ch => {
if (result[ch.channel] === undefined) {
result[ch.channel] = 0;
}
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/rules - List all rules
app.get('/api/rules', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC');
const rows = stmt.all();
const rules = rows.map(row => ({
...row,
conditions: JSON.parse(row.conditions || '{}'),
action: JSON.parse(row.action || '{}')
}));
res.json(rules);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules - Create rule (admin only)
app.post('/api/rules', requireAdmin, (req, res) => {
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
if (!name || !conditions || !action) {
return res.status(400).json({ error: 'Missing required fields: name, conditions, action' });
}
try {
const stmt = db.prepare(`
INSERT INTO rules (name, type, enabled, conditions, action, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
name,
type,
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.user?.id || null
);
res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/rules/:id - Update rule (admin only)
app.put('/api/rules/:id', requireAdmin, (req, res) => {
const { name, type, enabled, conditions, action } = req.body;
try {
const stmt = db.prepare(`
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
WHERE id = ?
`);
const info = stmt.run(
name,
type || 'static',
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.params.id
);
if (info.changes > 0) {
res.json({ id: req.params.id, name, type, enabled, conditions, action });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/rules/:id - Delete rule (admin only)
app.delete('/api/rules/:id', requireAdmin, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM rules WHERE id = ?');
const info = stmt.run(req.params.id);
if (info.changes > 0) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules/reorder - Reorder rules (admin only)
app.post('/api/rules/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// =============================================
// RULE RUNNER (Background Job)
// =============================================
// Get current sensor value
function getSensorValue(channel) {
// channel format: "device:channel" e.g. "ac:controller:co2"
const lastColonIndex = channel.lastIndexOf(':');
if (lastColonIndex === -1) return null;
const device = channel.substring(0, lastColonIndex);
const ch = channel.substring(lastColonIndex + 1);
const stmt = db.prepare(`
SELECT value FROM sensor_events
WHERE device = ? AND channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const row = stmt.get(device, ch);
return row ? row.value : null;
}
// Get current output value
function getOutputValue(channel) {
const stmt = db.prepare(`
SELECT value FROM output_events
WHERE channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const row = stmt.get(channel);
return row ? row.value : 0;
}
// Write output value with RLE
function writeOutputValue(channel, value) {
const now = new Date().toISOString();
// Get last value for this channel
const lastStmt = db.prepare(`
SELECT id, value FROM output_events
WHERE channel = ?
ORDER BY timestamp DESC LIMIT 1
`);
const last = lastStmt.get(channel);
if (last && Math.abs(last.value - value) < Number.EPSILON) {
// Same value - update the until timestamp (RLE)
const updateStmt = db.prepare('UPDATE output_events SET until = ? WHERE id = ?');
updateStmt.run(now, last.id);
} else {
// New value - insert new record
const insertStmt = db.prepare(`
INSERT INTO output_events (timestamp, until, channel, value, data_type)
VALUES (?, NULL, ?, ?, 'number')
`);
insertStmt.run(now, channel, value);
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
}
}
// Compare values with operator
function compareValues(actual, operator, target) {
if (actual === null || actual === undefined) return false;
switch (operator) {
case '=':
case '==': return actual === target;
case '!=': return actual !== target;
case '<': return actual < target;
case '>': return actual > target;
case '<=': return actual <= target;
case '>=': return actual >= target;
default: return false;
}
}
// Evaluate a single condition
function evaluateCondition(condition) {
const { type, operator, value, channel } = condition;
// Handle AND/OR groups
if (operator === 'AND' || operator === 'OR') {
const results = (condition.conditions || []).map(c => evaluateCondition(c));
return operator === 'AND'
? results.every(r => r)
: results.some(r => r);
}
switch (type) {
case 'time': {
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight
if (operator === 'between' && Array.isArray(value)) {
const [start, end] = value.map(t => {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
});
return currentTime >= start && currentTime <= end;
}
const [h, m] = String(value).split(':').map(Number);
const targetTime = h * 60 + m;
return compareValues(currentTime, operator, targetTime);
}
case 'date': {
const now = new Date();
const today = now.toISOString().split('T')[0];
if (operator === 'between' && Array.isArray(value)) {
return today >= value[0] && today <= value[1];
}
if (operator === 'before') return today < value;
if (operator === 'after') return today > value;
return today === value;
}
case 'sensor': {
const sensorValue = getSensorValue(channel);
return compareValues(sensorValue, operator, value);
}
case 'output': {
const outputValue = getOutputValue(channel);
return compareValues(outputValue, operator, value);
}
default:
console.warn(`[RuleRunner] Unknown condition type: ${type}`);
return false;
}
}
// Run all rules
function runRules() {
if (!db) return;
try {
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
const desiredOutputs = {}; // channel -> value
for (const rule of rules) {
try {
const conditions = JSON.parse(rule.conditions || '{}');
const action = JSON.parse(rule.action || '{}');
if (evaluateCondition(conditions)) {
// Rule matches - set output (later rules override)
if (action.channel && action.value !== undefined) {
desiredOutputs[action.channel] = action.value;
}
}
} catch (err) {
console.error(`[RuleRunner] Error evaluating rule ${rule.id}:`, err.message);
}
}
// Write output values
for (const [channel, value] of Object.entries(desiredOutputs)) {
writeOutputValue(channel, value);
}
} catch (err) {
console.error('[RuleRunner] Error running rules:', err.message);
}
}
// Start rule runner (every 10 seconds)
const ruleRunnerInterval = setInterval(runRules, 10000);
console.log('[RuleRunner] Started background job (10s interval)');
// Clean up on server close
devServer.server?.on('close', () => {
clearInterval(ruleRunnerInterval);
console.log('[RuleRunner] Stopped background job');
});
// GET /api/devices
// Returns list of unique device/channel pairs
app.get('/api/devices', (req, res) => {