u
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Devices API - List unique device/channel pairs
|
||||
*/
|
||||
|
||||
module.exports = function setupDevicesApi(app, { db, OUTPUT_CHANNELS }) {
|
||||
module.exports = function setupDevicesApi(app, { db, getOutputChannels }) {
|
||||
// GET /api/devices - Returns list of unique device/channel pairs (sensors + outputs)
|
||||
app.get('/api/devices', (req, res) => {
|
||||
try {
|
||||
@@ -12,7 +12,8 @@ module.exports = function setupDevicesApi(app, { db, OUTPUT_CHANNELS }) {
|
||||
const sensorRows = sensorStmt.all();
|
||||
|
||||
// Add output channels with 'output' as device
|
||||
const outputRows = OUTPUT_CHANNELS.map(ch => ({
|
||||
const outputChannels = getOutputChannels();
|
||||
const outputRows = outputChannels.map(ch => ({
|
||||
device: 'output',
|
||||
channel: ch.channel
|
||||
}));
|
||||
|
||||
@@ -6,11 +6,12 @@ const setupAuthApi = require('./auth');
|
||||
const setupViewsApi = require('./views');
|
||||
const setupRulesApi = require('./rules');
|
||||
const setupOutputsApi = require('./outputs');
|
||||
const setupOutputConfigApi = require('./output-config');
|
||||
const setupDevicesApi = require('./devices');
|
||||
const setupReadingsApi = require('./readings');
|
||||
|
||||
module.exports = function setupAllApis(app, context) {
|
||||
const { db, bcrypt, jwt, JWT_SECRET, OUTPUT_CHANNELS, OUTPUT_BINDINGS, runRules, activeRuleIds } = context;
|
||||
const { db, bcrypt, jwt, JWT_SECRET, getOutputChannels, getOutputBindings, runRules, activeRuleIds } = context;
|
||||
|
||||
// Auth middleware helpers
|
||||
const checkAuth = (req, res, next) => {
|
||||
@@ -37,7 +38,8 @@ module.exports = function setupAllApis(app, context) {
|
||||
setupAuthApi(app, { db, bcrypt, jwt, JWT_SECRET });
|
||||
setupViewsApi(app, { db, checkAuth, requireAdmin });
|
||||
setupRulesApi(app, { db, checkAuth, requireAdmin, runRules, activeRuleIds });
|
||||
setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BINDINGS });
|
||||
setupDevicesApi(app, { db, OUTPUT_CHANNELS });
|
||||
setupOutputConfigApi(app, { db, checkAuth, requireAdmin });
|
||||
setupOutputsApi(app, { db, getOutputChannels, getOutputBindings });
|
||||
setupDevicesApi(app, { db, getOutputChannels });
|
||||
setupReadingsApi(app, { db });
|
||||
};
|
||||
|
||||
162
uiserver/api/output-config.js
Normal file
162
uiserver/api/output-config.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Output Config API - CRUD for output channel configurations
|
||||
*/
|
||||
|
||||
module.exports = function setupOutputConfigApi(app, { db, checkAuth, requireAdmin }) {
|
||||
// Apply checkAuth middleware to output config routes
|
||||
app.use('/api/output-configs', checkAuth);
|
||||
|
||||
// GET /api/output-configs - List all output configs
|
||||
app.get('/api/output-configs', (req, res) => {
|
||||
try {
|
||||
if (!db) throw new Error('Database not connected');
|
||||
const stmt = db.prepare('SELECT * FROM output_configs ORDER BY position ASC');
|
||||
const rows = stmt.all();
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/output-configs - Create new output config (admin only)
|
||||
app.post('/api/output-configs', requireAdmin, (req, res) => {
|
||||
const { channel, description, value_type, min_value, max_value, device, device_channel } = req.body;
|
||||
|
||||
if (!channel || !value_type) {
|
||||
return res.status(400).json({ error: 'Missing required fields: channel, value_type' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get max position
|
||||
const maxPos = db.prepare('SELECT MAX(position) as max FROM output_configs').get();
|
||||
const position = (maxPos.max ?? -1) + 1;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO output_configs (channel, description, value_type, min_value, max_value, device, device_channel, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const info = stmt.run(
|
||||
channel,
|
||||
description || '',
|
||||
value_type,
|
||||
min_value ?? 0,
|
||||
max_value ?? 1,
|
||||
device || null,
|
||||
device_channel || null,
|
||||
position
|
||||
);
|
||||
|
||||
global.insertChangelog(req.user?.username || 'admin', `Created output config "${channel}"`);
|
||||
|
||||
res.json({
|
||||
id: info.lastInsertRowid,
|
||||
channel,
|
||||
description,
|
||||
value_type,
|
||||
min_value: min_value ?? 0,
|
||||
max_value: max_value ?? 1,
|
||||
device,
|
||||
device_channel,
|
||||
position
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message.includes('UNIQUE constraint')) {
|
||||
return res.status(400).json({ error: 'Channel name already exists' });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/output-configs/:id - Update output config (admin only)
|
||||
app.put('/api/output-configs/:id', requireAdmin, (req, res) => {
|
||||
const { channel, description, value_type, min_value, max_value, device, device_channel } = req.body;
|
||||
|
||||
try {
|
||||
const oldConfig = db.prepare('SELECT * FROM output_configs WHERE id = ?').get(req.params.id);
|
||||
if (!oldConfig) {
|
||||
return res.status(404).json({ error: 'Output config not found' });
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE output_configs
|
||||
SET channel = ?, description = ?, value_type = ?, min_value = ?, max_value = ?, device = ?, device_channel = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
const info = stmt.run(
|
||||
channel ?? oldConfig.channel,
|
||||
description ?? oldConfig.description,
|
||||
value_type ?? oldConfig.value_type,
|
||||
min_value ?? oldConfig.min_value,
|
||||
max_value ?? oldConfig.max_value,
|
||||
device ?? oldConfig.device,
|
||||
device_channel ?? oldConfig.device_channel,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
if (info.changes > 0) {
|
||||
const changes = [];
|
||||
if (oldConfig.channel !== channel) changes.push(`channel: ${oldConfig.channel} → ${channel}`);
|
||||
if (oldConfig.device !== device) changes.push(`device: ${oldConfig.device || 'none'} → ${device || 'none'}`);
|
||||
if (oldConfig.device_channel !== device_channel) changes.push(`device_channel: ${oldConfig.device_channel || 'none'} → ${device_channel || 'none'}`);
|
||||
|
||||
const changeText = changes.length > 0
|
||||
? `Updated output config "${channel}": ${changes.join(', ')}`
|
||||
: `Updated output config "${channel}"`;
|
||||
global.insertChangelog(req.user?.username || 'admin', changeText);
|
||||
|
||||
res.json({ success: true, id: req.params.id });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Output config not found' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message.includes('UNIQUE constraint')) {
|
||||
return res.status(400).json({ error: 'Channel name already exists' });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/output-configs/:id - Delete output config (admin only)
|
||||
app.delete('/api/output-configs/:id', requireAdmin, (req, res) => {
|
||||
try {
|
||||
const config = db.prepare('SELECT channel FROM output_configs WHERE id = ?').get(req.params.id);
|
||||
if (!config) {
|
||||
return res.status(404).json({ error: 'Output config not found' });
|
||||
}
|
||||
|
||||
const stmt = db.prepare('DELETE FROM output_configs WHERE id = ?');
|
||||
const info = stmt.run(req.params.id);
|
||||
|
||||
if (info.changes > 0) {
|
||||
global.insertChangelog(req.user?.username || 'admin', `Deleted output config "${config.channel}"`);
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Output config not found' });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/output-configs/reorder - Reorder output configs (admin only)
|
||||
app.post('/api/output-configs/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 output_configs 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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2,10 +2,10 @@
|
||||
* Outputs API - Output channel definitions and values
|
||||
*/
|
||||
|
||||
module.exports = function setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BINDINGS }) {
|
||||
module.exports = function setupOutputsApi(app, { db, getOutputChannels, getOutputBindings }) {
|
||||
// GET /api/outputs - List output channel definitions
|
||||
app.get('/api/outputs', (req, res) => {
|
||||
res.json(OUTPUT_CHANNELS);
|
||||
res.json(getOutputChannels());
|
||||
});
|
||||
|
||||
// GET /api/outputs/values - Get current output values
|
||||
@@ -24,7 +24,8 @@ module.exports = function setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BIN
|
||||
result[row.channel] = row.value;
|
||||
});
|
||||
// Fill in defaults for missing channels
|
||||
OUTPUT_CHANNELS.forEach(ch => {
|
||||
const outputChannels = getOutputChannels();
|
||||
outputChannels.forEach(ch => {
|
||||
if (result[ch.channel] === undefined) {
|
||||
result[ch.channel] = 0;
|
||||
}
|
||||
@@ -55,8 +56,9 @@ module.exports = function setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BIN
|
||||
});
|
||||
|
||||
// Map to device commands
|
||||
const bindings = getOutputBindings();
|
||||
const commands = {};
|
||||
for (const [outputChannel, binding] of Object.entries(OUTPUT_BINDINGS)) {
|
||||
for (const [outputChannel, binding] of Object.entries(bindings)) {
|
||||
const value = outputValues[outputChannel] ?? 0;
|
||||
const deviceKey = `${binding.device}:${binding.channel}`;
|
||||
commands[deviceKey] = {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
|
||||
|
||||
import Settings from './components/Settings';
|
||||
import Chart from './components/Chart';
|
||||
@@ -13,6 +14,7 @@ import Login from './components/Login';
|
||||
import ViewManager from './components/ViewManager';
|
||||
import ViewDisplay from './components/ViewDisplay';
|
||||
import RuleEditor from './components/RuleEditor';
|
||||
import OutputConfigEditor from './components/OutputConfigEditor';
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
@@ -105,7 +107,10 @@ export default class App extends Component {
|
||||
</>
|
||||
)}
|
||||
{user && user.role === 'admin' && (
|
||||
<>
|
||||
<Button color="inherit" component={Link} to="/rules" startIcon={<RuleIcon />}>Rules</Button>
|
||||
<Button color="inherit" component={Link} to="/outputs" startIcon={<SettingsInputComponentIcon />}>Outputs</Button>
|
||||
</>
|
||||
)}
|
||||
{user && (
|
||||
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
|
||||
@@ -123,6 +128,7 @@ export default class App extends Component {
|
||||
<Route path="/" element={<ViewManager user={user} />} />
|
||||
<Route path="/views/:id" element={<ViewDisplay />} />
|
||||
<Route path="/rules" element={<RuleEditor user={user} />} />
|
||||
<Route path="/outputs" element={<OutputConfigEditor user={user} />} />
|
||||
<Route path="/live" element={
|
||||
<Chart
|
||||
selectedChannels={selectedChannels}
|
||||
|
||||
371
uiserver/src/components/OutputConfigEditor.js
Normal file
371
uiserver/src/components/OutputConfigEditor.js
Normal file
@@ -0,0 +1,371 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Container, Typography, Paper, List, ListItem, ListItemText,
|
||||
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
FormControl, InputLabel, Select, MenuItem, Box, IconButton,
|
||||
Chip, Switch, FormControlLabel
|
||||
} from '@mui/material';
|
||||
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
|
||||
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 LinkIcon from '@mui/icons-material/Link';
|
||||
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
||||
|
||||
class OutputConfigEditor extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
configs: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// Dialog state
|
||||
open: false,
|
||||
editingId: null,
|
||||
channel: '',
|
||||
description: '',
|
||||
value_type: 'boolean',
|
||||
min_value: 0,
|
||||
max_value: 1,
|
||||
device: '',
|
||||
device_channel: ''
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadConfigs();
|
||||
}
|
||||
|
||||
isAdmin() {
|
||||
const { user } = this.props;
|
||||
return user && user.role === 'admin';
|
||||
}
|
||||
|
||||
loadConfigs = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/output-configs');
|
||||
const configs = await res.json();
|
||||
this.setState({ configs, loading: false });
|
||||
} catch (err) {
|
||||
this.setState({ error: err.message, loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleOpenCreate = () => {
|
||||
this.setState({
|
||||
open: true,
|
||||
editingId: null,
|
||||
channel: '',
|
||||
description: '',
|
||||
value_type: 'boolean',
|
||||
min_value: 0,
|
||||
max_value: 1,
|
||||
device: '',
|
||||
device_channel: ''
|
||||
});
|
||||
};
|
||||
|
||||
handleOpenEdit = (config, e) => {
|
||||
e.stopPropagation();
|
||||
this.setState({
|
||||
open: true,
|
||||
editingId: config.id,
|
||||
channel: config.channel,
|
||||
description: config.description || '',
|
||||
value_type: config.value_type,
|
||||
min_value: config.min_value,
|
||||
max_value: config.max_value,
|
||||
device: config.device || '',
|
||||
device_channel: config.device_channel || ''
|
||||
});
|
||||
};
|
||||
|
||||
handleSave = async () => {
|
||||
const { editingId, channel, description, value_type, min_value, max_value, device, device_channel } = this.state;
|
||||
const { user } = this.props;
|
||||
|
||||
if (!channel) {
|
||||
alert('Channel name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = editingId ? `/api/output-configs/${editingId}` : '/api/output-configs';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${user.token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel,
|
||||
description,
|
||||
value_type,
|
||||
min_value: parseFloat(min_value),
|
||||
max_value: parseFloat(max_value),
|
||||
device: device || null,
|
||||
device_channel: device_channel || null
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
this.setState({ open: false });
|
||||
this.loadConfigs();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert('Failed: ' + err.error);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
handleDelete = async (id, e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('Delete this output config?')) return;
|
||||
|
||||
const { user } = this.props;
|
||||
try {
|
||||
await fetch(`/api/output-configs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${user.token}` }
|
||||
});
|
||||
this.loadConfigs();
|
||||
} catch (err) {
|
||||
alert('Failed to delete: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
moveConfig = async (idx, dir) => {
|
||||
const newConfigs = [...this.state.configs];
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= newConfigs.length) return;
|
||||
|
||||
[newConfigs[idx], newConfigs[target]] = [newConfigs[target], newConfigs[idx]];
|
||||
this.setState({ configs: newConfigs });
|
||||
|
||||
const order = newConfigs.map((c, i) => ({ id: c.id, position: i }));
|
||||
const { user } = this.props;
|
||||
|
||||
try {
|
||||
await fetch('/api/output-configs/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);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { configs, loading, error, open, editingId, channel, description, value_type, min_value, max_value, device, device_channel } = this.state;
|
||||
const isAdmin = this.isAdmin();
|
||||
|
||||
if (loading) return <Container sx={{ mt: 4 }}><Typography>Loading...</Typography></Container>;
|
||||
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Paper sx={{ p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h5">
|
||||
<SettingsInputComponentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Output Configuration
|
||||
</Typography>
|
||||
{isAdmin && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
|
||||
Add Output
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Output Channels</Typography>
|
||||
<List>
|
||||
{configs.map((config, idx) => (
|
||||
<ListItem
|
||||
key={config.id}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
border: '1px solid #504945',
|
||||
bgcolor: config.device ? 'rgba(131, 165, 152, 0.1)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{config.channel}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={config.value_type}
|
||||
color={config.value_type === 'boolean' ? 'default' : 'info'}
|
||||
/>
|
||||
{config.device ? (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<LinkIcon />}
|
||||
label={`${config.device}:${config.device_channel}`}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<LinkOffIcon />}
|
||||
label="unbound"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{config.description || 'No description'}
|
||||
</Typography>
|
||||
{config.value_type === 'number' && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Range: {config.min_value} - {config.max_value}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton size="small" onClick={() => this.moveConfig(idx, -1)} disabled={idx === 0}>
|
||||
<ArrowUpwardIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => this.moveConfig(idx, 1)} disabled={idx === configs.length - 1}>
|
||||
<ArrowDownwardIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={(e) => this.handleOpenEdit(config, e)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton color="error" onClick={(e) => this.handleDelete(config.id, e)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
{configs.length === 0 && (
|
||||
<Typography color="text.secondary" sx={{ p: 2 }}>
|
||||
No output channels defined. {isAdmin ? 'Click "Add Output" to create one.' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
{/* Edit/Create Dialog */}
|
||||
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editingId ? 'Edit Output Config' : 'Add Output Config'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Channel Name"
|
||||
value={channel}
|
||||
onChange={e => this.setState({ channel: e.target.value })}
|
||||
fullWidth
|
||||
placeholder="e.g., CircFanLevel"
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={e => this.setState({ description: e.target.value })}
|
||||
fullWidth
|
||||
placeholder="e.g., Circulation Fan Level"
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Value Type</InputLabel>
|
||||
<Select
|
||||
value={value_type}
|
||||
label="Value Type"
|
||||
onChange={e => {
|
||||
const newType = e.target.value;
|
||||
// Auto-select compatible device: number->ac, boolean->tapo
|
||||
const newDevice = device ? (newType === 'number' ? 'ac' : 'tapo') : '';
|
||||
this.setState({
|
||||
value_type: newType,
|
||||
min_value: 0,
|
||||
max_value: newType === 'boolean' ? 1 : 10,
|
||||
device: newDevice
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MenuItem value="boolean">Boolean (on/off)</MenuItem>
|
||||
<MenuItem value="number">Number (0-10 range)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{value_type === 'number' && (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Min Value"
|
||||
type="number"
|
||||
value={min_value}
|
||||
onChange={e => this.setState({ min_value: e.target.value })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Max Value"
|
||||
type="number"
|
||||
value={max_value}
|
||||
onChange={e => this.setState({ max_value: e.target.value })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 2 }}>Device Binding (Optional)</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Device</InputLabel>
|
||||
<Select
|
||||
value={device}
|
||||
label="Device"
|
||||
onChange={e => this.setState({ device: e.target.value })}
|
||||
>
|
||||
<MenuItem value=""><em>Not bound</em></MenuItem>
|
||||
{value_type === 'boolean' && <MenuItem value="tapo">tapo (Switch)</MenuItem>}
|
||||
{value_type === 'number' && <MenuItem value="ac">ac (Level)</MenuItem>}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Device Channel"
|
||||
value={device_channel}
|
||||
onChange={e => this.setState({ device_channel: e.target.value })}
|
||||
sx={{ flex: 1 }}
|
||||
placeholder={value_type === 'number' ? 'e.g., tent:fan' : 'e.g., r0, c'}
|
||||
disabled={!device}
|
||||
/>
|
||||
</Box>
|
||||
{device && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Binding type: {device === 'ac' ? 'Level (0-10)' : 'Switch (on/off)'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={this.handleSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OutputConfigEditor;
|
||||
@@ -31,6 +31,22 @@ try {
|
||||
)
|
||||
`);
|
||||
|
||||
// Create output_configs table (unified channels + bindings)
|
||||
// Note: binding_type derived from device (ac=level, tapo=switch)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS output_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
value_type TEXT NOT NULL,
|
||||
min_value REAL DEFAULT 0,
|
||||
max_value REAL DEFAULT 1,
|
||||
device TEXT,
|
||||
device_channel TEXT,
|
||||
position INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
|
||||
// Helper to insert changelog entry
|
||||
global.insertChangelog = (user, text) => {
|
||||
try {
|
||||
@@ -47,15 +63,36 @@ try {
|
||||
console.error(`[UI Server] Failed to connect to database at ${dbPath}:`, err.message);
|
||||
}
|
||||
|
||||
// Output bindings: map virtual outputs to physical devices
|
||||
// Format: outputChannel -> { device, channel, type }
|
||||
const OUTPUT_BINDINGS = {
|
||||
'BigDehumid': { device: 'tapo', channel: 'r0', type: 'switch' },
|
||||
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' },
|
||||
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' },
|
||||
'CircFanLevel': { device: 'ac', channel: 'tent:fan', type: 'level' },
|
||||
'RoomExhaust': { device: 'ac', channel: 'wall-fan', type: 'level' },
|
||||
// Load output channels from database (replaces hardcoded OUTPUT_CHANNELS)
|
||||
function getOutputChannels() {
|
||||
if (!db) return [];
|
||||
const rows = db.prepare('SELECT * FROM output_configs ORDER BY position ASC').all();
|
||||
return rows.map(r => ({
|
||||
channel: r.channel,
|
||||
type: r.value_type,
|
||||
min: r.min_value,
|
||||
max: r.max_value,
|
||||
description: r.description
|
||||
}));
|
||||
}
|
||||
|
||||
// Load output bindings from database (replaces hardcoded OUTPUT_BINDINGS)
|
||||
// Binding type derived: ac=level, tapo=switch
|
||||
function getOutputBindings() {
|
||||
if (!db) return {};
|
||||
const rows = db.prepare('SELECT * FROM output_configs WHERE device IS NOT NULL').all();
|
||||
const bindings = {};
|
||||
for (const r of rows) {
|
||||
if (r.device && r.device_channel) {
|
||||
bindings[r.channel] = {
|
||||
device: r.device,
|
||||
channel: r.device_channel,
|
||||
type: r.device === 'ac' ? 'level' : 'switch'
|
||||
};
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// WebSocket Server for Agents (port 3962)
|
||||
@@ -315,6 +352,7 @@ function syncOutputStates() {
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const bindings = getOutputBindings();
|
||||
// Get current output values
|
||||
const stmt = db.prepare(`
|
||||
SELECT channel, value FROM output_events
|
||||
@@ -325,7 +363,7 @@ function syncOutputStates() {
|
||||
for (const row of rows) {
|
||||
// Only sync non-zero values
|
||||
if (row.value > 0) {
|
||||
const binding = OUTPUT_BINDINGS[row.channel];
|
||||
const binding = bindings[row.channel];
|
||||
if (binding) {
|
||||
let commandValue = row.value;
|
||||
if (binding.type === 'switch') {
|
||||
@@ -356,15 +394,6 @@ setInterval(syncOutputStates, 60000);
|
||||
// RULE ENGINE (Global Scope)
|
||||
// =============================================
|
||||
|
||||
// Virtual output channel definitions
|
||||
const OUTPUT_CHANNELS = [
|
||||
{ channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation 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: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' },
|
||||
{ channel: 'RoomExhaust', type: 'number', min: 0, max: 10, description: 'Room Exhaust Fan' },
|
||||
];
|
||||
|
||||
// Get current sensor value
|
||||
function getSensorValue(channel) {
|
||||
// channel format: "device:channel" e.g. "ac:controller:co2"
|
||||
@@ -420,7 +449,8 @@ function writeOutputValue(channel, value) {
|
||||
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
|
||||
|
||||
// Send command to bound physical device
|
||||
const binding = OUTPUT_BINDINGS[channel];
|
||||
const bindings = getOutputBindings();
|
||||
const binding = bindings[channel];
|
||||
if (binding) {
|
||||
let commandValue = value;
|
||||
if (binding.type === 'switch') {
|
||||
@@ -533,7 +563,8 @@ function runRules() {
|
||||
|
||||
// Default all outputs to OFF (0) - if no rule sets them, they stay off
|
||||
const desiredOutputs = {};
|
||||
for (const ch of OUTPUT_CHANNELS) {
|
||||
const outputChannels = getOutputChannels();
|
||||
for (const ch of outputChannels) {
|
||||
desiredOutputs[ch.channel] = 0;
|
||||
}
|
||||
|
||||
@@ -651,8 +682,8 @@ module.exports = {
|
||||
bcrypt,
|
||||
jwt,
|
||||
JWT_SECRET,
|
||||
OUTPUT_CHANNELS,
|
||||
OUTPUT_BINDINGS,
|
||||
getOutputChannels,
|
||||
getOutputBindings,
|
||||
runRules,
|
||||
activeRuleIds
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user