Compare commits
2 Commits
22701a2614
...
dcdfb27684
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcdfb27684 | ||
|
|
10556cb698 |
@@ -418,19 +418,86 @@ class RuleEditor extends Component {
|
|||||||
sx={{ width: 140 }}
|
sx={{ width: 140 }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
cond.type === 'sensor' ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{/* Dynamic Target Toggle */}
|
||||||
|
<Tooltip title="Compare to Value or Another Sensor">
|
||||||
|
<Chip
|
||||||
|
label={cond.value?.type === 'dynamic' ? 'Sensor' : 'Value'}
|
||||||
|
size="small"
|
||||||
|
color={cond.value?.type === 'dynamic' ? 'secondary' : 'default'}
|
||||||
|
onClick={this.isAdmin() ? () => {
|
||||||
|
const isDynamic = cond.value?.type === 'dynamic';
|
||||||
|
this.updateCondition(condPath, {
|
||||||
|
value: isDynamic
|
||||||
|
? 0 // Switch to static
|
||||||
|
: { type: 'dynamic', channel: '', factor: 1, offset: 0 } // Switch to dynamic
|
||||||
|
});
|
||||||
|
} : undefined}
|
||||||
|
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default', minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{cond.value?.type === 'dynamic' ? (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={cond.value.channel || ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, channel: e.target.value } })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Target Sensor</em></MenuItem>
|
||||||
|
{sensorChannels.map(ch => <MenuItem key={ch} value={ch}>{ch}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
<Typography>*</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Factor"
|
||||||
|
type="number"
|
||||||
|
value={cond.value.factor}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, factor: parseFloat(e.target.value) || 0 } })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
<Typography>+</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Offset"
|
||||||
|
type="number"
|
||||||
|
value={cond.value.offset}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, offset: parseFloat(e.target.value) || 0 } })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={cond.value ?? ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: parseFloat(e.target.value) || 0 })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
type={cond.type === 'time' ? 'time' : (cond.type === 'date' ? 'date' : 'number')}
|
type={cond.type === 'time' ? 'time' : (cond.type === 'date' ? 'date' : 'number')}
|
||||||
value={cond.value ?? ''}
|
value={cond.value ?? ''}
|
||||||
onChange={e => this.updateCondition(condPath, {
|
onChange={e => this.updateCondition(condPath, {
|
||||||
value: cond.type === 'sensor' || cond.type === 'output'
|
value: cond.type === 'output'
|
||||||
? parseFloat(e.target.value) || 0
|
? parseFloat(e.target.value) || 0
|
||||||
: e.target.value
|
: e.target.value
|
||||||
})}
|
})}
|
||||||
disabled={!this.isAdmin()}
|
disabled={!this.isAdmin()}
|
||||||
sx={{ width: 140 }}
|
sx={{ width: 140 }}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{this.isAdmin() && (
|
{this.isAdmin() && (
|
||||||
@@ -468,6 +535,12 @@ class RuleEditor extends Component {
|
|||||||
: `date ${operator} ${value}`;
|
: `date ${operator} ${value}`;
|
||||||
break;
|
break;
|
||||||
case 'sensor':
|
case 'sensor':
|
||||||
|
if (value && value.type === 'dynamic') {
|
||||||
|
formatted = `${channel} ${operator} (${value.channel} * ${value.factor} + ${value.offset})`;
|
||||||
|
} else {
|
||||||
|
formatted = `${channel || '?'} ${operator} ${value}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
formatted = `${channel || '?'} ${operator} ${value}`;
|
formatted = `${channel || '?'} ${operator} ${value}`;
|
||||||
break;
|
break;
|
||||||
@@ -547,7 +620,11 @@ class RuleEditor extends Component {
|
|||||||
When: {this.formatConditionSummary(rule.conditions)}
|
When: {this.formatConditionSummary(rule.conditions)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Then: Set {rule.action?.channel} = {rule.action?.value}
|
Then: Set {rule.action?.channel} = {
|
||||||
|
rule.action?.value?.type === 'calculated'
|
||||||
|
? `(${rule.action.value.sensorA} - ${rule.action.value.sensorB || '0'}) * ${rule.action.value.factor} + ${rule.action.value.offset}`
|
||||||
|
: rule.action?.value
|
||||||
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -609,8 +686,29 @@ class RuleEditor extends Component {
|
|||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
<Typography variant="subtitle1" gutterBottom>Action (Then)</Typography>
|
<Typography variant="subtitle1" gutterBottom>Action (Then)</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{/* Value Type Toggle */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">Value Type:</Typography>
|
||||||
|
<Chip
|
||||||
|
label={action.value?.type === 'calculated' ? 'Calculated' : 'Static'}
|
||||||
|
color={action.value?.type === 'calculated' ? 'secondary' : 'default'}
|
||||||
|
onClick={() => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: action.value?.type === 'calculated'
|
||||||
|
? 0 // Reset to static
|
||||||
|
: { type: 'calculated', sensorA: '', sensorB: '', factor: 1, offset: 0 }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Typography>Set</Typography>
|
<Typography>Set</Typography>
|
||||||
<Select
|
<Select
|
||||||
size="small"
|
size="small"
|
||||||
@@ -624,7 +722,77 @@ class RuleEditor extends Component {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Typography>to</Typography>
|
<Typography>=</Typography>
|
||||||
|
|
||||||
|
{action.value?.type === 'calculated' ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1, border: '1px solid #444', borderRadius: 1 }}>
|
||||||
|
<Typography>(</Typography>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={action.value.sensorA || ''}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, sensorA: e.target.value }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Sensor A</em></MenuItem>
|
||||||
|
{this.state.devices.map(d => `${d.device}:${d.channel}`).map(ch => (
|
||||||
|
<MenuItem key={ch} value={ch}>{ch}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Typography>-</Typography>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={action.value.sensorB || ''}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, sensorB: e.target.value }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Sensor B (0)</em></MenuItem>
|
||||||
|
{this.state.devices.map(d => `${d.device}:${d.channel}`).map(ch => (
|
||||||
|
<MenuItem key={ch} value={ch}>{ch}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Typography>)</Typography>
|
||||||
|
<Typography>*</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Factor"
|
||||||
|
value={action.value.factor}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, factor: parseFloat(e.target.value) || 0 }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
sx={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
<Typography>+</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Offset"
|
||||||
|
value={action.value.offset}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, offset: parseFloat(e.target.value) || 0 }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
sx={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -636,6 +804,8 @@ class RuleEditor extends Component {
|
|||||||
}}
|
}}
|
||||||
sx={{ width: 100 }}
|
sx={{ width: 100 }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class ViewManager extends Component {
|
|||||||
this.state = {
|
this.state = {
|
||||||
views: [],
|
views: [],
|
||||||
rules: [],
|
rules: [],
|
||||||
|
activeRuleIds: [],
|
||||||
outputValues: {},
|
outputValues: {},
|
||||||
open: false,
|
open: false,
|
||||||
colorPickerOpen: false,
|
colorPickerOpen: false,
|
||||||
@@ -77,7 +78,8 @@ class ViewManager extends Component {
|
|||||||
this.rulesInterval = setInterval(() => {
|
this.rulesInterval = setInterval(() => {
|
||||||
this.loadRules();
|
this.loadRules();
|
||||||
this.loadOutputValues();
|
this.loadOutputValues();
|
||||||
}, 30000);
|
this.loadRuleStatus();
|
||||||
|
}, 5000);
|
||||||
if (this.isAdmin()) {
|
if (this.isAdmin()) {
|
||||||
fetch('/api/devices')
|
fetch('/api/devices')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -128,6 +130,13 @@ class ViewManager extends Component {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
loadRuleStatus = () => {
|
||||||
|
fetch('/api/rules/status')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => this.setState({ activeRuleIds: data.activeIds || [] }))
|
||||||
|
.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: '' } };
|
||||||
@@ -197,7 +206,10 @@ class ViewManager extends Component {
|
|||||||
}
|
}
|
||||||
return `📅 ${operator} ${value}`;
|
return `📅 ${operator} ${value}`;
|
||||||
case 'sensor':
|
case 'sensor':
|
||||||
// Show device:channel for clarity (e.g. "ac:controller:humidity")
|
// Show device:channel for clarity
|
||||||
|
if (value && value.type === 'dynamic') {
|
||||||
|
return `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
|
||||||
|
}
|
||||||
return `📡 ${channel} ${op} ${value}`;
|
return `📡 ${channel} ${op} ${value}`;
|
||||||
case 'output':
|
case 'output':
|
||||||
return `⚙️ ${channel} ${op} ${value}`;
|
return `⚙️ ${channel} ${op} ${value}`;
|
||||||
@@ -216,6 +228,11 @@ class ViewManager extends Component {
|
|||||||
'TentExhaust': '💨 Tent Exhaust Fan'
|
'TentExhaust': '💨 Tent Exhaust Fan'
|
||||||
};
|
};
|
||||||
const name = channelNames[action.channel] || action.channel;
|
const name = channelNames[action.channel] || action.channel;
|
||||||
|
|
||||||
|
if (action.value && action.value.type === 'calculated') {
|
||||||
|
return `${name} = (${action.value.sensorA} - ${action.value.sensorB || '0'}) * ${action.value.factor} + ${action.value.offset}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `${name} = ${action.value}`;
|
return `${name} = ${action.value}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -508,14 +525,16 @@ class ViewManager extends Component {
|
|||||||
<Paper sx={{ p: 2, mt: 4 }}>
|
<Paper sx={{ p: 2, mt: 4 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>🤖 Active Rules</Typography>
|
<Typography variant="h5" sx={{ mb: 2 }}>🤖 Active Rules</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{this.state.rules.filter(r => r.enabled).map((rule, idx) => (
|
{this.state.rules.filter(r => r.enabled).map((rule, idx) => {
|
||||||
|
const isActive = this.state.activeRuleIds.includes(rule.id);
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
bgcolor: 'background.paper',
|
bgcolor: isActive ? 'rgba(76, 175, 80, 0.15)' : 'background.paper',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
border: '1px solid #504945',
|
border: isActive ? '1px solid #4caf50' : '1px solid #504945',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 2
|
gap: 2
|
||||||
@@ -536,7 +555,8 @@ class ViewManager extends Component {
|
|||||||
#{idx + 1}
|
#{idx + 1}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, textAlign: 'center' }}>
|
<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'}
|
📊 Current outputs: {Object.entries(this.state.outputValues).filter(([k, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(', ') || 'all off'}
|
||||||
|
|||||||
@@ -470,7 +470,14 @@ function evaluateCondition(condition) {
|
|||||||
|
|
||||||
case 'sensor': {
|
case 'sensor': {
|
||||||
const sensorValue = getSensorValue(channel);
|
const sensorValue = getSensorValue(channel);
|
||||||
return compareValues(sensorValue, operator, value);
|
let target = value;
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && value.type === 'dynamic') {
|
||||||
|
const targetSensorVal = getSensorValue(value.channel) || 0;
|
||||||
|
target = (targetSensorVal * (value.factor || 1)) + (value.offset || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareValues(sensorValue, operator, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'output': {
|
case 'output': {
|
||||||
@@ -484,6 +491,9 @@ function evaluateCondition(condition) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global set to track currently active rule IDs
|
||||||
|
const activeRuleIds = new Set();
|
||||||
|
|
||||||
// Run all rules
|
// Run all rules
|
||||||
function runRules() {
|
function runRules() {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
@@ -491,6 +501,9 @@ function runRules() {
|
|||||||
try {
|
try {
|
||||||
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
|
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
|
||||||
|
|
||||||
|
// Clear active rules list at start of run
|
||||||
|
activeRuleIds.clear();
|
||||||
|
|
||||||
// Default all outputs to OFF (0) - if no rule sets them, they stay off
|
// Default all outputs to OFF (0) - if no rule sets them, they stay off
|
||||||
const desiredOutputs = {};
|
const desiredOutputs = {};
|
||||||
for (const ch of OUTPUT_CHANNELS) {
|
for (const ch of OUTPUT_CHANNELS) {
|
||||||
@@ -503,9 +516,22 @@ function runRules() {
|
|||||||
const action = JSON.parse(rule.action || '{}');
|
const action = JSON.parse(rule.action || '{}');
|
||||||
|
|
||||||
if (evaluateCondition(conditions)) {
|
if (evaluateCondition(conditions)) {
|
||||||
|
// Rule matches - add to active list
|
||||||
|
activeRuleIds.add(rule.id);
|
||||||
|
|
||||||
// Rule matches - set output (later rules override)
|
// Rule matches - set output (later rules override)
|
||||||
if (action.channel && action.value !== undefined) {
|
if (action.channel && action.value !== undefined) {
|
||||||
desiredOutputs[action.channel] = action.value;
|
let finalValue = action.value;
|
||||||
|
|
||||||
|
// Handle calculated value
|
||||||
|
if (action.value && typeof action.value === 'object' && action.value.type === 'calculated') {
|
||||||
|
const valA = getSensorValue(action.value.sensorA) || 0;
|
||||||
|
const valB = action.value.sensorB ? (getSensorValue(action.value.sensorB) || 0) : 0;
|
||||||
|
const diff = valA - valB;
|
||||||
|
finalValue = (diff * (action.value.factor || 1)) + (action.value.offset || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
desiredOutputs[action.channel] = finalValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -801,6 +827,11 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/rules/status - Get currently active rule IDs
|
||||||
|
app.get('/api/rules/status', (req, res) => {
|
||||||
|
res.json({ activeIds: Array.from(activeRuleIds) });
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/rules - List all rules
|
// GET /api/rules - List all rules
|
||||||
app.get('/api/rules', (req, res) => {
|
app.get('/api/rules', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -818,6 +849,8 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// POST /api/rules - Create rule (admin only)
|
// POST /api/rules - Create rule (admin only)
|
||||||
app.post('/api/rules', requireAdmin, (req, res) => {
|
app.post('/api/rules', requireAdmin, (req, res) => {
|
||||||
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
|
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
|
||||||
|
|||||||
Reference in New Issue
Block a user