Files
SequencerIO/web/app.js
2026-03-02 17:48:55 -05:00

601 lines
20 KiB
JavaScript

/**
* 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 = "<span style='color:var(--text-dim)'>connecting...</span>"; 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 `<div class="device-status">
<span class="device-dot ${dot}"></span>
<span>${d.device_id}</span>
<span style="color:var(--text-dim)">${hz} Hz</span>
<span style="color:var(--text-dim)">err=${err}</span>
</div>`;
}).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 => `
<div class="signal-row" id="di-${s.name}">
<span class="indicator off"></span>
<span class="signal-name">${s.name}</span>
<span class="signal-value off">OFF</span>
</div>
`).join("");
// Build analog input rows
const aiList = document.getElementById("analog-input-list");
aiList.innerHTML = analogInputs.map(s => `
<div class="signal-row" id="ai-${s.name}">
<span class="signal-name">${s.name}</span>
<span class="signal-value analog">0</span>
</div>
`).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 => `
<div class="signal-row output-row" id="do-${s.name}" data-signal="${s.name}">
<span class="indicator off"></span>
<span class="signal-name">${s.name}</span>
<span class="signal-value off">OFF</span>
</div>
`).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 => `
<div class="signal-row" id="ao-${s.name}">
<span class="signal-name">${s.name}</span>
<div class="analog-controls">
<button class="ao-minus" data-signal="${s.name}" title="Decrease">&minus;</button>
<input type="number" class="ao-input" data-signal="${s.name}"
min="0" max="65535" value="${state.outputState[s.name] || 0}">
<button class="ao-plus" data-signal="${s.name}" title="Increase">+</button>
<button class="write-btn ao-write" data-signal="${s.name}">Write</button>
</div>
</div>
`).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 => `
<div class="seq-item">
<div style="flex:1">
<div class="seq-name">${esc(seq.name)}</div>
${seq.description ? `<div class="seq-desc">${esc(seq.description)}</div>` : ""}
</div>
<span class="seq-meta">${seq.steps} steps</span>
<button class="seq-run-btn" data-seq="${esc(seq.name)}"
${state.activeRunId ? "disabled" : ""}>Run</button>
</div>
`).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 = `<span class="${cls}">${esc(r.sequence_name)} \u2192 ${r.status.toUpperCase()}</span>`;
html += ` <span style="color:var(--text-dim)">${r.steps_completed}/${r.total_steps} steps &middot; ${r.duration_ms} ms</span>`;
if (r.failed_step) {
html += `<br><span class="run-failed">\u2717 step ${r.failed_step.step_index} (${r.failed_step.t_ms} ms): ${esc(r.failed_step.detail)}</span>`;
}
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 = "<div style='color:var(--text-dim)'>(loading steps...)</div>";
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 `<div class="step-row ${cls}">
<span class="step-icon">${icon}</span>
<span class="step-time">${tStr}</span>
<span class="step-action">${esc(action)}</span>
</div>`;
}).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);