u
This commit is contained in:
@@ -77,6 +77,35 @@ export function initDatabase(dbPath) {
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(created_by) REFERENCES users(id)
|
FOREIGN KEY(created_by) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Rules for automation
|
||||||
|
CREATE TABLE IF NOT EXISTS rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT DEFAULT 'static',
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
position INTEGER DEFAULT 0,
|
||||||
|
conditions TEXT NOT NULL, -- JSON (nested AND/OR structure)
|
||||||
|
action TEXT NOT NULL, -- JSON object
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(created_by) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Output events with RLE (same structure as sensor_events)
|
||||||
|
CREATE TABLE IF NOT EXISTS output_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
until DATETIME,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
value REAL,
|
||||||
|
data TEXT,
|
||||||
|
data_type TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_output_events_search
|
||||||
|
ON output_events(channel, timestamp);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('[DB] Database initialized successfully');
|
console.log('[DB] Database initialized successfully');
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import ShowChartIcon from '@mui/icons-material/ShowChart';
|
import ShowChartIcon from '@mui/icons-material/ShowChart';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
|
import RuleIcon from '@mui/icons-material/Rule';
|
||||||
|
|
||||||
import Settings from './components/Settings';
|
import Settings from './components/Settings';
|
||||||
import Chart from './components/Chart';
|
import Chart from './components/Chart';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import ViewManager from './components/ViewManager';
|
import ViewManager from './components/ViewManager';
|
||||||
import ViewDisplay from './components/ViewDisplay';
|
import ViewDisplay from './components/ViewDisplay';
|
||||||
|
import RuleEditor from './components/RuleEditor';
|
||||||
|
|
||||||
const darkTheme = createTheme({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
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="/" startIcon={<DashboardIcon />}>Views</Button>
|
||||||
<Button color="inherit" component={Link} to="/live" startIcon={<ShowChartIcon />}>Live</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>
|
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
@@ -111,6 +116,7 @@ export default class App extends Component {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ViewManager user={user} />} />
|
<Route path="/" element={<ViewManager user={user} />} />
|
||||||
<Route path="/views/:id" element={<ViewDisplay />} />
|
<Route path="/views/:id" element={<ViewDisplay />} />
|
||||||
|
<Route path="/rules" element={<RuleEditor user={user} />} />
|
||||||
<Route path="/live" element={
|
<Route path="/live" element={
|
||||||
<Chart
|
<Chart
|
||||||
selectedChannels={selectedChannels}
|
selectedChannels={selectedChannels}
|
||||||
|
|||||||
13
uiserver/src/components/Chart.js
vendored
13
uiserver/src/components/Chart.js
vendored
@@ -233,6 +233,7 @@ export default class Chart extends Component {
|
|||||||
let yAxisKey = 'left';
|
let yAxisKey = 'left';
|
||||||
let color = undefined;
|
let color = undefined;
|
||||||
let fillColor = undefined;
|
let fillColor = undefined;
|
||||||
|
let fillOpacity = 0.5;
|
||||||
if (channelConfig) {
|
if (channelConfig) {
|
||||||
const item = channelConfig.find(c => c.id === id);
|
const item = channelConfig.find(c => c.id === id);
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -240,6 +241,7 @@ export default class Chart extends Component {
|
|||||||
if (item.yAxis) yAxisKey = item.yAxis;
|
if (item.yAxis) yAxisKey = item.yAxis;
|
||||||
if (item.color) color = item.color;
|
if (item.color) color = item.color;
|
||||||
if (item.fillColor) fillColor = item.fillColor;
|
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,
|
yAxisKey: yAxisKey,
|
||||||
};
|
};
|
||||||
if (color) sObj.color = color;
|
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) {
|
if (fillColor) {
|
||||||
sObj.area = true;
|
sObj.area = true;
|
||||||
// Convert hex to rgba with 50% opacity
|
sObj.fillOpacity = fillOpacity;
|
||||||
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)`;
|
|
||||||
}
|
}
|
||||||
return sObj;
|
return sObj;
|
||||||
});
|
});
|
||||||
@@ -310,7 +307,7 @@ export default class Chart extends Component {
|
|||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
},
|
},
|
||||||
'& .MuiAreaElement-root': {
|
'& .MuiAreaElement-root': {
|
||||||
fillOpacity: 0.5,
|
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
651
uiserver/src/components/RuleEditor.js
Normal file
651
uiserver/src/components/RuleEditor.js
Normal 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;
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
||||||
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton,
|
FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton,
|
||||||
ToggleButton, ToggleButtonGroup
|
ToggleButton, ToggleButtonGroup, Slider
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
@@ -42,6 +42,8 @@ class ViewManager extends Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
views: [],
|
views: [],
|
||||||
|
rules: [],
|
||||||
|
outputValues: {},
|
||||||
open: false,
|
open: false,
|
||||||
colorPickerOpen: false,
|
colorPickerOpen: false,
|
||||||
colorPickerMode: 'line',
|
colorPickerMode: 'line',
|
||||||
@@ -69,6 +71,13 @@ class ViewManager extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.refreshViews();
|
this.refreshViews();
|
||||||
|
this.loadRules();
|
||||||
|
this.loadOutputValues();
|
||||||
|
// Refresh rules and outputs every 30s
|
||||||
|
this.rulesInterval = setInterval(() => {
|
||||||
|
this.loadRules();
|
||||||
|
this.loadOutputValues();
|
||||||
|
}, 30000);
|
||||||
if (this.isAdmin()) {
|
if (this.isAdmin()) {
|
||||||
fetch('/api/devices')
|
fetch('/api/devices')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -77,6 +86,10 @@ class ViewManager extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.rulesInterval) clearInterval(this.rulesInterval);
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (prevProps.user !== this.props.user) {
|
if (prevProps.user !== this.props.user) {
|
||||||
this.refreshViews();
|
this.refreshViews();
|
||||||
@@ -101,6 +114,20 @@ class ViewManager extends Component {
|
|||||||
.catch(console.error);
|
.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) {
|
parseViewData(view) {
|
||||||
let channels = [];
|
let channels = [];
|
||||||
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
|
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
|
||||||
@@ -131,6 +158,73 @@ class ViewManager extends Component {
|
|||||||
return { channels, axes };
|
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) {
|
getNextColor(idx) {
|
||||||
return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length];
|
return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length];
|
||||||
}
|
}
|
||||||
@@ -305,6 +399,17 @@ class ViewManager extends Component {
|
|||||||
clearFillColor = (idx) => {
|
clearFillColor = (idx) => {
|
||||||
const newConfig = [...this.state.viewConfig];
|
const newConfig = [...this.state.viewConfig];
|
||||||
delete newConfig[idx].fillColor;
|
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 });
|
this.setState({ viewConfig: newConfig });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,7 +496,8 @@ class ViewManager extends Component {
|
|||||||
alias: c.alias,
|
alias: c.alias,
|
||||||
yAxis: c.yAxis || 'left',
|
yAxis: c.yAxis || 'left',
|
||||||
color: c.color,
|
color: c.color,
|
||||||
fillColor: c.fillColor
|
fillColor: c.fillColor,
|
||||||
|
fillOpacity: c.fillOpacity
|
||||||
}))}
|
}))}
|
||||||
axisConfig={axes}
|
axisConfig={axes}
|
||||||
windowEnd={windowEnd}
|
windowEnd={windowEnd}
|
||||||
@@ -402,6 +508,47 @@ class ViewManager extends Component {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{views.length === 0 && <Typography>No views available.</Typography>}
|
{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>
|
</Box>
|
||||||
|
|
||||||
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
<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' }} />
|
<Box sx={{ width: 20, height: 20, bgcolor: ch.fillColor || 'transparent', borderRadius: '50%', border: ch.fillColor ? '2px solid #fff' : '2px dashed #666' }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{ch.fillColor && (
|
{ch.fillColor && (
|
||||||
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
|
<>
|
||||||
<DeleteIcon sx={{ fontSize: 14 }} />
|
<Slider
|
||||||
</IconButton>
|
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
|
<ListItemText
|
||||||
primary={ch.alias}
|
primary={ch.alias}
|
||||||
|
|||||||
@@ -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
|
// GET /api/devices
|
||||||
// Returns list of unique device/channel pairs
|
// Returns list of unique device/channel pairs
|
||||||
app.get('/api/devices', (req, res) => {
|
app.get('/api/devices', (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user