dreamstack/devices/panel-preview/index.html

1135 lines
No EOL
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=800, height=1280, initial-scale=1.0">
<title>DreamStack Panel IR Previewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--text: #e4e4ef;
--text-dim: #8888aa;
--accent: #818cf8;
--btn: #533483;
--btn-hover: #6a4c9c;
--green: #34d399;
--border: #2a2a4e;
--radius: 8px;
}
html,
body {
width: 800px;
height: 1280px;
background: var(--bg);
color: var(--text);
font-family: 'Inter', -apple-system, sans-serif;
overflow: auto;
}
/* Panel frame */
.panel-frame {
width: 800px;
min-height: 1280px;
position: relative;
background: var(--bg);
padding: 0;
}
.panel-header {
padding: 8px 16px;
background: rgba(0, 0, 0, 0.3);
font-size: 11px;
color: var(--text-dim);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.panel-header .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
display: inline-block;
margin-right: 6px;
}
/* IR widgets */
.ir-col {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.ir-row {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.ir-stk {
position: relative;
}
.ir-lbl {
font-size: 14px;
padding: 4px 0;
}
.ir-btn {
background: var(--btn);
border: none;
color: var(--text);
padding: 10px 20px;
border-radius: var(--radius);
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
font-family: inherit;
min-width: 50px;
text-align: center;
}
.ir-btn:hover {
background: var(--btn-hover);
transform: translateY(-1px);
}
.ir-btn:active {
transform: translateY(0);
opacity: 0.8;
}
.ir-btn.primary {
background: #6366f1;
}
.ir-btn.primary:hover {
background: #818cf8;
}
.ir-btn.secondary {
background: var(--surface);
border: 1px solid var(--border);
}
.ir-btn.destructive {
background: #dc2626;
}
.ir-btn.destructive:hover {
background: #ef4444;
}
.ir-btn.ghost {
background: transparent;
border: 1px solid var(--border);
}
.ir-btn.ghost:hover {
background: var(--surface);
}
.ir-inp {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 12px;
border-radius: var(--radius);
font-size: 14px;
outline: none;
width: 100%;
font-family: inherit;
}
.ir-inp:focus {
border-color: var(--accent);
}
.ir-sld {
width: 100%;
accent-color: var(--accent);
}
.ir-sw {
width: 50px;
height: 26px;
accent-color: var(--accent);
}
.ir-bar {
width: 100%;
height: 8px;
accent-color: var(--accent);
}
.ir-pnl {
background: var(--card);
border-radius: 12px;
border: 1px solid var(--border);
padding: 16px;
}
.ir-badge {
display: inline-flex;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
gap: 4px;
}
.ir-badge.success {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.3);
}
.ir-badge.info {
background: rgba(56, 189, 248, 0.15);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.3);
}
.ir-badge.warning {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.ir-badge.error {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
border: 1px solid rgba(248, 113, 113, 0.3);
}
.ir-badge.default {
background: rgba(255, 255, 255, 0.08);
color: var(--text-dim);
border: 1px solid var(--border);
}
/* Game board grid */
.game-board {
display: grid;
gap: 1px;
margin: 8px auto;
width: fit-content;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
overflow: hidden;
}
.game-cell {
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, opacity 0.12s;
}
.game-cell.empty {
background: rgba(255, 255, 255, 0.025);
}
.game-cell.snake-head {
background: #16a34a;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
z-index: 1;
}
.game-cell.snake-body {
background: #22c55e;
}
.game-cell.food {
background: rgba(239, 68, 68, 0.2);
animation: pulse-food 1s ease-in-out infinite;
}
@keyframes pulse-food {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
.game-cell.wall-hit {
background: #dc2626;
}
.game-over-overlay {
text-align: center;
padding: 12px;
color: #f87171;
font-size: 18px;
font-weight: 700;
}
.game-hud {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 4px;
font-size: 11px;
color: var(--text-dim);
font-weight: 600;
letter-spacing: 0.3px;
}
.game-hud .hud-score {
color: #34d399;
font-size: 13px;
}
.game-hud .hud-hi {
color: #fbbf24;
}
.game-hud .hud-speed {
color: #818cf8;
}
.game-hud .hud-len {
color: #38bdf8;
}
.new-high-score {
animation: flash-gold 0.5s ease 3;
}
@keyframes flash-gold {
0%,
100% {
color: #fbbf24;
}
50% {
color: #fff;
}
}
/* Debug panel */
#debug {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.9);
color: var(--green);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 8px 12px;
max-height: 200px;
overflow-y: auto;
border-top: 1px solid var(--border);
}
#debug .sig {
color: var(--accent);
}
#debug .evt {
color: #fbbf24;
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="panel-frame">
<div class="panel-header">
<span><span class="dot"></span> Panel IR Preview — 800×1280</span>
<span id="status">No IR loaded</span>
</div>
<div id="root"></div>
</div>
<div id="debug">
<div>DreamStack Panel IR Previewer — drag-drop an .ir.json file or use ?file= URL</div>
</div>
<script>
// ─── Panel IR Runtime (JavaScript version for testing) ───
// This mirrors what ds_runtime.c does on the ESP32,
// but renders to HTML instead of LVGL.
const signals = {};
let bindings = {}; // nodeId → { text template, element }
let currentIR = null; // Store full IR for reactive rebuilds
let rebuildQueued = false;
let debugEl;
function log(msg, cls = '') {
const d = document.getElementById('debug');
const line = document.createElement('div');
line.className = cls;
line.textContent = `[${new Date().toISOString().slice(11, 19)}] ${msg}`;
d.appendChild(line);
d.scrollTop = d.scrollHeight;
}
// ── Build UI from IR ────────────────────────────────
let activeTimers = [];
function buildUI(ir, isRebuild) {
const root = document.getElementById('root');
root.innerHTML = '';
bindings = {};
currentIR = ir;
// Initialize signals (only on first build)
if (!isRebuild && ir.signals) {
for (const s of ir.signals) {
signals[s.id] = s.v;
log(`Signal ${s.id} = ${JSON.stringify(s.v)}`, 'sig');
}
}
// Start timers (only on first build)
if (!isRebuild && ir.timers) {
// Clear any previous timers
activeTimers.forEach(t => clearInterval(t));
activeTimers = [];
for (const timer of ir.timers) {
const tid = setInterval(() => {
executeAction(timer.action);
}, timer.ms);
activeTimers.push(tid);
log(`Timer started: every ${timer.ms}ms`, 'sig');
}
}
// Build the widget tree
if (ir.root) {
const el = buildNode(ir.root);
if (el) root.appendChild(el);
}
document.getElementById('status').textContent =
`${Object.keys(signals).length} signals, ${countNodes(ir.root)} nodes`;
if (!isRebuild) log(`UI built: ${countNodes(ir.root)} nodes`);
}
function countNodes(node) {
if (!node) return 0;
let n = 1;
if (node.c) for (const ch of node.c) n += countNodes(ch);
if (node.then) n += countNodes(node.then);
if (node.else) n += countNodes(node.else);
if (node.body) n += countNodes(node.body);
return n;
}
function buildNode(node) {
if (!node || !node.t) return null;
switch (node.t) {
case 'col': return buildContainer(node, 'ir-col');
case 'row': return buildContainer(node, 'ir-row');
case 'stk': return buildContainer(node, 'ir-stk');
case 'lst': return buildContainer(node, 'ir-col');
case 'pnl': return buildPanel(node);
case 'lbl': return buildLabel(node);
case 'btn': return buildButton(node);
case 'inp': return buildInput(node);
case 'sld': return buildSlider(node);
case 'sw': return buildSwitch(node);
case 'bar': return buildProgress(node);
case 'img': return buildImage(node);
case 'cond': return buildConditional(node);
case 'each': return buildEach(node);
default:
log(`Unknown node type: ${node.t}`);
return null;
}
}
function buildContainer(node, className) {
const el = document.createElement('div');
el.className = className;
el.dataset.nodeId = node.id;
if (node.gap) el.style.gap = `${node.gap}px`;
if (node.pad) el.style.padding = `${node.pad}px`;
applyStyle(el, node.style);
if (node.c) for (const ch of node.c) {
const child = buildNode(ch);
if (child) el.appendChild(child);
}
return el;
}
function buildPanel(node) {
// If it's a Badge component, render as badge
if (node._comp === 'Badge') {
const el = document.createElement('span');
el.className = `ir-badge ${node.variant || 'default'}`;
el.dataset.nodeId = node.id;
const text = expandTemplate(node.text || '');
el.textContent = text;
if (node.text && node.text.includes('{')) {
bindings[node.id] = { template: node.text, element: el };
}
return el;
}
// If it's a Card component, render as panel with title
const el = document.createElement('div');
el.className = 'ir-pnl';
el.dataset.nodeId = node.id;
if (node.text) {
const title = document.createElement('div');
title.style.cssText = 'font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:1px;';
title.textContent = expandTemplate(node.text);
el.appendChild(title);
}
// Inject visual game board for Board cards
if (node.text && node.text.toLowerCase().includes('board')) {
const board = buildGameBoard();
el.appendChild(board);
}
if (node.c) for (const ch of node.c) {
const child = buildNode(ch);
if (child) el.appendChild(child);
}
return el;
}
// ── Snake Game Engine ────────────────────────────────
const GRID = 12;
const CELL = 32;
let snake = [{ x: 5, y: 5 }];
let snakeDir = { x: 1, y: 0 };
let food = { x: 2, y: 2 };
let gameScore = 0;
let gameMoves = 0;
let gameOver = false;
let gameStarted = false;
let gamePaused = false;
let gameTimer = null;
let currentTickMs = 250;
let highScore = parseInt(localStorage.getItem('ds-snake-hi') || '0');
function getTickSpeed() {
// Speed up as score grows: 250 → 200 → 160 → 130 → 100
if (gameScore >= 20) return 100;
if (gameScore >= 15) return 130;
if (gameScore >= 10) return 160;
if (gameScore >= 5) return 200;
return 250;
}
function startSnakeGame() {
if (gameTimer) clearInterval(gameTimer);
snake = [{ x: Math.floor(GRID / 2), y: Math.floor(GRID / 2) }];
snakeDir = { x: 1, y: 0 };
food = spawnFood();
gameScore = 0;
gameMoves = 0;
gameOver = false;
gamePaused = false;
gameStarted = true;
currentTickMs = 250;
syncSignals();
gameTimer = setInterval(snakeTick, currentTickMs);
log('Snake game started!', 'evt');
}
function togglePause() {
if (gameOver || !gameStarted) return;
gamePaused = !gamePaused;
if (gamePaused) {
clearInterval(gameTimer);
log('Game paused', 'evt');
} else {
gameTimer = setInterval(snakeTick, currentTickMs);
log('Game resumed', 'evt');
}
queueRebuild();
}
function spawnFood() {
let fx, fy;
do {
fx = Math.floor(Math.random() * GRID);
fy = Math.floor(Math.random() * GRID);
} while (snake.some(s => s.x === fx && s.y === fy));
return { x: fx, y: fy };
}
function snakeTick() {
if (gameOver || gamePaused) return;
const head = snake[0];
const nx = head.x + snakeDir.x;
const ny = head.y + snakeDir.y;
// Wall collision
if (nx < 0 || nx >= GRID || ny < 0 || ny >= GRID) {
endGame(); return;
}
// Self collision
if (snake.some(s => s.x === nx && s.y === ny)) {
endGame(); return;
}
snake.unshift({ x: nx, y: ny });
gameMoves++;
if (nx === food.x && ny === food.y) {
gameScore++;
food = spawnFood();
log(`Ate food! Score: ${gameScore}, Length: ${snake.length}`, 'evt');
// Speed up
const newSpeed = getTickSpeed();
if (newSpeed !== currentTickMs) {
currentTickMs = newSpeed;
clearInterval(gameTimer);
gameTimer = setInterval(snakeTick, currentTickMs);
log(`Speed up! ${currentTickMs}ms/tick`, 'evt');
}
} else {
snake.pop();
}
syncSignals();
queueRebuild();
}
function endGame() {
gameOver = true;
clearInterval(gameTimer);
if (gameScore > highScore) {
highScore = gameScore;
localStorage.setItem('ds-snake-hi', String(highScore));
log(`NEW HIGH SCORE: ${highScore}!`, 'evt');
}
log(`Game Over! Score: ${gameScore}, Length: ${snake.length}`, 'evt');
syncSignals();
queueRebuild();
}
function setSnakeDir(dx, dy) {
if (snake.length > 1 && snakeDir.x === -dx && snakeDir.y === -dy) return;
snakeDir = { x: dx, y: dy };
if (!gameStarted) startSnakeGame();
if (gamePaused) togglePause();
}
function syncSignals() {
const head = snake[0];
signals[0] = head.x;
signals[1] = head.y;
signals[2] = gameScore;
signals[3] = gameMoves;
signals[4] = food.x;
signals[5] = food.y;
}
// Keyboard handler
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowUp': setSnakeDir(0, -1); e.preventDefault(); break;
case 'ArrowDown': setSnakeDir(0, 1); e.preventDefault(); break;
case 'ArrowLeft': setSnakeDir(-1, 0); e.preventDefault(); break;
case 'ArrowRight': setSnakeDir(1, 0); e.preventDefault(); break;
case ' ': togglePause(); e.preventDefault(); break;
}
});
function buildGameBoard() {
const wrapper = document.createElement('div');
// HUD bar
const hud = document.createElement('div');
hud.className = 'game-hud';
hud.innerHTML = `
<span class="hud-score">Score: ${gameScore}</span>
<span class="hud-len">🐍 ×${snake.length}</span>
<span class="hud-speed">${currentTickMs}ms</span>
<span class="hud-hi ${gameScore > 0 && gameScore >= highScore ? 'new-high-score' : ''}">Hi: ${highScore}</span>
`;
wrapper.appendChild(hud);
const grid = document.createElement('div');
grid.className = 'game-board';
grid.style.gridTemplateColumns = `repeat(${GRID}, ${CELL}px)`;
grid.style.gridTemplateRows = `repeat(${GRID}, ${CELL}px)`;
const head = snake[0];
const bodyMap = new Map();
snake.forEach((s, i) => { if (i > 0) bodyMap.set(`${s.x},${s.y}`, i); });
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const cell = document.createElement('div');
cell.className = 'game-cell';
cell.style.width = `${CELL}px`;
cell.style.height = `${CELL}px`;
cell.style.fontSize = `${Math.floor(CELL * 0.55)}px`;
if (r === head.y && c === head.x) {
cell.classList.add('snake-head');
cell.textContent = gameOver ? '\u{1F480}' : '\u{1F7E9}';
} else if (bodyMap.has(`${c},${r}`)) {
cell.classList.add('snake-body');
// Gradient: body segments fade from 0.9 near head to 0.4 at tail
const idx = bodyMap.get(`${c},${r}`);
const opacity = 0.9 - (idx / snake.length) * 0.5;
cell.style.opacity = opacity.toFixed(2);
cell.textContent = '\u{1F7E2}';
} else if (r === food.y && c === food.x) {
cell.classList.add('food');
cell.textContent = '\u{1F34E}';
} else {
cell.classList.add('empty');
}
grid.appendChild(cell);
}
}
wrapper.appendChild(grid);
if (gameOver) {
const isNewHi = gameScore === highScore && gameScore > 0;
const ov = document.createElement('div');
ov.className = 'game-over-overlay';
ov.innerHTML = `\u{1F480} Game Over — Score: ${gameScore}${isNewHi ? ' \u{1F3C6} NEW HIGH SCORE!' : ''} — <span style="cursor:pointer;text-decoration:underline" onclick="startSnakeGame()">Play Again</span>`;
wrapper.appendChild(ov);
} else if (gamePaused) {
const p = document.createElement('div');
p.className = 'game-over-overlay';
p.style.color = '#818cf8';
p.innerHTML = '⏸ Paused — press any arrow or Space to resume';
wrapper.appendChild(p);
} else if (!gameStarted) {
const start = document.createElement('div');
start.className = 'game-over-overlay';
start.style.color = '#34d399';
start.innerHTML = `Press any arrow key or d-pad to start${highScore > 0 ? ` — High: ${highScore}` : ''}`;
wrapper.appendChild(start);
}
return wrapper;
}
function buildLabel(node) {
const el = document.createElement('span');
el.className = 'ir-lbl';
el.dataset.nodeId = node.id;
const text = expandTemplate(node.text || '');
el.textContent = text;
if (node.size) el.style.fontSize = `${node.size}px`;
applyStyle(el, node.style);
// Register binding for signal updates
if (node.text && node.text.includes('{')) {
bindings[node.id] = { template: node.text, element: el };
}
return el;
}
function buildButton(node) {
const el = document.createElement('button');
el.className = `ir-btn ${node.variant || ''}`;
el.dataset.nodeId = node.id;
el.textContent = expandTemplate(node.text || '');
applyStyle(el, node.style);
if (node.on && node.on.click) {
el.addEventListener('click', () => {
executeAction(node.on.click);
log(`Event: node ${node.id} clicked`, 'evt');
});
} else if (node.text && node.text.includes('⏹')) {
// Wire stop/pause button to game engine
el.addEventListener('click', () => {
togglePause();
log(`Event: node ${node.id} pause toggled`, 'evt');
});
}
return el;
}
function buildInput(node) {
const el = document.createElement('input');
el.className = 'ir-inp';
el.dataset.nodeId = node.id;
if (node.placeholder) el.placeholder = node.placeholder;
if (node.bind !== undefined) {
el.value = signals[node.bind] || '';
el.addEventListener('input', () => {
updateSignal(node.bind, el.value);
});
}
return el;
}
function buildSlider(node) {
const el = document.createElement('input');
el.type = 'range';
el.className = 'ir-sld';
el.dataset.nodeId = node.id;
el.min = node.min || 0;
el.max = node.max || 100;
if (node.bind !== undefined) {
el.value = signals[node.bind] || 0;
el.addEventListener('input', () => {
updateSignal(node.bind, parseInt(el.value));
});
}
return el;
}
function buildSwitch(node) {
const el = document.createElement('input');
el.type = 'checkbox';
el.className = 'ir-sw';
el.dataset.nodeId = node.id;
if (node.bind !== undefined) {
el.checked = !!signals[node.bind];
el.addEventListener('change', () => {
updateSignal(node.bind, el.checked);
});
}
return el;
}
function buildProgress(node) {
const el = document.createElement('progress');
el.className = 'ir-bar';
el.dataset.nodeId = node.id;
el.min = node.min || 0;
el.max = node.max || 100;
if (node.bind !== undefined) {
el.value = signals[node.bind] || 0;
}
return el;
}
function buildImage(node) {
const el = document.createElement('img');
el.dataset.nodeId = node.id;
el.src = node.src || '';
el.style.maxWidth = '100%';
return el;
}
function buildConditional(node) {
// Evaluate the condition against current signal values
if (node.if && evalCondition(node.if)) {
return buildNode(node.then);
} else if (node.else) {
return buildNode(node.else);
}
return null;
}
function evalCondition(condStr) {
// Replace signal refs (s0, s1, ...) with actual values
const expr = condStr.replace(/s(\d+)/g, (_, id) => {
const val = signals[parseInt(id)];
return JSON.stringify(val !== undefined ? val : null);
});
try { return eval(expr); } catch { return false; }
}
function buildEach(node) {
const el = document.createElement('div');
el.className = 'ir-col';
el.dataset.nodeId = node.id;
// For preview, render the body template 3 times as example
for (let i = 0; i < 3; i++) {
const child = buildNode(node.body);
if (child) el.appendChild(child);
}
return el;
}
// ── Signal system ───────────────────────────────────
function updateSignal(id, value) {
signals[id] = value;
log(`Signal ${id} = ${JSON.stringify(value)}`, 'sig');
queueRebuild();
}
// Debounced rebuild — batch multiple signal updates in one frame
function queueRebuild() {
if (rebuildQueued) return;
rebuildQueued = true;
requestAnimationFrame(() => {
rebuildQueued = false;
if (currentIR) buildUI(currentIR, true);
});
}
function expandTemplate(template) {
return template.replace(/\{(\d+)\}/g, (_, id) => {
const val = signals[parseInt(id)];
return val !== undefined ? String(val) : `{${id}}`;
});
}
// ── Action executor ─────────────────────────────────
function executeAction(action) {
// Handle multi-action arrays
if (Array.isArray(action)) {
// Check if this is a d-pad action batch (modifies headX or headY)
const dirAction = action.find(a => a && (a.s === 0 || a.s === 1) &&
(a.op === 'sub' || a.op === 'add' || a.op === 'inc' || a.op === 'dec'));
if (dirAction) {
// It's a d-pad press — route to game engine
if (dirAction.s === 0) {
// headX: sub=left, add/inc=right
const dx = (dirAction.op === 'sub' || dirAction.op === 'dec') ? -1 : 1;
setSnakeDir(dx, 0);
} else {
// headY: sub=up, add/inc=down
const dy = (dirAction.op === 'sub' || dirAction.op === 'dec') ? -1 : 1;
setSnakeDir(0, dy);
}
return;
}
// Check if this is a reset action (sets multiple signals to initial values)
const setActions = action.filter(a => a && a.op === 'set');
if (setActions.length >= 3) {
startSnakeGame();
return;
}
for (const a of action) executeAction(a);
return;
}
if (!action || !action.op) return;
// Intercept single-action d-pad presses
if ((action.s === 0 || action.s === 1) &&
(action.op === 'sub' || action.op === 'add' || action.op === 'inc' || action.op === 'dec')) {
if (action.s === 0) {
const dx = (action.op === 'sub' || action.op === 'dec') ? -1 : 1;
setSnakeDir(dx, 0);
} else {
const dy = (action.op === 'sub' || action.op === 'dec') ? -1 : 1;
setSnakeDir(0, dy);
}
return;
}
switch (action.op) {
case 'inc':
updateSignal(action.s, (signals[action.s] || 0) + 1);
break;
case 'dec':
updateSignal(action.s, (signals[action.s] || 0) - 1);
break;
case 'add':
updateSignal(action.s, (signals[action.s] || 0) + (action.v || 0));
break;
case 'sub':
updateSignal(action.s, (signals[action.s] || 0) - (action.v || 0));
break;
case 'set':
updateSignal(action.s, action.v);
break;
case 'toggle':
updateSignal(action.s, !signals[action.s]);
break;
case 'remote':
log(`Remote action: ${action.name} (would forward to hub)`, 'evt');
break;
}
}
// ── Style application ───────────────────────────────
function applyStyle(el, style) {
if (!style) return;
if (style.bg) el.style.background = style.bg;
if (style.fg) el.style.color = style.fg;
if (style.size) el.style.fontSize = `${style.size}px`;
if (style.radius) el.style.borderRadius = `${style.radius}px`;
if (style.w) el.style.width = `${style.w}px`;
if (style.h) el.style.height = `${style.h}px`;
if (style.align) el.style.textAlign = style.align;
}
// ── File loading ────────────────────────────────────
const params = new URLSearchParams(location.search);
const fileUrl = params.get('file');
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}`); })
.catch(e => log(`Error loading: ${e}`));
}
// Drag and drop
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
try {
const ir = JSON.parse(reader.result);
buildUI(ir);
log(`Loaded from dropped file: ${file.name}`);
} catch (e) {
log(`Error parsing: ${e}`);
}
};
reader.readAsText(file);
}
});
// 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 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;
</script>
</body>
</html>