diff --git a/devices/panel-preview/index.html b/devices/panel-preview/index.html
index b852f91..29b7cd3 100644
--- a/devices/panel-preview/index.html
+++ b/devices/panel-preview/index.html
@@ -973,10 +973,16 @@
}
// ── File loading ────────────────────────────────────
- // Load from URL param
const params = new URLSearchParams(location.search);
const fileUrl = params.get('file');
- if (fileUrl) {
+ const wsUrl = params.get('ws'); // e.g. ?ws=ws://localhost:9201
+
+ if (wsUrl) {
+ // ── WebSocket Binary Bridge Mode ─────────────────
+ // Connects to a relay that bridges UDP ↔ WebSocket.
+ // Receives binary signal frames from hub in real-time.
+ connectWebSocket(wsUrl);
+ } else if (fileUrl) {
fetch(fileUrl)
.then(r => r.json())
.then(ir => { buildUI(ir); log(`Loaded from ${fileUrl}`); })
@@ -1003,13 +1009,126 @@
}
});
- // Also try loading app.ir.json from same directory
- if (!fileUrl) {
+ // Auto-load app.ir.json (file mode fallback)
+ if (!fileUrl && !wsUrl) {
fetch('app.ir.json')
.then(r => r.json())
.then(ir => { buildUI(ir); log('Auto-loaded app.ir.json'); })
- .catch(() => log('No app.ir.json found. Drag-drop an IR file or use ?file=URL'));
+ .catch(() => log('No app.ir.json found. Drag-drop an IR file or use ?file=URL or ?ws=ws://host:port'));
}
+
+ // ── WebSocket Binary Bridge ──────────────────────────
+ // Frame types must match ds_espnow.h
+ const DS_NOW_SIG = 0x20;
+ const DS_NOW_SIG_BATCH = 0x21;
+ const DS_NOW_ACTION = 0x31;
+ const DS_NOW_PING = 0xFE;
+ const DS_NOW_PONG = 0xFD;
+ const DS_UDP_IR_PUSH = 0x40;
+
+ let ws = null;
+ let wsSeq = 0;
+
+ function connectWebSocket(url) {
+ log(`Connecting to ${url}...`, 'sig');
+ ws = new WebSocket(url);
+ ws.binaryType = 'arraybuffer';
+
+ ws.onopen = () => {
+ log('WebSocket connected — receiving live signals', 'sig');
+ document.getElementById('status').textContent = '🟢 Live';
+ document.querySelector('.dot').style.background = '#22c55e';
+ };
+
+ ws.onmessage = (event) => {
+ if (typeof event.data === 'string') {
+ // JSON message — treat as IR push
+ try {
+ const ir = JSON.parse(event.data);
+ buildUI(ir);
+ log('IR push received via WebSocket');
+ } catch (e) {
+ log(`WS JSON error: ${e}`);
+ }
+ return;
+ }
+
+ // Binary message
+ const buf = new DataView(event.data);
+ if (buf.byteLength < 1) return;
+ const type = buf.getUint8(0);
+
+ switch (type) {
+ case DS_NOW_SIG:
+ if (buf.byteLength >= 7) {
+ const sigId = buf.getUint16(1, true);
+ const value = buf.getInt32(3, true);
+ updateSignal(sigId, value);
+ }
+ break;
+
+ case DS_NOW_SIG_BATCH:
+ if (buf.byteLength >= 3) {
+ const count = buf.getUint8(1);
+ for (let i = 0; i < count; i++) {
+ const offset = 3 + i * 6;
+ if (offset + 6 > buf.byteLength) break;
+ const sigId = buf.getUint16(offset, true);
+ const value = buf.getInt32(offset + 2, true);
+ updateSignal(sigId, value);
+ }
+ }
+ break;
+
+ case DS_NOW_PING: {
+ // Respond with pong
+ const pong = new Uint8Array([DS_NOW_PONG, buf.getUint8(1)]);
+ ws.send(pong.buffer);
+ break;
+ }
+
+ case DS_UDP_IR_PUSH:
+ // Binary IR push: [magic:2][type][0][len:u16][json...]
+ if (buf.byteLength >= 6) {
+ const len = buf.getUint16(4, true);
+ const jsonBytes = new Uint8Array(event.data, 6, len);
+ const json = new TextDecoder().decode(jsonBytes);
+ try {
+ const ir = JSON.parse(json);
+ buildUI(ir);
+ log(`IR push received (${len} bytes)`);
+ } catch (e) {
+ log(`IR parse error: ${e}`);
+ }
+ }
+ break;
+
+ default:
+ log(`Unknown binary frame: 0x${type.toString(16)}`, 'evt');
+ }
+ };
+
+ ws.onclose = () => {
+ log('WebSocket disconnected — reconnecting in 3s...', 'evt');
+ document.getElementById('status').textContent = '🔴 Disconnected';
+ document.querySelector('.dot').style.background = '#ef4444';
+ setTimeout(() => connectWebSocket(url), 3000);
+ };
+
+ ws.onerror = (e) => {
+ log(`WebSocket error: ${e}`, 'evt');
+ };
+ }
+
+ // Send action event to hub (when button clicked in previewer)
+ function sendActionToHub(nodeId, actionType) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ const buf = new Uint8Array([DS_NOW_ACTION, nodeId, actionType, wsSeq++ & 0xFF]);
+ ws.send(buf.buffer);
+ }
+
+ // Expose globally so buildButton can call it
+ window.sendActionToHub = sendActionToHub;