dreamstack/devices/panel-preview/relay-bridge.js

139 lines
4.4 KiB
JavaScript

#!/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('');
});