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' && (
- }>Rules
+ <>
+ }>Rules
+ }>Outputs
+ >
)}
{user && (
}>Settings
@@ -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 && (
+ } onClick={this.handleOpenCreate}>
+ Add Output
+
+ )}
+
+
+
+ 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 */}
+
+
+ );
+ }
+}
+
+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
});