- protocol.mjs: all reads/writes switched to LE to match serial.h - node_client.mjs: persistent error handler prevents ECONNRESET crash - discovery.mjs: remove unnecessary SO_REUSEPORT - server.mjs: discovery runs at startup (not per SSE open); uses EventEmitter + known_peers Map so SSE replays existing peers on connect Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
194 lines
6.1 KiB
JavaScript
194 lines
6.1 KiB
JavaScript
/*
|
|
* Express 5 web server — REST bridge to video-node.
|
|
*
|
|
* Usage:
|
|
* node server.mjs [--host IP] [--port PORT] [--listen PORT]
|
|
*
|
|
* Discovery runs at startup unconditionally — the server joins the multicast
|
|
* group and sends an announcement immediately so nodes respond right away.
|
|
* The /api/discover SSE endpoint subscribes to the running peer feed.
|
|
*/
|
|
|
|
import express from 'express';
|
|
import { EventEmitter } from 'node:events';
|
|
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 listen_port = arg('--listen') ? parseInt(arg('--listen')) : 3000;
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* Persistent discovery — runs from startup, feeds the SSE endpoint
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
const peer_bus = new EventEmitter();
|
|
const known_peers = new Map(); /* key: addr:name -> peer */
|
|
|
|
start_discovery(
|
|
peer => {
|
|
const key = `${peer.addr}:${peer.name}`;
|
|
known_peers.set(key, peer);
|
|
peer_bus.emit('peer', peer);
|
|
console.log(`discovered: ${peer.name} at ${peer.addr}:${peer.tcp_port}`);
|
|
},
|
|
err => {
|
|
console.error('discovery error:', err.message);
|
|
peer_bus.emit('discovery_error', err);
|
|
},
|
|
);
|
|
|
|
/* -------------------------------------------------------------------------
|
|
* 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 });
|
|
}
|
|
});
|
|
|
|
app.post('/api/disconnect', (_req, res) => {
|
|
client.disconnect();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/*
|
|
* SSE stream — immediately replays already-known peers, then streams new ones
|
|
* as they arrive. Client closes 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 send_peer = peer => res.write(`data: ${JSON.stringify(peer)}\n\n`);
|
|
const send_error = err => res.write(`event: discovery_error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
|
|
|
|
/* replay peers already seen before this SSE was opened */
|
|
for (const peer of known_peers.values()) { send_peer(peer); }
|
|
|
|
peer_bus.on('peer', send_peer);
|
|
peer_bus.on('discovery_error', send_error);
|
|
|
|
req.on('close', () => {
|
|
peer_bus.off('peer', send_peer);
|
|
peer_bus.off('discovery_error', send_error);
|
|
});
|
|
});
|
|
|
|
/* -- 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_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));
|
|
}
|