dreamstack/devices/panel-preview/index.html

1135 lines
32 KiB
HTML
Raw Permalink Normal View History

<!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>