/* * 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)); }