#!/usr/bin/env node /** * DreamStack Relay Bridge — UDP ↔ WebSocket * * Bridges the hub's UDP binary frames to the browser previewer * via WebSocket. This lets you test the full signal pipeline * without ESP32 hardware. * * Architecture: * ds-hub (Rust) → UDP:9200 → [this relay] → WS:9201 → browser previewer * browser → WS:9201 → [this relay] → UDP:9200 → ds-hub * * Usage: * node relay-bridge.js * Then open previewer with: ?ws=ws://localhost:9201 */ const dgram = require('dgram'); const { WebSocketServer } = require('ws'); const http = require('http'); const fs = require('fs'); const path = require('path'); const UDP_PORT = 9200; const WS_PORT = 9201; const HTTP_PORT = 9876; // Serve previewer HTML too // ─── WebSocket Server ─── const wss = new WebSocketServer({ host: '0.0.0.0', port: WS_PORT }); const clients = new Set(); wss.on('connection', (ws, req) => { clients.add(ws); console.log(`[WS] Client connected (${clients.size} total)`); ws.on('message', (data) => { // Forward binary messages from browser → UDP (hub) if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) { const buf = Buffer.from(data); udp.send(buf, 0, buf.length, UDP_PORT, '127.0.0.1', (err) => { if (err) console.error('[UDP] Send error:', err); }); } }); ws.on('close', () => { clients.delete(ws); console.log(`[WS] Client disconnected (${clients.size} remaining)`); }); // If we have a cached IR, send it immediately if (cachedIR) { ws.send(cachedIR); console.log('[WS] Sent cached IR to new client'); } }); // ─── UDP Receiver ─── const udp = dgram.createSocket('udp4'); let cachedIR = null; // Cache latest IR push for new WS clients let hubAddr = null; // Remember hub address for replies udp.on('message', (msg, rinfo) => { hubAddr = rinfo; // Check if this is an IR push (has magic bytes + IR type) if (msg.length >= 6 && msg[0] === 0xD5 && msg[1] === 0x7A && msg[2] === 0x40) { // Extract and cache the IR JSON const len = msg.readUInt16LE(4); const json = msg.slice(6, 6 + len).toString(); cachedIR = json; console.log(`[UDP] IR push received (${len} bytes), broadcasting to ${clients.size} WS clients`); // Send as JSON text to all WS clients for (const ws of clients) { if (ws.readyState === 1) ws.send(json); } return; } // Forward all other binary frames to WS clients for (const ws of clients) { if (ws.readyState === 1) ws.send(msg); } // Log signal updates if (msg[0] === 0x20 && msg.length >= 7) { const sigId = msg.readUInt16LE(1); const value = msg.readInt32LE(3); // Only log occasionally to avoid spam if (sigId === 0 || value % 10 === 0) { console.log(`[UDP] Signal ${sigId} = ${value}`); } } }); udp.on('error', (err) => { console.error('[UDP] Error:', err); }); udp.bind(UDP_PORT, () => { console.log(`[UDP] Listening on port ${UDP_PORT}`); }); // ─── HTTP Server (serve previewer) ─── const server = http.createServer((req, res) => { let filePath = path.join(__dirname, req.url === '/' ? 'index.html' : req.url.split('?')[0]); if (!fs.existsSync(filePath)) { res.writeHead(404); res.end('Not found'); return; } const ext = path.extname(filePath); const contentType = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', }[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': contentType }); fs.createReadStream(filePath).pipe(res); }); server.listen(HTTP_PORT, '0.0.0.0', () => { console.log(`\n DreamStack Relay Bridge`); console.log(` ─────────────────────────`); console.log(` HTTP: http://localhost:${HTTP_PORT}/`); console.log(` WebSocket: ws://localhost:${WS_PORT}`); console.log(` UDP: port ${UDP_PORT}`); console.log(`\n Open previewer in live mode:`); console.log(` http://localhost:${HTTP_PORT}/index.html?ws=ws://localhost:${WS_PORT}`); console.log(`\n Or file mode (no hub needed):`); console.log(` http://localhost:${HTTP_PORT}/index.html`); console.log(''); });