diff --git a/uiserver/api/devices.js b/uiserver/api/devices.js index 2b3254d..1bec552 100644 --- a/uiserver/api/devices.js +++ b/uiserver/api/devices.js @@ -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 })); diff --git a/uiserver/api/index.js b/uiserver/api/index.js index 6980b6f..5b85862 100644 --- a/uiserver/api/index.js +++ b/uiserver/api/index.js @@ -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 }); }; diff --git a/uiserver/api/output-config.js b/uiserver/api/output-config.js new file mode 100644 index 0000000..4849067 --- /dev/null +++ b/uiserver/api/output-config.js @@ -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 }); + } + }); +}; diff --git a/uiserver/api/outputs.js b/uiserver/api/outputs.js index 9baaa05..52d7d65 100644 --- a/uiserver/api/outputs.js +++ b/uiserver/api/outputs.js @@ -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] = { diff --git a/uiserver/src/App.js b/uiserver/src/App.js index 0283b2a..d6e0940 100644 --- a/uiserver/src/App.js +++ b/uiserver/src/App.js @@ -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' && ( - + <> + + + )} {user && ( @@ -123,6 +128,7 @@ export default class App extends Component { } /> } /> } /> + } /> { + 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 Loading...; + if (error) return {error}; + + return ( + + + + + Output Configuration + + {isAdmin && ( + + )} + + + + Output Channels + + {configs.map((config, idx) => ( + + + + {config.channel} + + + {config.device ? ( + } + label={`${config.device}:${config.device_channel}`} + color="success" + variant="outlined" + /> + ) : ( + } + label="unbound" + color="warning" + variant="outlined" + /> + )} + + } + secondary={ + + + {config.description || 'No description'} + + {config.value_type === 'number' && ( + + Range: {config.min_value} - {config.max_value} + + )} + + } + /> + {isAdmin && ( + + this.moveConfig(idx, -1)} disabled={idx === 0}> + + + this.moveConfig(idx, 1)} disabled={idx === configs.length - 1}> + + + this.handleOpenEdit(config, e)}> + + + this.handleDelete(config.id, e)}> + + + + )} + + ))} + {configs.length === 0 && ( + + No output channels defined. {isAdmin ? 'Click "Add Output" to create one.' : ''} + + )} + + + + {/* Edit/Create Dialog */} + this.setState({ open: false })} maxWidth="sm" fullWidth> + {editingId ? 'Edit Output Config' : 'Add Output Config'} + + + this.setState({ channel: e.target.value })} + fullWidth + placeholder="e.g., CircFanLevel" + /> + this.setState({ description: e.target.value })} + fullWidth + placeholder="e.g., Circulation Fan Level" + /> + + Value Type + + + {value_type === 'number' && ( + + this.setState({ min_value: e.target.value })} + sx={{ flex: 1 }} + /> + this.setState({ max_value: e.target.value })} + sx={{ flex: 1 }} + /> + + )} + + Device Binding (Optional) + + + Device + + + this.setState({ device_channel: e.target.value })} + sx={{ flex: 1 }} + placeholder={value_type === 'number' ? 'e.g., tent:fan' : 'e.g., r0, c'} + disabled={!device} + /> + + {device && ( + + Binding type: {device === 'ac' ? 'Level (0-10)' : 'Switch (on/off)'} + + )} + + + + + + + + + ); + } +} + +export default OutputConfigEditor; diff --git a/uiserver/webpack.config.js b/uiserver/webpack.config.js index fa54149..8af382a 100644 --- a/uiserver/webpack.config.js +++ b/uiserver/webpack.config.js @@ -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 });