/** * Arnold — Terminator I/O Web Interface * * Vanilla JS, no build tools. Polls the REST API at ~20 Hz for I/O state * and ~2 Hz for status/sequences. Renders into the DOM panels defined in * index.html. */ "use strict"; // ── State ────────────────────────────────────────────────────────────────── const state = { signals: [], // from GET /config/signals (bootstrap) ioSnapshot: {}, // from GET /io status: null, // from GET /status sequences: [], // from GET /sequences sequenceCache: {}, // name -> full detail from GET /sequences/{name} // Output shadow state (what we last wrote) outputState: {}, // signal_name -> bool|int // Analog pending edits analogPending: {}, // signal_name -> int (before committed) // Active sequence run activeRunId: null, activeRun: null, // RunResult from GET /runs/{run_id} lastResult: null, // last completed RunResult // Flash message flashMsg: "", flashTimer: null, }; // Derived signal lists (populated after bootstrap) let digitalInputs = []; let analogInputs = []; let digitalOutputs = []; let analogOutputs = []; let hasAnalogInputs = false; let hasDigitalInputs = false; let hasAnalogOutputs = false; let hasDigitalOutputs = false; // ── API helpers ──────────────────────────────────────────────────────────── const API = ""; // same origin async function api(method, path, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); } const res = await fetch(API + path, opts); if (!res.ok) { const text = await res.text(); throw new Error(`${res.status} ${res.statusText}: ${text}`); } return res.json(); } // ── Bootstrap ────────────────────────────────────────────────────────────── async function bootstrap() { try { // Load signal config + sequences in parallel const [signals, sequences] = await Promise.all([ api("GET", "/config/signals"), api("GET", "/sequences"), ]); state.signals = signals; state.sequences = sequences; // Partition signals digitalInputs = signals.filter(s => s.direction === "input" && s.value_type === "bool"); analogInputs = signals.filter(s => s.direction === "input" && s.value_type === "int"); digitalOutputs = signals.filter(s => s.direction === "output" && s.value_type === "bool"); analogOutputs = signals.filter(s => s.direction === "output" && s.value_type === "int"); hasDigitalInputs = digitalInputs.length > 0; hasAnalogInputs = analogInputs.length > 0; hasDigitalOutputs = digitalOutputs.length > 0; hasAnalogOutputs = analogOutputs.length > 0; // Init output shadow state for (const s of digitalOutputs) { state.outputState[s.name] = s.default_state ?? false; } for (const s of analogOutputs) { state.outputState[s.name] = s.default_value ?? 0; } // Pre-fetch full sequence details for (const seq of sequences) { api("GET", `/sequences/${encodeURIComponent(seq.name)}`).then(detail => { state.sequenceCache[seq.name] = detail; }); } buildInputPanel(); buildOutputPanel(); buildSequencePanel(); // Start poll loops pollIO(); pollStatus(); pollRun(); } catch (err) { flash("Bootstrap failed: " + err.message); console.error("Bootstrap error:", err); // Retry in 3 seconds setTimeout(bootstrap, 3000); } } // ── Polling loops ────────────────────────────────────────────────────────── function pollIO() { api("GET", "/io") .then(data => { state.ioSnapshot = data; renderInputs(); renderOutputValues(); }) .catch(() => {}) .finally(() => setTimeout(pollIO, 50)); // ~20 Hz } function pollStatus() { api("GET", "/status") .then(data => { state.status = data; renderStatusBar(); // Track active run if (data.active_run && !state.activeRunId) { state.activeRunId = data.active_run; } }) .catch(() => {}) .finally(() => setTimeout(pollStatus, 500)); // 2 Hz } function pollRun() { if (state.activeRunId) { api("GET", `/runs/${state.activeRunId}`) .then(run => { state.activeRun = run; if (run.status !== "pending" && run.status !== "running") { // Run completed state.lastResult = run; state.activeRunId = null; state.activeRun = null; renderSequenceIdle(); } else { renderSequenceRunning(); } }) .catch(() => {}); } setTimeout(pollRun, 100); } // ── Status bar ───────────────────────────────────────────────────────────── function renderStatusBar() { const el = document.getElementById("status-devices"); if (!state.status) { el.innerHTML = "connecting..."; return; } const devs = state.status.devices || []; const polls = state.status.poll_stats || []; const pollMap = {}; for (const p of polls) pollMap[p.device_id] = p; el.innerHTML = devs.map(d => { const p = pollMap[d.device_id] || {}; const dot = d.connected ? "connected" : "disconnected"; const hz = (p.achieved_hz || 0).toFixed(0); const err = p.error_count || 0; return `
${d.device_id} ${hz} Hz err=${err}
`; }).join(""); } // ── Input panel ──────────────────────────────────────────────────────────── function buildInputPanel() { const diHeader = document.querySelector("#digital-inputs .sub-header"); const aiHeader = document.querySelector("#analog-inputs .sub-header"); const noInputs = document.getElementById("no-inputs"); if (!hasDigitalInputs && !hasAnalogInputs) { noInputs.classList.remove("hidden"); return; } noInputs.classList.add("hidden"); // Show sub-headers only when both types present if (hasDigitalInputs && hasAnalogInputs) { diHeader.classList.remove("hidden"); aiHeader.classList.remove("hidden"); } // Build digital input rows const diList = document.getElementById("digital-input-list"); diList.innerHTML = digitalInputs.map(s => `
${s.name} OFF
`).join(""); // Build analog input rows const aiList = document.getElementById("analog-input-list"); aiList.innerHTML = analogInputs.map(s => `
${s.name} 0
`).join(""); } function renderInputs() { // Digital inputs for (const s of digitalInputs) { const row = document.getElementById(`di-${s.name}`); if (!row) continue; const io = state.ioSnapshot[s.name]; const dot = row.querySelector(".indicator"); const val = row.querySelector(".signal-value"); if (!io || io.stale) { dot.className = "indicator stale"; val.className = "signal-value stale"; val.textContent = "?"; } else if (io.value) { dot.className = "indicator on"; val.className = "signal-value on"; val.textContent = "ON"; } else { dot.className = "indicator off"; val.className = "signal-value off"; val.textContent = "OFF"; } } // Analog inputs for (const s of analogInputs) { const row = document.getElementById(`ai-${s.name}`); if (!row) continue; const io = state.ioSnapshot[s.name]; const val = row.querySelector(".signal-value"); if (!io || io.stale) { val.className = "signal-value stale"; val.textContent = "?"; } else { val.className = "signal-value analog"; val.textContent = String(io.value); } } } // ── Output panel ─────────────────────────────────────────────────────────── function buildOutputPanel() { const doHeader = document.querySelector("#digital-outputs .sub-header"); const aoHeader = document.querySelector("#analog-outputs .sub-header"); const noOutputs = document.getElementById("no-outputs"); const bulkDiv = document.getElementById("digital-bulk"); if (!hasDigitalOutputs && !hasAnalogOutputs) { noOutputs.classList.remove("hidden"); return; } noOutputs.classList.add("hidden"); // Show sub-headers only when both types present if (hasDigitalOutputs && hasAnalogOutputs) { doHeader.classList.remove("hidden"); aoHeader.classList.remove("hidden"); } // Bulk buttons if (hasDigitalOutputs) { bulkDiv.classList.remove("hidden"); document.getElementById("btn-all-off").addEventListener("click", () => allDigitalOutputs(false)); document.getElementById("btn-all-on").addEventListener("click", () => allDigitalOutputs(true)); } // Digital output rows const doList = document.getElementById("digital-output-list"); doList.innerHTML = digitalOutputs.map(s => `
${s.name} OFF
`).join(""); // Click to toggle for (const s of digitalOutputs) { document.getElementById(`do-${s.name}`).addEventListener("click", () => { const cur = state.outputState[s.name]; writeOutput(s.name, !cur); }); } // Analog output rows const aoList = document.getElementById("analog-output-list"); aoList.innerHTML = analogOutputs.map(s => `
${s.name}
`).join(""); // Analog control events for (const s of analogOutputs) { const input = aoList.querySelector(`input[data-signal="${s.name}"]`); const minus = aoList.querySelector(`.ao-minus[data-signal="${s.name}"]`); const plus = aoList.querySelector(`.ao-plus[data-signal="${s.name}"]`); const write = aoList.querySelector(`.ao-write[data-signal="${s.name}"]`); minus.addEventListener("click", () => { const cur = parseInt(input.value) || 0; const nv = Math.max(0, cur - 100); input.value = nv; markAnalogPending(s.name, nv, input); }); plus.addEventListener("click", () => { const cur = parseInt(input.value) || 0; const nv = Math.min(65535, cur + 100); input.value = nv; markAnalogPending(s.name, nv, input); }); input.addEventListener("input", () => { let v = parseInt(input.value); if (isNaN(v)) v = 0; v = Math.max(0, Math.min(65535, v)); markAnalogPending(s.name, v, input); }); input.addEventListener("keydown", (e) => { if (e.key === "Enter") { const v = parseInt(input.value) || 0; writeOutput(s.name, Math.max(0, Math.min(65535, v))); } }); write.addEventListener("click", () => { const v = parseInt(input.value) || 0; writeOutput(s.name, Math.max(0, Math.min(65535, v))); }); } } function markAnalogPending(name, value, inputEl) { state.analogPending[name] = value; if (value !== state.outputState[name]) { inputEl.classList.add("pending"); } else { inputEl.classList.remove("pending"); } } function renderOutputValues() { // Digital outputs — use shadow state, NOT polled IO for (const s of digitalOutputs) { const row = document.getElementById(`do-${s.name}`); if (!row) continue; const val = state.outputState[s.name]; const dot = row.querySelector(".indicator"); const txt = row.querySelector(".signal-value"); if (val) { dot.className = "indicator on"; txt.className = "signal-value on"; txt.textContent = "ON"; } else { dot.className = "indicator off"; txt.className = "signal-value off"; txt.textContent = "OFF"; } } // Analog outputs — update input value only if not pending for (const s of analogOutputs) { const input = document.querySelector(`input.ao-input[data-signal="${s.name}"]`); if (!input) continue; if (!(s.name in state.analogPending)) { input.value = state.outputState[s.name] || 0; input.classList.remove("pending"); } } } // ── Output writes ────────────────────────────────────────────────────────── async function writeOutput(name, value) { try { await api("POST", `/io/${encodeURIComponent(name)}/write`, { value }); state.outputState[name] = value; delete state.analogPending[name]; // Clear pending style on analog input const input = document.querySelector(`input.ao-input[data-signal="${name}"]`); if (input) { input.value = value; input.classList.remove("pending"); } if (typeof value === "boolean") { flash(`${name} \u2192 ${value ? "ON" : "OFF"}`); } else { flash(`${name} \u2192 ${value}`); } } catch (err) { flash(`WRITE FAILED: ${name} \u2014 ${err.message}`); } } async function allDigitalOutputs(value) { for (const s of digitalOutputs) { writeOutput(s.name, value); } } // ── Sequence panel ───────────────────────────────────────────────────────── function buildSequencePanel() { renderSequenceIdle(); } function renderSequenceIdle() { const idle = document.getElementById("sequence-idle"); const running = document.getElementById("sequence-running"); idle.classList.remove("hidden"); running.classList.add("hidden"); const list = document.getElementById("sequence-list"); list.innerHTML = state.sequences.map(seq => `
${esc(seq.name)}
${seq.description ? `
${esc(seq.description)}
` : ""}
${seq.steps} steps
`).join(""); // Bind run buttons for (const btn of list.querySelectorAll(".seq-run-btn")) { btn.addEventListener("click", () => runSequence(btn.dataset.seq)); } // Last run summary const summaryEl = document.getElementById("last-run-summary"); if (state.lastResult) { const r = state.lastResult; const cls = r.status === "success" ? "run-success" : "run-failed"; let html = `${esc(r.sequence_name)} \u2192 ${r.status.toUpperCase()}`; html += ` ${r.steps_completed}/${r.total_steps} steps · ${r.duration_ms} ms`; if (r.failed_step) { html += `
\u2717 step ${r.failed_step.step_index} (${r.failed_step.t_ms} ms): ${esc(r.failed_step.detail)}`; } summaryEl.innerHTML = html; summaryEl.classList.remove("hidden"); } else { summaryEl.classList.add("hidden"); } } function renderSequenceRunning() { const idle = document.getElementById("sequence-idle"); const running = document.getElementById("sequence-running"); idle.classList.add("hidden"); running.classList.remove("hidden"); const run = state.activeRun; if (!run) return; const detail = state.sequenceCache[run.sequence_name]; // Header document.getElementById("run-header").textContent = `\u25B6 ${run.sequence_name} ${run.steps_completed}/${run.total_steps}`; // Progress bar const pct = run.total_steps ? (run.steps_completed / run.total_steps * 100) : 0; document.getElementById("run-progress-fill").style.width = pct + "%"; // Step list const stepList = document.getElementById("run-step-list"); if (!detail) { stepList.innerHTML = "
(loading steps...)
"; return; } stepList.innerHTML = detail.steps.map((step, i) => { let cls, icon; if (i === run.current_step_index) { cls = "current"; icon = "\u25B6"; } else if (i < run.steps_completed) { cls = "completed"; icon = "\u2713"; } else { cls = "pending-step"; icon = "\u00B7"; } const tStr = (step.t_ms / 1000).toFixed(1) + "s"; const action = formatStep(step); return `
${icon} ${tStr} ${esc(action)}
`; }).join(""); } function formatStep(step) { if (step.action === "set_output") { if (step.value !== null && step.value !== undefined) { return `set ${step.signal} \u2192 ${step.value}`; } return `set ${step.signal} \u2192 ${step.state ? "ON" : "OFF"}`; } if (step.action === "check_input") { if (step.expected_value !== null && step.expected_value !== undefined) { const tol = step.tolerance ? `\u00B1${step.tolerance}` : ""; return `chk ${step.signal} == ${step.expected_value}${tol}`; } return `chk ${step.signal} == ${step.expected ? "ON" : "OFF"}`; } if (step.action === "wait_input") { const tout = step.timeout_ms ? `${step.timeout_ms} ms` : "?"; if (step.expected_value !== null && step.expected_value !== undefined) { const tol = step.tolerance ? `\u00B1${step.tolerance}` : ""; return `wait ${step.signal} == ${step.expected_value}${tol} (timeout ${tout})`; } return `wait ${step.signal} == ${step.expected ? "ON" : "OFF"} (timeout ${tout})`; } return `${step.action} ${step.signal}`; } async function runSequence(name) { if (state.activeRunId) { flash("Busy: sequence already running"); return; } try { const res = await api("POST", `/sequences/${encodeURIComponent(name)}/run`); state.activeRunId = res.run_id; flash(`Started: ${name}`); renderSequenceRunning(); } catch (err) { flash(`Failed to start: ${err.message}`); } } // ── Flash message ────────────────────────────────────────────────────────── function flash(msg, duration = 4000) { const el = document.getElementById("status-message"); el.textContent = msg; if (state.flashTimer) clearTimeout(state.flashTimer); state.flashTimer = setTimeout(() => { el.textContent = ""; }, duration); } // ── Utilities ────────────────────────────────────────────────────────────── function esc(str) { const d = document.createElement("div"); d.textContent = str; return d.innerHTML; } // ── Start ────────────────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", bootstrap);