Add protocol module, video-node binary, query/web CLI tools
- Protocol module: framed binary encoding for control requests/responses (ENUM_DEVICES, ENUM_CONTROLS, GET/SET_CONTROL, STREAM_OPEN/CLOSE) - video-node: scans /dev/media* and /dev/video*, serves V4L2 device topology and controls over TCP; uses UDP discovery for peer announce - query_cli: auto-discovers a node, queries devices and controls - protocol_cli: low-level protocol frame decoder for debugging - dev/web: Express 5 ESM web inspector — live SSE discovery picker, REST bridge to video-node, controls UI with sliders/selects/checkboxes - Makefile: sequential module builds before cli/node to fix make -j races - common.mk: add DEPFLAGS (-MMD -MP) for automatic header dependencies - All module Makefiles: split compile/link, generate .d dependency files - discovery: replace 100ms poll loop with pthread_cond_timedwait; respond to all announcements (not just new peers) for instant re-discovery - ENUM_DEVICES response: carry device_caps (V4L2_CAP_*) per video node so clients can distinguish capture nodes from metadata nodes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
174
dev/web/server.mjs
Normal file
174
dev/web/server.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Express 5 web server — REST bridge to video-node.
|
||||
*
|
||||
* Usage:
|
||||
* node server.mjs [--host IP] [--port PORT] [--discover] [--listen PORT]
|
||||
*
|
||||
* If --host and --port are given, connects on startup.
|
||||
* If --discover is given, runs UDP discovery to find the node automatically.
|
||||
* Otherwise, call POST /api/connect to connect at runtime.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { Node_Client } from './node_client.mjs';
|
||||
import { start_discovery } from './discovery.mjs';
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Argument parsing
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const arg = name => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
const opt_host = arg('--host');
|
||||
const opt_port = arg('--port') ? parseInt(arg('--port')) : null;
|
||||
const opt_discover = args.includes('--discover');
|
||||
const listen_port = arg('--listen') ? parseInt(arg('--listen')) : 3000;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Node client (singleton)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const client = new Node_Client();
|
||||
|
||||
client.on('disconnect', () => {
|
||||
console.log('video-node disconnected');
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Express app
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
app.use(express.static(path.join(__dir, 'public')));
|
||||
|
||||
/* -- Status ---------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/status', (_req, res) => {
|
||||
res.json({
|
||||
connected: client.connected,
|
||||
host: client.host,
|
||||
port: client.port,
|
||||
});
|
||||
});
|
||||
|
||||
/* -- Connect --------------------------------------------------------------- */
|
||||
|
||||
app.post('/api/connect', async (req, res) => {
|
||||
const { host, port } = req.body ?? {};
|
||||
if (!host || !port) {
|
||||
return res.status(400).json({ error: 'host and port required' });
|
||||
}
|
||||
try {
|
||||
await client.connect(host, parseInt(port));
|
||||
res.json({ ok: true, host: client.host, port: client.port });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* SSE stream: each discovered peer arrives as a JSON event.
|
||||
* The client closes the connection when done (user picked a node or dismissed).
|
||||
*/
|
||||
app.get('/api/discover', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const stop = start_discovery(peer => {
|
||||
res.write(`data: ${JSON.stringify(peer)}\n\n`);
|
||||
});
|
||||
|
||||
req.on('close', stop);
|
||||
});
|
||||
|
||||
app.post('/api/disconnect', (_req, res) => {
|
||||
client.disconnect();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* -- Devices --------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/devices', async (_req, res) => {
|
||||
try {
|
||||
const result = await client.enum_devices();
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* -- Controls -------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/devices/:idx/controls', async (req, res) => {
|
||||
const idx = parseInt(req.params.idx);
|
||||
if (isNaN(idx)) { return res.status(400).json({ error: 'invalid device index' }); }
|
||||
try {
|
||||
const result = await client.enum_controls(idx);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/devices/:idx/controls/:ctrl_id', async (req, res) => {
|
||||
const idx = parseInt(req.params.idx);
|
||||
const ctrl_id = parseInt(req.params.ctrl_id, 16) || parseInt(req.params.ctrl_id);
|
||||
if (isNaN(idx) || isNaN(ctrl_id)) {
|
||||
return res.status(400).json({ error: 'invalid params' });
|
||||
}
|
||||
try {
|
||||
const result = await client.get_control(idx, ctrl_id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/devices/:idx/controls/:ctrl_id', async (req, res) => {
|
||||
const idx = parseInt(req.params.idx);
|
||||
const ctrl_id = parseInt(req.params.ctrl_id, 16) || parseInt(req.params.ctrl_id);
|
||||
const value = req.body?.value;
|
||||
if (isNaN(idx) || isNaN(ctrl_id) || value === undefined) {
|
||||
return res.status(400).json({ error: 'invalid params' });
|
||||
}
|
||||
try {
|
||||
const result = await client.set_control(idx, ctrl_id, parseInt(value));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Startup
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
app.listen(listen_port, () => {
|
||||
console.log(`listening on http://localhost:${listen_port}`);
|
||||
});
|
||||
|
||||
if (opt_discover) {
|
||||
console.log('discovering video-nodes...');
|
||||
const stop = start_discovery(peer => {
|
||||
console.log(`found ${peer.name} at ${peer.addr}:${peer.tcp_port} — connecting`);
|
||||
stop();
|
||||
client.connect(peer.addr, peer.tcp_port)
|
||||
.then(() => console.log('connected'))
|
||||
.catch(err => console.error('connect failed:', err.message));
|
||||
});
|
||||
} else if (opt_host && opt_port) {
|
||||
client.connect(opt_host, opt_port)
|
||||
.then(() => console.log(`connected to ${opt_host}:${opt_port}`))
|
||||
.catch(err => console.error('connect failed:', err.message));
|
||||
}
|
||||
Reference in New Issue
Block a user