Implemented new C++ logic

This commit is contained in:
Martino Ferrari
2026-06-12 15:25:13 +02:00
parent 617b5bd712
commit f25bd7f08e
220 changed files with 39185 additions and 850 deletions
+130 -117
View File
@@ -302,7 +302,7 @@ let _gridCols = 1, _gridRows = 1;
const trig = {
enabled: false, signal: '', edge: 'rising', threshold: 0, windowSec: 1,
prePercent: 20, mode: 'normal', stopped: false,
armed: false, prevVal: null, collecting: false, trigTime: null, snapshot: null,
armed: false, collecting: false, trigTime: null, snapshot: null,
};
function trigPreSec() { return trig.windowSec * trig.prePercent / 100; }
function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100; }
@@ -311,8 +311,23 @@ function trigPostSec() { return trig.windowSec * (100 - trig.prePercent) / 100;
WebSocket
════════════════════════════════════════════════════════════════ */
let ws = null, wsBackoff = 1000;
// Hub address resolution order:
// 1. ?hub=host:port query parameter (explicit override)
// 2. GET /hub served by the static webui server (points at the C++ StreamHub)
// 3. the serving host itself (Go hub mode: it implements /ws directly)
let HUB = new URLSearchParams(location.search).get('hub') || location.host;
async function resolveHub() {
if (new URLSearchParams(location.search).get('hub')) return;
try {
const r = await fetch('/hub', { cache: 'no-store' });
if (r.ok) {
const a = (await r.text()).trim();
if (a) HUB = a;
}
} catch { /* no /hub endpoint: Go hub mode, keep location.host */ }
}
function connectWS() {
ws = new WebSocket('ws://' + location.host + '/ws');
ws = new WebSocket('ws://' + HUB + '/ws');
ws.binaryType = 'arraybuffer';
ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected waiting for data'); };
ws.onclose = () => {
@@ -328,9 +343,35 @@ function connectWS() {
else if (msg.type === 'config') onConfig(msg);
else if (msg.type === 'data') onData(msg);
else if (msg.type === 'stats') onStats(msg);
else if (msg.type === 'zoom') onZoomReply(msg);
else if (msg.type === 'triggerState') onTriggerState(msg);
};
}
/* WS zoom request/reply — replaces the Go hub's /api/zoom HTTP endpoint.
Resolves with the {key:{t,v}} signals map; rejects on timeout/closure. */
let _zoomReqId = 0;
const _zoomPending = new Map();
function wsZoomRequest(t0, t1, n, keys) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) { reject(new Error('ws not open')); return; }
const reqId = ++_zoomReqId;
const timer = setTimeout(() => {
_zoomPending.delete(reqId);
reject(new Error('zoom request timeout'));
}, 5000);
_zoomPending.set(reqId, { resolve, timer });
ws.send(JSON.stringify({ type: 'zoom', reqId, t0, t1, n, signals: keys.join(',') }));
});
}
function onZoomReply(msg) {
const p = _zoomPending.get(msg.reqId);
if (!p) return;
clearTimeout(p.timer);
_zoomPending.delete(msg.reqId);
p.resolve(msg.signals || {});
}
/* ════════════════════════════════════════════════════════════════
Status LED
════════════════════════════════════════════════════════════════ */
@@ -396,7 +437,7 @@ function onConfig(msg) {
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(sigCap); }
});
if (trig.signal && trig.signal.startsWith(prefix)) {
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
trigDisarm(); trig.snapshot = null;
}
zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none';
@@ -419,9 +460,6 @@ function onData(msg) {
});
// Increment data generation counter so render loop knows data changed
_dataGen++;
if (trig.enabled && trig.armed && trig.signal) checkTrigger(sigs);
if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
if (!trig.enabled) {
plots.forEach(p => {
if (globalPause) return;
@@ -448,8 +486,9 @@ function onBinaryData(buf) {
lastDataAt = performance.now();
const dv = new DataView(buf);
let off = 0;
if (dv.getUint8(off) !== 1) return;
off += 1;
const version = dv.getUint8(off); off += 1;
if (version === 2) { onTriggerCapture(dv, buf, off); return; }
if (version !== 1) return;
const srcIdLen = dv.getUint8(off); off += 1;
const srcId = new TextDecoder().decode(new Uint8Array(buf, off, srcIdLen));
@@ -458,9 +497,6 @@ function onBinaryData(buf) {
const numSigs = dv.getUint32(off, true); off += 4;
// Collect trigger-signal values for inline check
let trigVals = null;
for (let s = 0; s < numSigs; s++) {
const keyLen = dv.getUint16(off, true); off += 2;
const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen));
@@ -487,23 +523,8 @@ function onBinaryData(buf) {
pushBuffer(bufObj, dv.getFloat64(tOff + i * 8, true), dv.getFloat64(vOff + i * 8, true));
}
off += n * 16; // skip both t and v arrays
// Capture trigger signal values
if (trig.enabled && trig.armed && fullKey === trig.signal) {
trigVals = { t: new Float64Array(n), v: new Float64Array(n) };
for (let i = 0; i < n; i++) {
trigVals.t[i] = dv.getFloat64(tOff + i * 8, true);
trigVals.v[i] = dv.getFloat64(vOff + i * 8, true);
}
}
}
// Trigger check — pass as keyed object matching checkTrigger's expected format
if (trigVals) checkTrigger({ [trig.signal]: trigVals });
if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
if (!trig.enabled) {
_dataGen++;
plots.forEach(p => {
@@ -513,99 +534,98 @@ function onBinaryData(buf) {
}
}
function checkTrigger(sigs) {
const sd = sigs[trig.signal]; if (!sd || !sd.v || !sd.v.length) return;
for (let i = 0; i < sd.v.length; i++) {
const cur = sd.v[i], prev = trig.prevVal; trig.prevVal = cur;
if (prev === null) continue;
const e = trig.edge, thr = trig.threshold;
if ((e === 'rising' && prev < thr && cur >= thr) ||
(e === 'falling' && prev > thr && cur <= thr) ||
(e === 'both' && ((prev < thr && cur >= thr) || (prev > thr && cur <= thr)))) {
fireTrigger(sd.t ? sd.t[i] : Date.now() / 1000); break;
}
/* Trigger logic runs hub-side (C++ StreamHub TriggerEngine). The SPA sends
the configuration + arm/disarm commands, tracks the FSM via "triggerState"
broadcasts, and receives the finished capture as a version-2 binary frame. */
function wsSend(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function sendTrigConfig() {
wsSend({
type: 'setTrigger', signal: trig.signal, edge: trig.edge,
threshold: trig.threshold, windowSec: trig.windowSec,
prePercent: trig.prePercent, mode: trig.mode,
});
}
// Hub FSM broadcast: {state:"idle|armed|collecting|triggered", mode, stopped[, trigTime]}
function onTriggerState(msg) {
const st = msg.state || 'idle';
trig.stopped = !!msg.stopped;
updateStopBtn();
if (st === 'armed') {
trig.armed = true; trig.collecting = false;
setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('armed');
} else if (st === 'collecting') {
trig.armed = false; trig.collecting = true;
if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime;
// Clear the old snapshot now (mirrors the old fireTrigger) so the new
// capture replaces it when the v2 frame arrives.
trig.snapshot = null;
updateTrigStatusBadge('waiting'); setAllCardsCollecting(true);
} else if (st === 'triggered') {
trig.armed = false; trig.collecting = false;
if (msg.trigTime !== undefined) trig.trigTime = msg.trigTime;
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
showRearmBtn(trig.mode === 'single');
showStopBtn(trig.mode === 'normal');
} else { // idle
trig.armed = false; trig.collecting = false;
setAllCardsCollecting(false); showRearmBtn(false); updateTrigStatusBadge('idle');
}
}
function fireTrigger(t) {
// Clear the previous snapshot here (not in trigArm) so the last waveform
// and trigger marker stay visible while the trigger is rearmed and waiting.
trig.snapshot = null; trig.armed = false; trig.collecting = true; trig.trigTime = t;
updateTrigStatusBadge('waiting'); setAllCardsCollecting(true);
}
function finaliseTriggerCapture() {
trig.collecting = false;
const t0 = trig.trigTime - trigPreSec(), t1 = trig.trigTime + trigPostSec();
// Version-2 binary capture frame from the hub:
// [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig]
// {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]}
function onTriggerCapture(dv, buf, off) {
const trigTime = dv.getFloat64(off, true); off += 8;
const preS = dv.getFloat64(off, true); off += 8;
const postS = dv.getFloat64(off, true); off += 8;
const nSig = dv.getUint32(off, true); off += 4;
const snap = {};
Object.keys(buffers).forEach(key => {
const sl = getBufferSliceRange(buffers[key], t0, t1);
if (sl.t.length > 0) snap[key] = sl;
});
// Tag snapshot with the window parameters captured at trigger time so that
// the render loop uses the correct bounds even if UI controls are changed later.
snap._preS = trigPreSec();
snap._postS = trigPostSec();
for (let s = 0; s < nSig; s++) {
const keyLen = dv.getUint16(off, true); off += 2;
const key = new TextDecoder().decode(new Uint8Array(buf, off, keyLen));
off += keyLen;
const n = dv.getUint32(off, true); off += 4;
// slice() copies — guarantees the 8-byte alignment Float64Array requires.
const t = new Float64Array(buf.slice(off, off + n * 8)); off += n * 8;
const v = new Float64Array(buf.slice(off, off + n * 8)); off += n * 8;
snap[key] = { t, v };
}
// Window parameters latched at fire time (hub-side) so later UI edits do
// not affect how this capture is rendered.
snap._preS = preS;
snap._postS = postS;
trig.trigTime = trigTime;
trig.snapshot = snap;
trig.collecting = false;
setAllCardsCollecting(false); updateTrigStatusBadge('triggered');
showRearmBtn(trig.mode === 'single');
showStopBtn(trig.mode === 'normal');
// Show cursor button now that snapshot exists (positions preserved from before)
updateCursorBtnVisibility();
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
if (trig.mode === 'normal' && !trig.stopped)
setTimeout(() => { if (trig.enabled && trig.mode === 'normal' && !trig.stopped) trigArm(); }, 200);
// Upgrade the snapshot in the background with full-resolution ring data.
// The push buffer has min Δt ≈ 16 µs; the ring provides ≈ 1.65 µs.
upgradeTriggerSnapshot(t0, t1, trig.trigTime);
}
// Fetches full-resolution ring data for the trigger window and replaces the
// push-buffer entries in trig.snapshot, then re-renders all plots.
async function upgradeTriggerSnapshot(t0, t1, capturedTrigTime) {
// Small delay so the ring receives the last post-trigger samples
// (ring write period ≈ 33 ms; 120 ms gives ≥ 3 ticks of margin).
await new Promise(r => setTimeout(r, 120));
// Abort if a new trigger has fired since we started.
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
const keys = Object.keys(buffers);
if (!keys.length) return;
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
let ringSignals;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const json = await resp.json();
ringSignals = json && json.signals;
} catch (e) {
console.warn('trigger snapshot ring upgrade failed:', e);
return;
}
// Abort if the snapshot was replaced while we were fetching.
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
let updated = false;
Object.entries(ringSignals || {}).forEach(([key, sd]) => {
if (!sd || !sd.t || !sd.t.length) return;
trig.snapshot[key] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
updated = true;
});
if (updated) plots.forEach(p => { p.needsRedraw = true; });
}
function trigArm() {
// Do NOT clear trig.snapshot here — keep the last waveform and trigger marker
// visible while waiting for the next event. fireTrigger() clears it when a new
// trigger actually fires.
trig.armed = true; trig.collecting = false; trig.prevVal = null;
// visible while waiting for the next event. The "collecting" triggerState
// broadcast clears it when a new trigger actually fires.
trig.armed = true; trig.collecting = false;
showRearmBtn(false); updateTrigStatusBadge('armed');
updateCursorBtnVisibility();
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
sendTrigConfig();
wsSend({ type: 'arm' });
}
function trigDisarm() {
trig.armed = false; trig.collecting = false; trig.trigTime = null; trig.stopped = false;
setAllCardsCollecting(false); showRearmBtn(false); showStopBtn(false); updateTrigStatusBadge('idle');
wsSend({ type: 'disarm' });
}
function setAllCardsCollecting(on) {
document.querySelectorAll('.plot-card').forEach(c => c.classList.toggle('trig-collecting', on));
@@ -915,15 +935,10 @@ async function doZoomFetch(t0, t1) {
if (allKeys.size === 0) return;
const targetPts = Math.max(400, (plots.find(p => p.uplot)?.uplot?.width || 600) * 2);
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=${targetPts}&signals=${[...allKeys].join(',')}`;
let fetched;
let sigs;
try {
const resp = await fetch(url);
if (!resp.ok) return;
fetched = await resp.json();
sigs = await wsZoomRequest(t0, t1, targetPts, [...allKeys]);
} catch (e) { console.warn('zoom fetch:', e); return; }
const sigs = fetched && fetched.signals;
if (!sigs) return;
// Convert arrays from JSON to Float64Array and store per-plot.
@@ -2142,7 +2157,7 @@ document.getElementById('trig-signal').addEventListener('change', e => {
const n = meta ? numElements(meta) : 1;
if (meta && !isTemporal(meta) && n > 1) {
showArrayIdxPicker(val, n, idx => {
trig.signal = val + '[' + idx + ']'; trig.prevVal = null;
trig.signal = val + '[' + idx + ']'; sendTrigConfig();
if (trig.enabled) trigArm();
}, () => {
// Cancelled: revert selection to current trig.signal base or empty.
@@ -2152,18 +2167,20 @@ document.getElementById('trig-signal').addEventListener('change', e => {
});
return;
}
trig.signal = val; trig.prevVal = null;
trig.signal = val; sendTrigConfig();
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
});
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; if (trig.armed) trig.prevVal = null; });
document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); });
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; sendTrigConfig(); });
document.getElementById('trig-threshold').addEventListener('change', e => { trig.threshold = parseFloat(e.target.value) || 0; sendTrigConfig(); });
document.getElementById('trig-window').addEventListener('change', e => { trig.windowSec = parseFloat(e.target.value); sendTrigConfig(); });
document.getElementById('trig-pre').addEventListener('input', e => {
trig.prePercent = parseInt(e.target.value, 10);
document.getElementById('trig-pre-val').textContent = trig.prePercent + '%';
sendTrigConfig();
});
document.getElementById('trig-mode').addEventListener('change', e => {
trig.mode = e.target.value;
sendTrigConfig();
// If a snapshot already exists the trigger has already fired — update button
// visibility immediately so it matches the newly selected mode.
if (trig.snapshot) {
@@ -2177,6 +2194,7 @@ document.getElementById('btn-trig-stop').addEventListener('click', () => {
if (!trig.enabled || trig.mode !== 'normal') return;
trig.stopped = !trig.stopped;
updateStopBtn();
wsSend({ type: 'trigStop', stopped: trig.stopped });
if (!trig.stopped) trigArm(); // resume: re-arm immediately
});
@@ -2581,14 +2599,9 @@ async function exportAllCSV() {
btn.disabled = true;
// Fetch full-resolution ring data (n=0 → no LTTB decimation).
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
let ringSignals = null;
try {
const resp = await fetch(url);
if (resp.ok) {
const json = await resp.json();
ringSignals = json && json.signals;
}
ringSignals = await wsZoomRequest(t0, t1, 0, keys);
} catch (e) {
console.warn('CSV export: ring fetch failed, falling back to push buffer', e);
} finally {
@@ -3449,7 +3462,7 @@ document.getElementById('stats-source-sel').addEventListener('change', e => {
statsSelectedSrc = e.target.value || null;
renderStats();
});
connectWS();
resolveHub().then(connectWS);
requestAnimationFrame(renderDirtyPlots);
fetch('/version').then(r => r.text()).then(v => {
document.getElementById('build-version').textContent = 'v' + v;