(function () { const apiState = { snapshot: null, catalog: null, presets: [], groups: [], events: [], ws: null, commandTimers: new Map(), }; const dom = { projectName: document.getElementById("project-name"), topologyLabel: document.getElementById("topology-label"), connectionPill: document.getElementById("connection-pill"), previewUpdated: document.getElementById("preview-updated"), refreshButton: document.getElementById("refresh-button"), patternSelect: document.getElementById("pattern-select"), transitionSlider: document.getElementById("transition-slider"), transitionValue: document.getElementById("transition-value"), brightnessSlider: document.getElementById("brightness-slider"), brightnessValue: document.getElementById("brightness-value"), blackoutButton: document.getElementById("blackout-button"), presetList: document.getElementById("preset-list"), groupList: document.getElementById("group-list"), sceneParams: document.getElementById("scene-params"), previewGrid: document.getElementById("preview-grid"), summaryCards: document.getElementById("summary-cards"), snapshotJson: document.getElementById("snapshot-json"), eventList: document.getElementById("event-list"), }; function init() { bindControls(); refreshAll(); connectStream(); } function bindControls() { dom.refreshButton.addEventListener("click", () => refreshAll()); dom.patternSelect.addEventListener("change", (event) => { sendCommand({ type: "select_pattern", payload: { pattern_id: event.target.value }, }); }); dom.transitionSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.transitionValue.textContent = `${value} ms`; debounceCommand("transition", { type: "set_transition_duration_ms", payload: { duration_ms: value }, }); }); dom.brightnessSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; debounceCommand("brightness", { type: "set_master_brightness", payload: { value }, }); }); dom.blackoutButton.addEventListener("click", () => { const enabled = !(apiState.snapshot?.state?.global?.blackout ?? false); sendCommand({ type: "set_blackout", payload: { enabled }, }); }); } async function refreshAll() { setConnectionState("connecting", "loading"); try { const [snapshot, catalog, presets, groups] = await Promise.all([ fetchJson("/api/v1/snapshot"), fetchJson("/api/v1/catalog"), fetchJson("/api/v1/presets"), fetchJson("/api/v1/groups"), ]); apiState.snapshot = snapshot; apiState.catalog = catalog; apiState.presets = presets.presets || catalog.presets || []; apiState.groups = groups.groups || catalog.groups || []; renderAll(); setConnectionState("online", "HTTP snapshot synced"); } catch (error) { console.error(error); setConnectionState("offline", "snapshot fetch failed"); pushEvent({ at: new Date().toLocaleTimeString(), message: `HTTP refresh failed: ${error.message}`, }); } } function connectStream() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = `${protocol}//${window.location.host}/api/v1/stream`; const socket = new WebSocket(url); apiState.ws = socket; socket.addEventListener("open", () => { setConnectionState("online", "stream connected"); pushEvent({ at: new Date().toLocaleTimeString(), message: "WebSocket stream connected", }); }); socket.addEventListener("message", (event) => { const envelope = JSON.parse(event.data); handleStreamEnvelope(envelope); }); socket.addEventListener("close", () => { setConnectionState("offline", "stream disconnected"); pushEvent({ at: new Date().toLocaleTimeString(), message: "WebSocket stream closed, retrying", }); window.setTimeout(connectStream, 1500); }); socket.addEventListener("error", () => { setConnectionState("warning", "stream error"); }); } function handleStreamEnvelope(envelope) { const message = envelope.message; if (!message) { return; } if (!apiState.snapshot) { apiState.snapshot = { api_version: envelope.api_version, generated_at_millis: envelope.generated_at_millis, state: null, preview: null, }; } if (message.type === "snapshot") { apiState.snapshot.api_version = envelope.api_version; apiState.snapshot.generated_at_millis = envelope.generated_at_millis; apiState.snapshot.state = message.payload; renderState(); return; } if (message.type === "preview") { apiState.snapshot.preview = message.payload; apiState.snapshot.generated_at_millis = envelope.generated_at_millis; renderPreview(); renderSnapshotJson(); return; } if (message.type === "event") { pushEvent({ at: `${envelope.generated_at_millis} ms`, message: message.payload.message, }); } } async function sendCommand(command) { try { const response = await fetchJson("/api/v1/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: `web-${Date.now()}`, command, }), }); pushEvent({ at: new Date().toLocaleTimeString(), message: response.summary, }); await refreshAll(); } catch (error) { console.error(error); pushEvent({ at: new Date().toLocaleTimeString(), message: `Command failed: ${error.message}`, }); } } function debounceCommand(key, command) { const existing = apiState.commandTimers.get(key); if (existing) { window.clearTimeout(existing); } const timeoutId = window.setTimeout(() => { sendCommand(command); apiState.commandTimers.delete(key); }, 120); apiState.commandTimers.set(key, timeoutId); } async function fetchJson(url, options) { const response = await window.fetch(url, options); const body = await response.text(); let payload = null; try { payload = body ? JSON.parse(body) : null; } catch (error) { throw new Error(`Invalid JSON from ${url}`); } if (!response.ok) { const message = payload?.error?.message || payload?.error || response.statusText; throw new Error(message); } return payload; } function renderAll() { renderState(); renderPreview(); renderEvents(); } function renderState() { if (!apiState.snapshot?.state) { return; } const snapshot = apiState.snapshot; const state = snapshot.state; const global = state.global; const scene = state.active_scene; dom.projectName.textContent = state.system.project_name; dom.topologyLabel.textContent = `${state.system.topology_label} / API ${snapshot.api_version}`; dom.patternSelect.innerHTML = ""; (apiState.catalog?.patterns || []).forEach((pattern) => { const option = document.createElement("option"); option.value = pattern.pattern_id; option.textContent = `${pattern.display_name} (${pattern.pattern_id})`; option.selected = pattern.pattern_id === global.selected_pattern; dom.patternSelect.appendChild(option); }); dom.transitionSlider.value = String(global.transition_duration_ms); dom.transitionValue.textContent = `${global.transition_duration_ms} ms`; dom.brightnessSlider.value = String(global.master_brightness); dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout"; dom.blackoutButton.classList.toggle("is-active", global.blackout); renderPresetButtons(scene, global); renderGroupButtons(global); renderSceneParameters(scene); renderSummaryCards(state, snapshot.generated_at_millis); renderSnapshotJson(); dom.previewUpdated.textContent = `${snapshot.generated_at_millis} ms`; } function renderPresetButtons(scene) { dom.presetList.innerHTML = ""; const presets = apiState.presets || []; if (!presets.length) { dom.presetList.innerHTML = '
No presets available.
'; return; } presets.forEach((preset) => { const button = document.createElement("button"); button.type = "button"; button.className = "preset-button"; button.classList.toggle("active", scene.preset_id === preset.preset_id); button.innerHTML = ` ${preset.preset_id}
${preset.pattern_id} / ${preset.transition_duration_ms} ms
`; button.addEventListener("click", () => sendCommand({ type: "recall_preset", payload: { preset_id: preset.preset_id }, }) ); dom.presetList.appendChild(button); }); } function renderGroupButtons(global) { dom.groupList.innerHTML = ""; const allButton = document.createElement("button"); allButton.type = "button"; allButton.className = "group-button"; allButton.classList.toggle("active", !global.selected_group); allButton.innerHTML = "all_panels
global target
"; allButton.addEventListener("click", () => sendCommand({ type: "select_group", payload: { group_id: null }, }) ); dom.groupList.appendChild(allButton); (apiState.groups || []).forEach((group) => { const button = document.createElement("button"); button.type = "button"; button.className = "group-button"; button.classList.toggle("active", group.group_id === global.selected_group); button.innerHTML = ` ${group.group_id}
${group.member_count} members
`; button.addEventListener("click", () => sendCommand({ type: "select_group", payload: { group_id: group.group_id }, }) ); dom.groupList.appendChild(button); }); } function renderSceneParameters(scene) { dom.sceneParams.innerHTML = ""; const parameters = scene.parameters || []; if (!parameters.length) { dom.sceneParams.innerHTML = '
This pattern has no exposed scene parameters.
'; return; } parameters.forEach((parameter) => { const card = document.createElement("div"); card.className = "parameter-card"; if (parameter.kind === "scalar") { const currentValue = Number(parameter.value.value || 0); card.innerHTML = ` `; const slider = card.querySelector("input"); const readout = card.querySelector("span:last-of-type"); slider.addEventListener("input", (event) => { const value = Number(event.target.value); readout.textContent = value.toFixed(2); debounceCommand(`param:${parameter.key}`, { type: "set_scene_parameter", payload: { key: parameter.key, value: { kind: "scalar", value }, }, }); }); } else if (parameter.kind === "toggle") { const checked = Boolean(parameter.value.value); card.innerHTML = ` `; const checkbox = card.querySelector("input"); checkbox.addEventListener("change", (event) => sendCommand({ type: "set_scene_parameter", payload: { key: parameter.key, value: { kind: "toggle", value: event.target.checked }, }, }) ); } else { const currentValue = parameter.value.value || ""; card.innerHTML = ` `; const input = card.querySelector("input"); input.addEventListener("change", (event) => sendCommand({ type: "set_scene_parameter", payload: { key: parameter.key, value: { kind: "text", value: event.target.value }, }, }) ); } dom.sceneParams.appendChild(card); }); } function renderPreview() { const preview = apiState.snapshot?.preview; dom.previewGrid.innerHTML = ""; if (!preview?.panels?.length) { dom.previewGrid.innerHTML = '
Preview stream is waiting for panel snapshots.
'; return; } const panels = [...preview.panels].sort(comparePreviewPanels); panels.forEach((panel) => { const card = document.createElement("article"); card.className = "preview-card"; card.style.setProperty("--preview-color", panel.representative_color_hex); card.innerHTML = `

${panel.node_id}

${panel.panel_position} / ${panel.source}
${panel.energy_percent}%
${panel.sample_led_hex .map( (hex) => `` ) .join("")}
`; dom.previewGrid.appendChild(card); }); } function renderSummaryCards(state, generatedAtMillis) { const scene = state.active_scene; const global = state.global; const engine = state.engine; const nodeStats = summarizeNodes(state.nodes || []); const cards = [ { label: "Active Pattern", value: scene.pattern_id, detail: scene.preset_id ? `Preset ${scene.preset_id}` : "live scene", }, { label: "Group Target", value: scene.target_group || "all_panels", detail: `${(apiState.groups || []).length} groups available`, }, { label: "Transition", value: `${global.transition_duration_ms} ms`, detail: engine.active_transition ? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%` : "idle", }, { label: "Brightness", value: `${Math.round(global.master_brightness * 100)}%`, detail: global.blackout ? "blackout active" : "output live", }, { label: "Engine", value: `${engine.frame_hz} fps target`, detail: `${engine.logic_hz} hz logic / frame ${engine.frame_index}`, }, { label: "Nodes", value: `${nodeStats.online}/${state.nodes.length} online`, detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`, }, { label: "Preview Timestamp", value: `${generatedAtMillis} ms`, detail: `${state.system.schema_version} schema`, }, ]; dom.summaryCards.innerHTML = cards .map( (card) => `
${card.value} ${card.label}
${card.detail}
` ) .join(""); } function renderSnapshotJson() { dom.snapshotJson.textContent = apiState.snapshot ? JSON.stringify(apiState.snapshot, null, 2) : "No snapshot loaded."; } function pushEvent(entry) { apiState.events.unshift(entry); apiState.events = apiState.events.slice(0, 12); renderEvents(); } function renderEvents() { if (!apiState.events.length) { dom.eventList.innerHTML = '
No websocket notices yet.
'; return; } dom.eventList.innerHTML = apiState.events .map( (entry) => `
${entry.at}
${entry.message}
` ) .join(""); } function setConnectionState(kind, message) { dom.connectionPill.textContent = message; dom.connectionPill.className = kind === "online" ? "pill pill-online" : kind === "warning" ? "pill pill-warning" : "pill pill-offline"; } function summarizeNodes(nodes) { return nodes.reduce( (summary, node) => { summary[node.connection] += 1; return summary; }, { online: 0, degraded: 0, offline: 0 } ); } function comparePreviewPanels(left, right) { const leftNode = left.node_id.localeCompare(right.node_id); if (leftNode !== 0) { return leftNode; } return panelPositionRank(left.panel_position) - panelPositionRank(right.panel_position); } function panelPositionRank(position) { if (position === "top") { return 0; } if (position === "middle") { return 1; } return 2; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } init(); })();