diff --git a/server/src/db/schema.js b/server/src/db/schema.js
index f830ef3..40fa543 100644
--- a/server/src/db/schema.js
+++ b/server/src/db/schema.js
@@ -77,6 +77,35 @@ export function initDatabase(dbPath) {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
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');
diff --git a/uiserver/src/App.js b/uiserver/src/App.js
index 7ef1007..4bee386 100644
--- a/uiserver/src/App.js
+++ b/uiserver/src/App.js
@@ -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 {
}>Views
}>Live
+ {user && user.role === 'admin' && (
+ }>Rules
+ )}
}>Settings
{user ? (
@@ -111,6 +116,7 @@ export default class App extends Component {
} />
} />
+ } />
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,
},
}}
/>
diff --git a/uiserver/src/components/RuleEditor.js b/uiserver/src/components/RuleEditor.js
new file mode 100644
index 0000000..42ed8fb
--- /dev/null
+++ b/uiserver/src/components/RuleEditor.js
@@ -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 (
+
+
+ this.toggleGroupOperator(path) : undefined}
+ sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }}
+ />
+ {this.isAdmin() && (
+ <>
+
+
+
+ >
+ )}
+
+
+ {group.conditions?.map((cond, idx) => {
+ const condPath = [...path, idx];
+
+ // Nested group
+ if (cond.operator === 'AND' || cond.operator === 'OR') {
+ return (
+
+ {this.renderConditionGroup(cond, condPath)}
+ {this.isAdmin() && (
+ this.removeCondition(condPath)}>
+
+
+ )}
+
+ );
+ }
+
+ // Single condition
+ return (
+
+
+
+ {(cond.type === 'sensor' || cond.type === 'output') && (
+
+ )}
+
+
+
+ {cond.operator === 'between' ? (
+ <>
+ this.updateCondition(condPath, { value: [e.target.value, (cond.value?.[1] || '')] })}
+ disabled={!this.isAdmin()}
+ sx={{ width: 140 }}
+ />
+ to
+ this.updateCondition(condPath, { value: [(cond.value?.[0] || ''), e.target.value] })}
+ disabled={!this.isAdmin()}
+ sx={{ width: 140 }}
+ />
+ >
+ ) : (
+ 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() && (
+ this.removeCondition(condPath)}>
+
+
+ )}
+
+ );
+ })}
+
+ );
+ };
+
+ 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 (
+
+
+
+
+ Rule Editor
+
+ {isAdmin && (
+ } onClick={this.handleOpenCreate}>
+ Create Rule
+
+ )}
+
+
+ {/* Current Output Values */}
+
+ Current Output Values
+
+ {outputChannels.map(ch => (
+ 0 ? 'success' : 'default'}
+ variant="outlined"
+ />
+ ))}
+
+
+
+ {/* Rules List */}
+
+ Rules (Priority Order)
+
+ {rules.map((rule, idx) => (
+
+
+ {isAdmin ? (
+ this.toggleRuleEnabled(rule)}>
+ {rule.enabled ? : }
+
+ ) : (
+ rule.enabled ? :
+ )}
+
+
+ {rule.name}
+
+
+ }
+ secondary={
+
+
+ When: {this.formatConditionSummary(rule.conditions)}
+
+
+ Then: Set {rule.action?.channel} = {rule.action?.value}
+
+
+ }
+ />
+ {isAdmin && (
+
+ this.moveRule(idx, -1)} disabled={idx === 0}>
+
+
+ this.moveRule(idx, 1)} disabled={idx === rules.length - 1}>
+
+
+ this.handleOpenEdit(rule, e)}>
+
+
+ this.handleDelete(rule.id, e)}>
+
+
+
+ )}
+
+ ))}
+ {rules.length === 0 && (
+
+ No rules defined. {isAdmin ? 'Click "Create Rule" to add one.' : ''}
+
+ )}
+
+
+
+ {/* Edit/Create Dialog */}
+
+
+ );
+ }
+}
+
+export default RuleEditor;
diff --git a/uiserver/src/components/ViewManager.js b/uiserver/src/components/ViewManager.js
index c072b5b..5fcd7fa 100644
--- a/uiserver/src/components/ViewManager.js
+++ b/uiserver/src/components/ViewManager.js
@@ -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 && No views available.}
+
+ {/* Rules Summary */}
+ {this.state.rules.length > 0 && (
+
+ 🤖 Active Rules
+
+ {this.state.rules.filter(r => r.enabled).map((rule, idx) => (
+
+
+ {this.getRuleEmoji(rule)}
+
+
+
+ {rule.name}
+
+
+ {this.formatRuleConditions(rule.conditions)} → {this.formatRuleAction(rule.action)}
+
+
+
+ #{idx + 1}
+
+
+ ))}
+
+
+ 📊 Current outputs: {Object.entries(this.state.outputValues).filter(([k, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(', ') || 'all off'}
+
+
+ )}