601 lines
20 KiB
JavaScript
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">−</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 · ${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);
|