Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2370848994 | ||
|
|
ff5ad22447 |
@@ -89,6 +89,15 @@ function getVScale(plotId, key) {
|
|||||||
return sigVScale[vsKey];
|
return sigVScale[vsKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Round a raw units-per-division up to the next 1/2/5×10ⁿ step so the Y-axis
|
||||||
|
// gridlines land on human-readable values.
|
||||||
|
function niceDiv(x) {
|
||||||
|
if (!isFinite(x) || x <= 0) return 1;
|
||||||
|
const p = Math.pow(10, Math.floor(Math.log10(x)));
|
||||||
|
const m = x / p;
|
||||||
|
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p;
|
||||||
|
}
|
||||||
|
|
||||||
function findSignalMeta(key) {
|
function findSignalMeta(key) {
|
||||||
const colon = key.indexOf(':');
|
const colon = key.indexOf(':');
|
||||||
if (colon < 0) return null;
|
if (colon < 0) return null;
|
||||||
@@ -107,8 +116,8 @@ function resolveVScale(plotId, key, rawY) {
|
|||||||
if (vs.mode === 'range') {
|
if (vs.mode === 'range') {
|
||||||
const meta = findSignalMeta(key);
|
const meta = findSignalMeta(key);
|
||||||
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
|
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
|
||||||
const divValue = (meta.rangeMax - meta.rangeMin) / 8;
|
const divValue = niceDiv((meta.rangeMax - meta.rangeMin) / 8);
|
||||||
const offset = (meta.rangeMin + meta.rangeMax) / 2;
|
const offset = Math.round((meta.rangeMin + meta.rangeMax) / 2 / divValue) * divValue;
|
||||||
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||||
return { divValue, offset, screenPos };
|
return { divValue, offset, screenPos };
|
||||||
}
|
}
|
||||||
@@ -120,7 +129,9 @@ function resolveVScale(plotId, key, rawY) {
|
|||||||
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||||
return { divValue, offset, screenPos };
|
return { divValue, offset, screenPos };
|
||||||
}
|
}
|
||||||
// Auto: fit data in central 6 of 8 divisions, centered at screenPos
|
// Auto: fit data in central 6 of 8 divisions, centered at screenPos.
|
||||||
|
// Both the V/div and the centre offset are snapped so gridlines (and the
|
||||||
|
// zero line, when in view) fall on round values.
|
||||||
let min = Infinity, max = -Infinity;
|
let min = Infinity, max = -Infinity;
|
||||||
for (let i = 0; i < rawY.length; i++) {
|
for (let i = 0; i < rawY.length; i++) {
|
||||||
const v = rawY[i];
|
const v = rawY[i];
|
||||||
@@ -128,8 +139,8 @@ function resolveVScale(plotId, key, rawY) {
|
|||||||
}
|
}
|
||||||
if (!isFinite(min)) { min = -1; max = 1; }
|
if (!isFinite(min)) { min = -1; max = 1; }
|
||||||
if (min === max) { min -= 1; max += 1; }
|
if (min === max) { min -= 1; max += 1; }
|
||||||
const divValue = Math.max((max - min) / 6, 1e-30);
|
const divValue = niceDiv(Math.max((max - min) / 6, 1e-30));
|
||||||
const offset = (max + min) / 2;
|
const offset = Math.round((max + min) / 2 / divValue) * divValue;
|
||||||
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||||
return { divValue, offset, screenPos };
|
return { divValue, offset, screenPos };
|
||||||
}
|
}
|
||||||
@@ -285,6 +296,12 @@ let _zoomFetchTimer = null;
|
|||||||
// trig mode → relative seconds from trigger
|
// trig mode → relative seconds from trigger
|
||||||
const cursors = { mode: 'off', tA: null, tB: null };
|
const cursors = { mode: 'off', tA: null, tB: null };
|
||||||
let cursorsDirty = false; // if true, redraw all plots to update cursor lines
|
let cursorsDirty = false; // if true, redraw all plots to update cursor lines
|
||||||
|
// Rolling-window anchor used to keep cursors visually fixed while live data scrolls.
|
||||||
|
let _cursorAnchorNow = null;
|
||||||
|
|
||||||
|
// Horizontal value rulers — stored in normalized division units (the shared
|
||||||
|
// y scale, -4.5…4.5) so one pair applies to every plot regardless of V/div.
|
||||||
|
const rulers = { mode: 'off', yA: null, yB: null };
|
||||||
|
|
||||||
// Layout — [label, cssClass, cols, rows]
|
// Layout — [label, cssClass, cols, rows]
|
||||||
const LAYOUTS = [
|
const LAYOUTS = [
|
||||||
@@ -329,7 +346,15 @@ async function resolveHub() {
|
|||||||
function connectWS() {
|
function connectWS() {
|
||||||
ws = new WebSocket('ws://' + HUB + '/ws');
|
ws = new WebSocket('ws://' + HUB + '/ws');
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
ws.onopen = () => { wsBackoff = 1000; setStatus('orange', 'Connected – waiting for data'); };
|
ws.onopen = () => {
|
||||||
|
wsBackoff = 1000;
|
||||||
|
setStatus('orange', 'Connected – waiting for data');
|
||||||
|
// Restore monotonic TS preference from localStorage.
|
||||||
|
const monoPref = localStorage.getItem('udpscope.monotonic') === '1';
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'setMonotonic', enabled: monoPref }));
|
||||||
|
}
|
||||||
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setStatus('red', 'Disconnected (reconnecting…)');
|
setStatus('red', 'Disconnected (reconnecting…)');
|
||||||
setTimeout(connectWS, wsBackoff);
|
setTimeout(connectWS, wsBackoff);
|
||||||
@@ -346,10 +371,26 @@ function connectWS() {
|
|||||||
else if (msg.type === 'zoom') onZoomReply(msg);
|
else if (msg.type === 'zoom') onZoomReply(msg);
|
||||||
else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
|
else if (msg.type === 'historyZoom') onHistoryZoomReply(msg);
|
||||||
else if (msg.type === 'historyInfo') onHistoryInfo(msg);
|
else if (msg.type === 'historyInfo') onHistoryInfo(msg);
|
||||||
else if (msg.type === 'triggerState') onTriggerState(msg);
|
else if (msg.type === 'monotonicState') onMonotonicState(msg);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Monotonic timestamp snapping — when enabled, the hub snaps small inter-frame
|
||||||
|
timestamp deviations (< 5 ms) to the ideal gap, eliminating overlaps/gaps
|
||||||
|
caused by software-dispatch jitter. */
|
||||||
|
function onMonotonicState(msg) {
|
||||||
|
const cb = document.getElementById('cb-monotonic');
|
||||||
|
if (!cb) return;
|
||||||
|
cb.checked = !!msg.enabled;
|
||||||
|
localStorage.setItem('udpscope.monotonic', msg.enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
document.getElementById('cb-monotonic').addEventListener('change', e => {
|
||||||
|
localStorage.setItem('udpscope.monotonic', e.target.checked ? '1' : '0');
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'setMonotonic', enabled: e.target.checked }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* WS zoom request/reply — replaces the Go hub's /api/zoom HTTP endpoint.
|
/* 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. */
|
Resolves with the {key:{t,v}} signals map; rejects on timeout/closure. */
|
||||||
let _zoomReqId = 0;
|
let _zoomReqId = 0;
|
||||||
@@ -882,24 +923,41 @@ function makeSeriesPath(key) {
|
|||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
LTTB Web Worker — offloads decimation off the main thread.
|
LTTB Web Worker — offloads decimation off the main thread.
|
||||||
Cache key: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>"
|
Cache key: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>" when zoomed, or
|
||||||
|
"<plotId>:<masterKey>:rolling" in live mode, where the data
|
||||||
|
generation is passed alongside instead of being part of the key.
|
||||||
On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate).
|
On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate).
|
||||||
On cache-miss → render falls back to sync lttb once (first zoom render only),
|
On cache-miss → render falls back to sync lttb once (first render only),
|
||||||
then worker takes over for subsequent updates.
|
then worker takes over for subsequent updates.
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
const lttbCache = new Map(); // key → {t, v}
|
const lttbCache = new Map(); // key → {t, v, gen}
|
||||||
const lttbPending = new Set(); // keys currently in-flight
|
const lttbPending = new Map(); // key → generation currently in flight
|
||||||
|
// Hard ceiling on cached decimations. Rolling-mode keys are stable (one entry
|
||||||
|
// per plot), but zoom keys embed the range, so without a cap the map would grow
|
||||||
|
// for the whole session.
|
||||||
|
const LTTB_CACHE_MAX = 256;
|
||||||
|
|
||||||
|
// Store a decimation, re-inserting so Map iteration order stays oldest-first.
|
||||||
|
function lttbCacheStore(key, entry) {
|
||||||
|
lttbCache.delete(key);
|
||||||
|
lttbCache.set(key, entry);
|
||||||
|
while (lttbCache.size > LTTB_CACHE_MAX) {
|
||||||
|
lttbCache.delete(lttbCache.keys().next().value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _lttbWorker = null;
|
let _lttbWorker = null;
|
||||||
try {
|
try {
|
||||||
_lttbWorker = new Worker('lttb-worker.js');
|
_lttbWorker = new Worker('lttb-worker.js');
|
||||||
_lttbWorker.onmessage = function({ data: { id, t, v } }) {
|
_lttbWorker.onmessage = function({ data: { id, t, v } }) {
|
||||||
|
const gen = lttbPending.get(id);
|
||||||
lttbPending.delete(id);
|
lttbPending.delete(id);
|
||||||
lttbCache.set(id, { t, v });
|
lttbCacheStore(id, { t, v, gen });
|
||||||
// Invalidate and redraw the owning plot.
|
// Invalidate and redraw the owning plot. Clearing lastDataGen defeats the
|
||||||
|
// render loop's no-new-data fast path so this fresh result is actually drawn.
|
||||||
const plotId = parseInt(id.split(':')[0], 10);
|
const plotId = parseInt(id.split(':')[0], 10);
|
||||||
const p = plots.find(q => q.id === plotId);
|
const p = plots.find(q => q.id === plotId);
|
||||||
if (p) { p.needsRedraw = true; }
|
if (p) { p.needsRedraw = true; p.lastDataGen = -1; }
|
||||||
};
|
};
|
||||||
_lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e);
|
_lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -907,14 +965,20 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Submit a LTTB job to the worker (or run sync if worker unavailable).
|
// Submit a LTTB job to the worker (or run sync if worker unavailable).
|
||||||
// Returns cached {t, v} if fresh, null if a worker job was just submitted,
|
// `gen` identifies the input data behind a key that does not itself change with
|
||||||
// or a sync result if the worker is unavailable.
|
// the data (rolling mode). A cached entry computed for an older generation is
|
||||||
function lttbAsync(cacheKey, t, v, threshold) {
|
// still returned — stale-while-revalidate — while a fresh job runs. Callers
|
||||||
|
// whose key already encodes the input (zoom ranges) pass no generation.
|
||||||
|
// Returns the cached {t, v} (fresh or stale), or null on the first render.
|
||||||
|
function lttbAsync(cacheKey, t, v, threshold, gen) {
|
||||||
const cached = lttbCache.get(cacheKey);
|
const cached = lttbCache.get(cacheKey);
|
||||||
if (cached) return cached; // cache hit — use immediately
|
if (cached && cached.gen === gen) return cached; // fresh — nothing to do
|
||||||
|
|
||||||
|
// At most one job per key in flight: submitting on every generation change
|
||||||
|
// would let the worker's message queue grow without bound whenever it cannot
|
||||||
|
// keep up with the push rate.
|
||||||
if (!lttbPending.has(cacheKey)) {
|
if (!lttbPending.has(cacheKey)) {
|
||||||
lttbPending.add(cacheKey);
|
lttbPending.set(cacheKey, gen);
|
||||||
if (_lttbWorker) {
|
if (_lttbWorker) {
|
||||||
// Send copies so the main thread retains the originals.
|
// Send copies so the main thread retains the originals.
|
||||||
const tCopy = new Float64Array(t);
|
const tCopy = new Float64Array(t);
|
||||||
@@ -924,12 +988,13 @@ function lttbAsync(cacheKey, t, v, threshold) {
|
|||||||
} else {
|
} else {
|
||||||
// Synchronous fallback (worker unavailable).
|
// Synchronous fallback (worker unavailable).
|
||||||
const result = lttb(t, v, threshold);
|
const result = lttb(t, v, threshold);
|
||||||
lttbCache.set(cacheKey, result);
|
result.gen = gen;
|
||||||
|
lttbCacheStore(cacheKey, result);
|
||||||
lttbPending.delete(cacheKey);
|
lttbPending.delete(cacheKey);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null; // worker job in-flight — caller should use fallback
|
return cached || null; // stale entry, or nothing to draw yet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evict stale LTTB cache entries for a plot (call when zoom range changes).
|
// Evict stale LTTB cache entries for a plot (call when zoom range changes).
|
||||||
@@ -938,7 +1003,7 @@ function lttbCacheEvict(plotId) {
|
|||||||
for (const k of [...lttbCache.keys()]) {
|
for (const k of [...lttbCache.keys()]) {
|
||||||
if (k.startsWith(prefix)) lttbCache.delete(k);
|
if (k.startsWith(prefix)) lttbCache.delete(k);
|
||||||
}
|
}
|
||||||
for (const k of [...lttbPending]) {
|
for (const k of [...lttbPending.keys()]) {
|
||||||
if (k.startsWith(prefix)) lttbPending.delete(k);
|
if (k.startsWith(prefix)) lttbPending.delete(k);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1447,6 +1512,51 @@ function drawCursorLines(u, p) {
|
|||||||
drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B');
|
drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a normalized (division) y value to the active signal's raw units.
|
||||||
|
function rulerRawValue(p, yNorm) {
|
||||||
|
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
|
||||||
|
const vs = key ? sigVScale[p.id + ':' + key] : null;
|
||||||
|
if (!vs) return null;
|
||||||
|
const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1);
|
||||||
|
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||||
|
return (yNorm - (vs.screenPos || 0)) * dv + ofs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the horizontal value rulers (called from the draw hook).
|
||||||
|
function drawRulerLines(u, p) {
|
||||||
|
if (rulers.mode !== 'on') return;
|
||||||
|
const { ctx, bbox } = u;
|
||||||
|
if (!bbox) return;
|
||||||
|
|
||||||
|
const drawLine = (yNorm, color, label) => {
|
||||||
|
if (yNorm === null) return;
|
||||||
|
const y = Math.round(u.valToPos(yNorm, 'y', true));
|
||||||
|
if (y < bbox.top || y > bbox.top + bbox.height) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([5, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bbox.left, y);
|
||||||
|
ctx.lineTo(bbox.left + bbox.width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = 'bold 11px monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
const raw = p ? rulerRawValue(p, yNorm) : null;
|
||||||
|
ctx.fillText(label + (raw !== null ? ' ' + _fmtVal(raw) : ''), bbox.left + 4, y - 2);
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
drawLine(rulers.yA, 'rgba(166,227,161,0.85)', 'Y1');
|
||||||
|
drawLine(rulers.yB, 'rgba(243,139,168,0.85)', 'Y2');
|
||||||
|
}
|
||||||
|
|
||||||
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
|
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
|
||||||
// Returns the min-of-max timestamp across ACTIVE sources contributing traces to p,
|
// Returns the min-of-max timestamp across ACTIVE sources contributing traces to p,
|
||||||
// so no live source shows a blank right edge.
|
// so no live source shows a blank right edge.
|
||||||
@@ -1563,7 +1673,7 @@ function makeUPlotOpts(p, inTrigMode) {
|
|||||||
legend: { show: false },
|
legend: { show: false },
|
||||||
padding: [4, 4, 0, 0],
|
padding: [4, 4, 0, 0],
|
||||||
hooks: {
|
hooks: {
|
||||||
draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
|
draw: [u => { drawBandSeparators(u, p); drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawRulerLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
|
||||||
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
|
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
|
||||||
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
|
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
|
||||||
// All programmatic setScale calls happen without a preceding setSelect, so the
|
// All programmatic setScale calls happen without a preceding setSelect, so the
|
||||||
@@ -1617,34 +1727,60 @@ function createUPlot(p) {
|
|||||||
return min + pct * (max - min);
|
return min + pct * (max - min);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update pointer style based on what's under the mouse
|
function _rulerAtClientY(clientY) {
|
||||||
|
const rect = p.uplot.over.getBoundingClientRect();
|
||||||
|
const { min, max } = p.uplot.scales.y;
|
||||||
|
const toY = val => rect.top + (1 - (val - min) / (max - min)) * rect.height;
|
||||||
|
if (rulers.yA !== null && Math.abs(clientY - toY(rulers.yA)) <= CURSOR_SNAP_PX) return 'A';
|
||||||
|
if (rulers.yB !== null && Math.abs(clientY - toY(rulers.yB)) <= CURSOR_SNAP_PX) return 'B';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rulerValFromEvent(e) {
|
||||||
|
const rect = p.uplot.over.getBoundingClientRect();
|
||||||
|
const pct = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||||
|
const { min, max } = p.uplot.scales.y;
|
||||||
|
return max - pct * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pointer style based on what's under the mouse, and drive the
|
||||||
|
// time/value hover readout.
|
||||||
p.uplot.over.addEventListener('mousemove', e => {
|
p.uplot.over.addEventListener('mousemove', e => {
|
||||||
const snap = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
|
const snapX = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
|
||||||
p.uplot.over.style.cursor = snap ? 'ew-resize' : '';
|
const snapY = !snapX && rulers.mode === 'on' ? _rulerAtClientY(e.clientY) : null;
|
||||||
|
p.uplot.over.style.cursor = snapX ? 'ew-resize' : (snapY ? 'ns-resize' : '');
|
||||||
|
showHoverReadout(p, e);
|
||||||
});
|
});
|
||||||
p.uplot.over.addEventListener('mouseleave', () => {
|
p.uplot.over.addEventListener('mouseleave', () => {
|
||||||
p.uplot.over.style.cursor = '';
|
p.uplot.over.style.cursor = '';
|
||||||
|
hideHoverReadout();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mousedown: drag an existing cursor (only when mode='on' and mouse is near a cursor line).
|
// Mousedown: drag an existing cursor (only when mode='on' and mouse is near a cursor line).
|
||||||
// If not near a cursor, the event falls through to uPlot for normal zoom/pan behavior.
|
// If not near a cursor, the event falls through to uPlot for normal zoom/pan behavior.
|
||||||
p.uplot.over.addEventListener('mousedown', e => {
|
p.uplot.over.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0 || e.shiftKey) return; // shift is pan
|
if (e.button !== 0 || e.shiftKey) return; // shift is pan
|
||||||
if (cursors.mode !== 'on') return;
|
const target = cursors.mode === 'on' ? _cursorAtClientX(e.clientX) : null;
|
||||||
const target = _cursorAtClientX(e.clientX);
|
const yTarget = !target && rulers.mode === 'on' ? _rulerAtClientY(e.clientY) : null;
|
||||||
if (!target) return; // not near a cursor — let uPlot handle zoom
|
if (!target && !yTarget) return; // not near a cursor — let uPlot handle zoom
|
||||||
|
|
||||||
e.stopImmediatePropagation(); // prevent uPlot drag-zoom
|
e.stopImmediatePropagation(); // prevent uPlot drag-zoom
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Set cursor position immediately on mousedown
|
// Set cursor position immediately on mousedown
|
||||||
if (target === 'A') cursors.tA = _cursorValFromEvent(e);
|
if (yTarget) {
|
||||||
|
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(e);
|
||||||
|
else rulers.yB = _rulerValFromEvent(e);
|
||||||
|
} else if (target === 'A') cursors.tA = _cursorValFromEvent(e);
|
||||||
else cursors.tB = _cursorValFromEvent(e);
|
else cursors.tB = _cursorValFromEvent(e);
|
||||||
updateCursorReadout();
|
updateCursorReadout();
|
||||||
cursorsDirty = true;
|
cursorsDirty = true;
|
||||||
|
|
||||||
const onMove = ev => {
|
const onMove = ev => {
|
||||||
if (target === 'A') cursors.tA = _cursorValFromEvent(ev);
|
if (yTarget) {
|
||||||
|
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(ev);
|
||||||
|
else rulers.yB = _rulerValFromEvent(ev);
|
||||||
|
} else if (target === 'A') cursors.tA = _cursorValFromEvent(ev);
|
||||||
else cursors.tB = _cursorValFromEvent(ev);
|
else cursors.tB = _cursorValFromEvent(ev);
|
||||||
updateCursorReadout();
|
updateCursorReadout();
|
||||||
cursorsDirty = true;
|
cursorsDirty = true;
|
||||||
@@ -1871,10 +2007,12 @@ function buildLiveData(p) {
|
|||||||
// the full window slice can easily reach 100k–300k pts — far more than uPlot
|
// the full window slice can easily reach 100k–300k pts — far more than uPlot
|
||||||
// needs for a 1200px-wide canvas. Always run LTTB via the background worker
|
// needs for a 1200px-wide canvas. Always run LTTB via the background worker
|
||||||
// (stale-while-revalidate: use cached result; fall back to sync on first render).
|
// (stale-while-revalidate: use cached result; fall back to sync on first render).
|
||||||
// Rolling-mode cache key uses _dataGen so the result refreshes on new data.
|
// The rolling-mode key is constant per (plot, signal) — the data generation is
|
||||||
|
// carried separately so the cache holds one entry per plot instead of one per
|
||||||
|
// push tick, which used to grow without bound for the whole session.
|
||||||
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
|
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
|
||||||
const cacheKey = isRolling
|
const cacheKey = isRolling
|
||||||
? `${p.id}:${masterKey}:rolling:${_dataGen}`
|
? `${p.id}:${masterKey}:rolling`
|
||||||
: `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
|
: `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
|
||||||
let sharedT, masterV;
|
let sharedT, masterV;
|
||||||
if (masterRaw.t.length <= targetPts) {
|
if (masterRaw.t.length <= targetPts) {
|
||||||
@@ -1882,7 +2020,8 @@ function buildLiveData(p) {
|
|||||||
sharedT = masterRaw.t;
|
sharedT = masterRaw.t;
|
||||||
masterV = masterRaw.v;
|
masterV = masterRaw.v;
|
||||||
} else {
|
} else {
|
||||||
const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
|
const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts,
|
||||||
|
isRolling ? _dataGen : undefined);
|
||||||
let dec;
|
let dec;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
dec = cached;
|
dec = cached;
|
||||||
@@ -2094,19 +2233,10 @@ document.getElementById('btn-zoom-fit').addEventListener('click', zoomFit);
|
|||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
Cursor controls
|
Cursor controls
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
// Show the cursor button only when paused or in trigger-snapshot mode.
|
// Cursors are always available — in live rolling mode they are pinned to the
|
||||||
|
// moving viewport by the render loop.
|
||||||
function updateCursorBtnVisibility() {
|
function updateCursorBtnVisibility() {
|
||||||
const canUseCursors = globalPause || (trig.enabled && trig.snapshot !== null);
|
document.getElementById('btn-cursor').style.display = '';
|
||||||
const btn = document.getElementById('btn-cursor');
|
|
||||||
btn.style.display = canUseCursors ? '' : 'none';
|
|
||||||
if (!canUseCursors && cursors.mode !== 'off') {
|
|
||||||
cursors.mode = 'off';
|
|
||||||
cursors.tA = null; cursors.tB = null; // context changed, clear positions
|
|
||||||
btn.textContent = 'Cursors';
|
|
||||||
btn.classList.remove('active');
|
|
||||||
document.getElementById('cursor-readout').classList.remove('visible');
|
|
||||||
cursorsDirty = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('btn-cursor').addEventListener('click', () => {
|
document.getElementById('btn-cursor').addEventListener('click', () => {
|
||||||
@@ -2133,6 +2263,18 @@ document.getElementById('btn-cursor').addEventListener('click', () => {
|
|||||||
cursorsDirty = true;
|
cursorsDirty = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-ruler').addEventListener('click', () => {
|
||||||
|
rulers.mode = rulers.mode === 'off' ? 'on' : 'off';
|
||||||
|
const btn = document.getElementById('btn-ruler');
|
||||||
|
btn.classList.toggle('active', rulers.mode === 'on');
|
||||||
|
if (rulers.mode === 'on' && rulers.yA === null && rulers.yB === null) {
|
||||||
|
// Auto-place at ±2 divisions from the centre on first use.
|
||||||
|
rulers.yA = -2; rulers.yB = 2;
|
||||||
|
}
|
||||||
|
updateCursorReadout();
|
||||||
|
cursorsDirty = true;
|
||||||
|
});
|
||||||
|
|
||||||
// Format a signal value for the per-plot cursor readout.
|
// Format a signal value for the per-plot cursor readout.
|
||||||
function fmtVal(v) {
|
function fmtVal(v) {
|
||||||
if (v === null || v === undefined) return '—';
|
if (v === null || v === undefined) return '—';
|
||||||
@@ -2178,11 +2320,80 @@ function updatePlotCursorReadouts() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Hover readout ──────────────────────────────────────────────────────── */
|
||||||
|
// Un-normalize a plotted value of trace `key` in plot `p` back to raw units.
|
||||||
|
function rawFromNorm(p, key, vNorm) {
|
||||||
|
const vs = sigVScale[p.id + ':' + key];
|
||||||
|
if (!vs) return vNorm;
|
||||||
|
const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1);
|
||||||
|
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||||
|
return (vNorm - (vs.screenPos || 0)) * dv + ofs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHoverReadout() {
|
||||||
|
document.getElementById('hover-readout').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the time under the mouse plus every trace's value at that time.
|
||||||
|
function showHoverReadout(p, e) {
|
||||||
|
const el = document.getElementById('hover-readout');
|
||||||
|
if (!p.uplot || p.traces.length === 0) { el.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
const rect = p.uplot.over.getBoundingClientRect();
|
||||||
|
const { min, max } = p.uplot.scales.x;
|
||||||
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
|
const t = min + pct * (max - min);
|
||||||
|
const span = Math.abs(max - min);
|
||||||
|
|
||||||
|
const tStr = (trig.enabled && trig.snapshot) ? fmtDuration(t, span, true) : fmtLiveTime(t, span);
|
||||||
|
let html = '<div class="hov-time">' + escHtml(tStr) + '</div>';
|
||||||
|
p.traces.forEach((key, idx) => {
|
||||||
|
const vNorm = interpAtTime(p.uplot, idx + 1, t);
|
||||||
|
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
|
||||||
|
const val = vNorm === null ? '—' : _fmtVal(rawFromNorm(p, key, vNorm));
|
||||||
|
html += '<div class="hov-row"><span class="hov-dot" style="background:' +
|
||||||
|
escHtml(getSigStyle(key).color) + '"></span>' +
|
||||||
|
'<span class="hov-name">' + escHtml(name) + '</span>' +
|
||||||
|
'<span class="hov-val">' + escHtml(val) + '</span></div>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.style.display = 'block';
|
||||||
|
|
||||||
|
// Keep the tooltip inside the viewport.
|
||||||
|
const w = el.offsetWidth, h = el.offsetHeight;
|
||||||
|
let x = e.clientX + 14, y = e.clientY + 14;
|
||||||
|
if (x + w > window.innerWidth - 4) x = e.clientX - w - 14;
|
||||||
|
if (y + h > window.innerHeight - 4) y = e.clientY - h - 14;
|
||||||
|
el.style.left = Math.max(4, x) + 'px';
|
||||||
|
el.style.top = Math.max(4, y) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the first
|
||||||
|
// plot that has an active (or sole) signal.
|
||||||
|
function updateRulerReadout() {
|
||||||
|
const box = document.getElementById('ruler-readout');
|
||||||
|
const on = rulers.mode === 'on';
|
||||||
|
box.style.display = on ? '' : 'none';
|
||||||
|
if (!on) return;
|
||||||
|
const ref = plots.find(p => p.uplot && p.traces.length > 0 &&
|
||||||
|
rulerRawValue(p, 0) !== null);
|
||||||
|
const conv = y => (y === null || !ref) ? null : rulerRawValue(ref, y);
|
||||||
|
const vA = conv(rulers.yA), vB = conv(rulers.yB);
|
||||||
|
document.getElementById('cur-y1').textContent = 'Y1: ' + fmtVal(vA);
|
||||||
|
document.getElementById('cur-y2').textContent = 'Y2: ' + fmtVal(vB);
|
||||||
|
document.getElementById('cur-dy').textContent =
|
||||||
|
'ΔY: ' + fmtVal(vA !== null && vB !== null ? vB - vA : null);
|
||||||
|
}
|
||||||
|
|
||||||
function updateCursorReadout() {
|
function updateCursorReadout() {
|
||||||
const ro = document.getElementById('cursor-readout');
|
const ro = document.getElementById('cursor-readout');
|
||||||
const active = cursors.mode === 'on';
|
const active = cursors.mode === 'on';
|
||||||
ro.classList.toggle('visible', active);
|
ro.classList.toggle('visible', active || rulers.mode === 'on');
|
||||||
updatePlotCursorReadouts();
|
updatePlotCursorReadouts();
|
||||||
|
updateRulerReadout();
|
||||||
|
['cur-ta', 'cur-tb', 'cur-dt'].forEach(id => {
|
||||||
|
document.getElementById(id).style.display = active ? '' : 'none';
|
||||||
|
});
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
// Use the current visible x-range to pick the display unit.
|
// Use the current visible x-range to pick the display unit.
|
||||||
@@ -2286,6 +2497,12 @@ document.getElementById('trig-mode').addEventListener('change', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); });
|
document.getElementById('btn-trig-rearm').addEventListener('click', () => { if (trig.enabled) trigArm(); });
|
||||||
|
// Force: capture the window around the newest sample regardless of the threshold.
|
||||||
|
document.getElementById('btn-trig-force').addEventListener('click', () => {
|
||||||
|
if (!trig.enabled || !trig.signal) return;
|
||||||
|
sendTrigConfig();
|
||||||
|
wsSend({ type: 'forceTrigger' });
|
||||||
|
});
|
||||||
document.getElementById('btn-trig-stop').addEventListener('click', () => {
|
document.getElementById('btn-trig-stop').addEventListener('click', () => {
|
||||||
if (!trig.enabled || trig.mode !== 'normal') return;
|
if (!trig.enabled || trig.mode !== 'normal') return;
|
||||||
trig.stopped = !trig.stopped;
|
trig.stopped = !trig.stopped;
|
||||||
@@ -2943,6 +3160,22 @@ function renderDirtyPlots() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live rolling mode: pin the cursors to the moving viewport so they stay put
|
||||||
|
// on screen instead of scrolling off the left edge as time advances.
|
||||||
|
if (cursors.mode === 'on' && !trig.enabled && !globalPause &&
|
||||||
|
plots.some(p => p.uplot && !p.xRange && p.traces.length > 0)) {
|
||||||
|
if (_cursorAnchorNow !== null && globalPlotNow !== _cursorAnchorNow) {
|
||||||
|
const shift = globalPlotNow - _cursorAnchorNow;
|
||||||
|
if (cursors.tA !== null) cursors.tA += shift;
|
||||||
|
if (cursors.tB !== null) cursors.tB += shift;
|
||||||
|
cursorsDirty = true;
|
||||||
|
updateCursorReadout();
|
||||||
|
}
|
||||||
|
_cursorAnchorNow = globalPlotNow;
|
||||||
|
} else {
|
||||||
|
_cursorAnchorNow = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Fast path: cursor-only redraw (no data rebuild needed)
|
// Fast path: cursor-only redraw (no data rebuild needed)
|
||||||
if (cursorsDirty) {
|
if (cursorsDirty) {
|
||||||
cursorsDirty = false;
|
cursorsDirty = false;
|
||||||
@@ -3204,11 +3437,15 @@ function showVScaleMenu(key, plotId) {
|
|||||||
|
|
||||||
const isManual = vs.mode === 'manual';
|
const isManual = vs.mode === 'manual';
|
||||||
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
||||||
|
document.getElementById('vscale-offset-row').style.display = isManual ? 'flex' : 'none';
|
||||||
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
|
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
|
||||||
|
|
||||||
// Pre-fill V/div with resolved or stored value; Position always shows current screenPos.
|
// Pre-fill V/div and Offset with the resolved or stored values; Position
|
||||||
|
// always shows the current screenPos.
|
||||||
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
|
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
|
||||||
|
const ofs = isManual ? (vs.offset || 0) : (vs._resolvedOffset || 0);
|
||||||
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
|
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
|
||||||
|
document.getElementById('vscale-offset').value = parseFloat(ofs.toPrecision(6));
|
||||||
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
||||||
|
|
||||||
// Type row (Analog/Digital) only shown in mixed mode.
|
// Type row (Analog/Digital) only shown in mixed mode.
|
||||||
@@ -3293,8 +3530,9 @@ function initVScaleMenu() {
|
|||||||
if (newMode === 'manual' && vs.mode !== 'manual') {
|
if (newMode === 'manual' && vs.mode !== 'manual') {
|
||||||
// Seed V/div from currently resolved value; screenPos stays as-is.
|
// Seed V/div from currently resolved value; screenPos stays as-is.
|
||||||
vs.divValue = vs._resolvedDiv || 1;
|
vs.divValue = vs._resolvedDiv || 1;
|
||||||
vs.offset = vs._resolvedOffset || 0; // keep for DC subtraction (internal)
|
vs.offset = vs._resolvedOffset || 0; // raw value at screen centre
|
||||||
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
|
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
|
||||||
|
document.getElementById('vscale-offset').value = parseFloat(vs.offset.toPrecision(6));
|
||||||
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
||||||
}
|
}
|
||||||
vs.mode = newMode;
|
vs.mode = newMode;
|
||||||
@@ -3302,6 +3540,7 @@ function initVScaleMenu() {
|
|||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
const isManual = vs.mode === 'manual';
|
const isManual = vs.mode === 'manual';
|
||||||
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
||||||
|
document.getElementById('vscale-offset-row').style.display = isManual ? 'flex' : 'none';
|
||||||
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
|
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
|
||||||
refreshPlotForKey(_vsMenuKey);
|
refreshPlotForKey(_vsMenuKey);
|
||||||
});
|
});
|
||||||
@@ -3312,6 +3551,16 @@ function initVScaleMenu() {
|
|||||||
vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
|
vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
|
||||||
refreshPlotForKey(_vsMenuKey);
|
refreshPlotForKey(_vsMenuKey);
|
||||||
});
|
});
|
||||||
|
// "Offset" is the raw value shown at screen centre. Unlike Position (which
|
||||||
|
// is clamped to the ±4 visible divisions) it is unbounded, so a signal can be
|
||||||
|
// referenced to a level far outside the currently plotted range.
|
||||||
|
document.getElementById('vscale-offset').addEventListener('input', e => {
|
||||||
|
if (!_vsMenuKey) return;
|
||||||
|
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
|
||||||
|
const v = parseFloat(e.target.value);
|
||||||
|
vs.offset = isFinite(v) ? v : 0;
|
||||||
|
refreshPlotForKey(_vsMenuKey);
|
||||||
|
});
|
||||||
// "Position (div)" moves the marker and signal together on screen.
|
// "Position (div)" moves the marker and signal together on screen.
|
||||||
document.getElementById('vscale-pos').addEventListener('input', e => {
|
document.getElementById('vscale-pos').addEventListener('input', e => {
|
||||||
if (!_vsMenuKey) return;
|
if (!_vsMenuKey) return;
|
||||||
@@ -3551,6 +3800,11 @@ buildSidebar(); // show "Add Source" section even before WS connection
|
|||||||
initArrayIdxPicker();
|
initArrayIdxPicker();
|
||||||
initVScaleMenu();
|
initVScaleMenu();
|
||||||
initSignalMenu();
|
initSignalMenu();
|
||||||
|
// Restore monotonic TS checkbox from localStorage (visual state before WS reply).
|
||||||
|
{
|
||||||
|
const cb = document.getElementById('cb-monotonic');
|
||||||
|
if (cb) cb.checked = localStorage.getItem('udpscope.monotonic') === '1';
|
||||||
|
}
|
||||||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||||||
document.getElementById('btn-stats').addEventListener('click', toggleStats);
|
document.getElementById('btn-stats').addEventListener('click', toggleStats);
|
||||||
document.getElementById('btn-stats-close').addEventListener('click', toggleStats);
|
document.getElementById('btn-stats-close').addEventListener('click', toggleStats);
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
<span id="cur-ta">A: —</span><span class="cur-sep">│</span>
|
<span id="cur-ta">A: —</span><span class="cur-sep">│</span>
|
||||||
<span id="cur-tb">B: —</span><span class="cur-sep">│</span>
|
<span id="cur-tb">B: —</span><span class="cur-sep">│</span>
|
||||||
<span id="cur-dt">ΔT: —</span>
|
<span id="cur-dt">ΔT: —</span>
|
||||||
|
<span id="ruler-readout" style="display:none">
|
||||||
|
<span class="cur-sep">│</span>
|
||||||
|
<span id="cur-y1">Y1: —</span><span class="cur-sep">│</span>
|
||||||
|
<span id="cur-y2">Y2: —</span><span class="cur-sep">│</span>
|
||||||
|
<span id="cur-dy">ΔY: —</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ctrl-label" id="lbl-window">Window:</span>
|
<span class="ctrl-label" id="lbl-window">Window:</span>
|
||||||
<select id="window-select" class="ctrl-select">
|
<select id="window-select" class="ctrl-select">
|
||||||
@@ -28,13 +34,17 @@
|
|||||||
<option value="10">10 s</option><option value="30">30 s</option>
|
<option value="10">10 s</option><option value="30">30 s</option>
|
||||||
<option value="60">60 s</option>
|
<option value="60">60 s</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="btn-cursor" class="ctrl-btn" style="display:none">Cursor</button>
|
<button id="btn-cursor" class="ctrl-btn">Cursors</button>
|
||||||
|
<button id="btn-ruler" class="ctrl-btn" title="Horizontal value rulers">Rulers</button>
|
||||||
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
|
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
|
||||||
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
|
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
|
||||||
<button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button>
|
<button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button>
|
||||||
<button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button>
|
<button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button>
|
||||||
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
|
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
|
||||||
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
|
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
|
||||||
|
<label class="ctrl-check" title="Snap jittery inter-frame timestamps to ideal spacing (eliminates overlaps/gaps from software-dispatch jitter)">
|
||||||
|
<input type="checkbox" id="cb-monotonic"> Sync TS
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<!-- ── Trigger bar ───────────────────────────────────────────── -->
|
<!-- ── Trigger bar ───────────────────────────────────────────── -->
|
||||||
<div id="trigbar">
|
<div id="trigbar">
|
||||||
@@ -83,6 +93,7 @@
|
|||||||
<div class="trig-sep"></div>
|
<div class="trig-sep"></div>
|
||||||
<div class="trig-group" style="gap:8px">
|
<div class="trig-group" style="gap:8px">
|
||||||
<span id="trig-status-badge">IDLE</span>
|
<span id="trig-status-badge">IDLE</span>
|
||||||
|
<button id="btn-trig-force" title="Capture now, ignoring the threshold">Force</button>
|
||||||
<button id="btn-trig-stop" style="display:none">Stop</button>
|
<button id="btn-trig-stop" style="display:none">Stop</button>
|
||||||
<button id="btn-trig-rearm">Rearm</button>
|
<button id="btn-trig-rearm">Rearm</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,6 +196,10 @@
|
|||||||
<label class="vstb-lbl">V/div</label>
|
<label class="vstb-lbl">V/div</label>
|
||||||
<input type="number" id="vscale-vdiv" class="ctx-num" min="1e-30" step="any" value="1">
|
<input type="number" id="vscale-vdiv" class="ctx-num" min="1e-30" step="any" value="1">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="vscale-offset-row" style="display:none;align-items:center;gap:4px">
|
||||||
|
<label class="vstb-lbl" title="Raw value at screen centre — unbounded, may lie outside the plotted range">Offset</label>
|
||||||
|
<input type="number" id="vscale-offset" class="ctx-num" step="any" value="0">
|
||||||
|
</div>
|
||||||
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
|
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
|
||||||
<label class="vstb-lbl">Pos</label>
|
<label class="vstb-lbl">Pos</label>
|
||||||
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
|
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
|
||||||
@@ -199,6 +214,8 @@
|
|||||||
<button id="btn-vscale-close" class="vstb-close" title="Close">✕</button>
|
<button id="btn-vscale-close" class="vstb-close" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Follows the mouse over a plot: time + per-trace values. -->
|
||||||
|
<div id="hover-readout" style="display:none"></div>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -52,6 +52,23 @@ html, body { height:100%; background:var(--bg); color:var(--text);
|
|||||||
#cursor-readout.visible { display:flex; }
|
#cursor-readout.visible { display:flex; }
|
||||||
#cur-ta { color:var(--sky); } #cur-tb { color:var(--yellow); }
|
#cur-ta { color:var(--sky); } #cur-tb { color:var(--yellow); }
|
||||||
#cur-dt { color:var(--subtext1); } .cur-sep { color:var(--surface2); }
|
#cur-dt { color:var(--subtext1); } .cur-sep { color:var(--surface2); }
|
||||||
|
#ruler-readout { display:inline-flex; align-items:center; gap:8px; }
|
||||||
|
#cur-y1 { color:var(--green); } #cur-y2 { color:var(--red); }
|
||||||
|
#cur-dy { color:var(--subtext1); }
|
||||||
|
|
||||||
|
/* Mouse-over time/value tooltip */
|
||||||
|
#hover-readout {
|
||||||
|
position:fixed; z-index:60; pointer-events:none;
|
||||||
|
background:var(--surface0); border:1px solid var(--surface1);
|
||||||
|
border-radius:5px; padding:4px 8px;
|
||||||
|
font-size:11px; font-family:monospace; white-space:nowrap;
|
||||||
|
box-shadow:0 4px 12px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
#hover-readout .hov-time { color:var(--subtext1); margin-bottom:3px; }
|
||||||
|
#hover-readout .hov-row { display:flex; align-items:center; gap:6px; }
|
||||||
|
#hover-readout .hov-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||||
|
#hover-readout .hov-name { color:var(--subtext0); }
|
||||||
|
#hover-readout .hov-val { color:var(--text); margin-left:auto; padding-left:10px; }
|
||||||
|
|
||||||
.topbar-vsep { width:1px; height:22px; background:var(--surface0); flex-shrink:0; margin:0 2px; }
|
.topbar-vsep { width:1px; height:22px; background:var(--surface0); flex-shrink:0; margin:0 2px; }
|
||||||
#layout-btns { display:flex; gap:2px; align-items:center; flex-shrink:0; }
|
#layout-btns { display:flex; gap:2px; align-items:center; flex-shrink:0; }
|
||||||
@@ -74,6 +91,12 @@ button.ctrl-btn.trig-active { background:rgba(203,166,247,0.15); border-color:va
|
|||||||
button.ctrl-btn.cursor-a { border-color:var(--sky); color:var(--sky); }
|
button.ctrl-btn.cursor-a { border-color:var(--sky); color:var(--sky); }
|
||||||
button.ctrl-btn.cursor-b { border-color:var(--yellow); color:var(--yellow); }
|
button.ctrl-btn.cursor-b { border-color:var(--yellow); color:var(--yellow); }
|
||||||
button.ctrl-btn.resume-btn { border-color:var(--teal); color:var(--teal); }
|
button.ctrl-btn.resume-btn { border-color:var(--teal); color:var(--teal); }
|
||||||
|
label.ctrl-check {
|
||||||
|
display:flex; align-items:center; gap:4px; flex-shrink:0;
|
||||||
|
font-size:12px; color:var(--subtext0); cursor:pointer; white-space:nowrap;
|
||||||
|
}
|
||||||
|
label.ctrl-check input { margin:0; cursor:pointer; accent-color:var(--accent); }
|
||||||
|
label.ctrl-check:has(input:checked) { color:var(--accent); }
|
||||||
|
|
||||||
/* ── Trigger bar ──────────────────────────────────────────────── */
|
/* ── Trigger bar ──────────────────────────────────────────────── */
|
||||||
#trigbar {
|
#trigbar {
|
||||||
|
|||||||
+195
-74
@@ -107,7 +107,18 @@ func (c *wsClient) readPump() {
|
|||||||
case c.hub.commandCh <- hubCmd{op: "wsSaveSources"}:
|
case c.hub.commandCh <- hubCmd{op: "wsSaveSources"}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
case "setMonotonic":
|
||||||
|
enabled, _ := env["enabled"].(bool)
|
||||||
|
select {
|
||||||
|
case c.hub.commandCh <- hubCmd{op: "setMonotonic", enabled: enabled}:
|
||||||
default:
|
default:
|
||||||
|
}
|
||||||
|
case "zoom":
|
||||||
|
c.hub.handleWSZoom(c, env)
|
||||||
|
default:
|
||||||
|
if c.hub.handleTriggerCommand(t, env) {
|
||||||
|
break
|
||||||
|
}
|
||||||
// Unrecognized message type — forward to DebugCh
|
// Unrecognized message type — forward to DebugCh
|
||||||
select {
|
select {
|
||||||
case c.hub.DebugCh <- msg:
|
case c.hub.DebugCh <- msg:
|
||||||
@@ -182,6 +193,14 @@ type sourceHubState struct {
|
|||||||
// per signal name. Used by the default (TimeModePacket, n>1) path to estimate
|
// per signal name. Used by the default (TimeModePacket, n>1) path to estimate
|
||||||
// per-element dt when only one packet arrives in a 30 Hz tick.
|
// per-element dt when only one packet arrives in a 30 Hz tick.
|
||||||
lastPktNs map[string]int64
|
lastPktNs map[string]int64
|
||||||
|
|
||||||
|
// Monotonic timestamp snapping state (all accessed from Run() goroutine):
|
||||||
|
// lastFrameMeasured — uncorrected measured anchor of the previous frame.
|
||||||
|
// lastFrameEndT — corrected anchor after snapping.
|
||||||
|
// gapEMA — exponential moving average of the measured inter-frame gap.
|
||||||
|
lastFrameMeasured map[string]float64
|
||||||
|
lastFrameEndT map[string]float64
|
||||||
|
gapEMA map[string]float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// taggedSample is a DataSample annotated with its source ID.
|
// taggedSample is a DataSample annotated with its source ID.
|
||||||
@@ -201,6 +220,7 @@ type hubCmd struct {
|
|||||||
sigs []udpsprotocol.SignalInfo
|
sigs []udpsprotocol.SignalInfo
|
||||||
multicastGroup string
|
multicastGroup string
|
||||||
dataPort int
|
dataPort int
|
||||||
|
enabled bool // "setMonotonic" toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub is the central broker between UDP clients and WebSocket clients.
|
// Hub is the central broker between UDP clients and WebSocket clients.
|
||||||
@@ -224,21 +244,17 @@ type Hub struct {
|
|||||||
ringsMu sync.RWMutex
|
ringsMu sync.RWMutex
|
||||||
rings map[string]*sigRing // "sourceId:signalKey" → ring
|
rings map[string]*sigRing // "sourceId:signalKey" → ring
|
||||||
|
|
||||||
// lastZoomAt tracks the last time a zoom request was served.
|
|
||||||
// Ring buffer writes are skipped when no zoom has been requested
|
|
||||||
// in the last 10 s, saving substantial CPU on LTTB + ring writes.
|
|
||||||
lastZoomAt time.Time
|
|
||||||
zoomAtMu sync.Mutex
|
|
||||||
|
|
||||||
statsMu sync.RWMutex
|
statsMu sync.RWMutex
|
||||||
statsMap map[string]*SourceStat
|
statsMap map[string]*SourceStat
|
||||||
|
|
||||||
// onClientConnect, if set, is called each time a new WebSocket client
|
// trigger is the hub-side trigger FSM driving the oscilloscope capture mode.
|
||||||
// registers. The callback receives a send function that delivers a message
|
trigger *triggerEngine
|
||||||
// directly to that client. It is invoked synchronously from Run(), so it
|
|
||||||
// must not block.
|
|
||||||
onClientConnectMu sync.RWMutex
|
onClientConnectMu sync.RWMutex
|
||||||
onClientConnect func(send func([]byte))
|
onClientConnect func(send func([]byte))
|
||||||
|
|
||||||
|
// monotonicTS, when true, snaps small inter-frame timestamp deviations
|
||||||
|
// (< monotonicTolerance) to the ideal gap to eliminate jitter.
|
||||||
|
monotonicTS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates an initialised Hub.
|
// NewHub creates an initialised Hub.
|
||||||
@@ -253,6 +269,7 @@ func NewHub() *Hub {
|
|||||||
DebugCh: make(chan []byte, 256),
|
DebugCh: make(chan []byte, 256),
|
||||||
rings: make(map[string]*sigRing),
|
rings: make(map[string]*sigRing),
|
||||||
statsMap: make(map[string]*SourceStat),
|
statsMap: make(map[string]*SourceStat),
|
||||||
|
trigger: newTriggerEngine(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,44 +295,9 @@ func (h *Hub) getRing(key string) *sigRing {
|
|||||||
return rb
|
return rb
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldWriteRing returns true if zoom was requested within the last 10 seconds.
|
// zoomSlice extracts [t0, t1] from the full-resolution rings for the named
|
||||||
func (h *Hub) shouldWriteRing() bool {
|
// signals, decimating each to at most n points.
|
||||||
h.zoomAtMu.Lock()
|
func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData {
|
||||||
ok := time.Since(h.lastZoomAt) < 10*time.Second
|
|
||||||
h.zoomAtMu.Unlock()
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleZoom serves GET /api/zoom?... It also records the access time
|
|
||||||
// so the ring buffer knows zoom is active and worth populating.
|
|
||||||
func (h *Hub) HandleZoom(w http.ResponseWriter, r *http.Request) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
t0, err0 := strconv.ParseFloat(q.Get("t0"), 64)
|
|
||||||
t1, err1 := strconv.ParseFloat(q.Get("t1"), 64)
|
|
||||||
if err0 != nil || err1 != nil || t1 <= t0 {
|
|
||||||
http.Error(w, "invalid t0/t1", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var n int
|
|
||||||
if nStr := q.Get("n"); nStr == "" {
|
|
||||||
n = 2400
|
|
||||||
} else {
|
|
||||||
n, _ = strconv.Atoi(nStr)
|
|
||||||
if n <= 0 {
|
|
||||||
n = 1 << 30 // no decimation
|
|
||||||
} else if n < 10 {
|
|
||||||
n = 2400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if n > 0 {
|
|
||||||
h.zoomAtMu.Lock()
|
|
||||||
h.lastZoomAt = time.Now()
|
|
||||||
h.zoomAtMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
keys := strings.Split(q.Get("signals"), ",")
|
|
||||||
|
|
||||||
h.ringsMu.RLock()
|
h.ringsMu.RLock()
|
||||||
refs := make(map[string]*sigRing, len(keys))
|
refs := make(map[string]*sigRing, len(keys))
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
@@ -338,11 +320,69 @@ func (h *Hub) HandleZoom(w http.ResponseWriter, r *http.Request) {
|
|||||||
dt, dv := lttbDecimate(rt, rv, n)
|
dt, dv := lttbDecimate(rt, rv, n)
|
||||||
result[k] = sigData{T: dt, V: dv}
|
result[k] = sigData{T: dt, V: dv}
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoomPoints normalises the client's requested point budget: absent → 2400,
|
||||||
|
// non-positive → every sample in the range, implausibly small → 2400.
|
||||||
|
func zoomPoints(n int, present bool) int {
|
||||||
|
switch {
|
||||||
|
case !present:
|
||||||
|
return 2400
|
||||||
|
case n <= 0:
|
||||||
|
return 1 << 30 // no decimation
|
||||||
|
case n < 10:
|
||||||
|
return 2400
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWSZoom answers a browser {"type":"zoom","reqId":..,"t0":..,"t1":..,
|
||||||
|
// "n":..,"signals":"a,b"} request, unicasting {"type":"zoom","reqId":..,
|
||||||
|
// "signals":{...}} back to the requesting client. This is the path the web SPA
|
||||||
|
// actually uses; /api/zoom is the equivalent HTTP entry point.
|
||||||
|
func (h *Hub) handleWSZoom(c *wsClient, env map[string]interface{}) {
|
||||||
|
t0, ok0 := env["t0"].(float64)
|
||||||
|
t1, ok1 := env["t1"].(float64)
|
||||||
|
if !ok0 || !ok1 || t1 <= t0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nF, nOK := env["n"].(float64)
|
||||||
|
n := zoomPoints(int(nF), nOK)
|
||||||
|
sigCSV, _ := env["signals"].(string)
|
||||||
|
|
||||||
|
reply, err := json.Marshal(map[string]any{
|
||||||
|
"type": "zoom",
|
||||||
|
"reqId": env["reqId"],
|
||||||
|
"signals": h.zoomSlice(t0, t1, strings.Split(sigCSV, ","), n),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("hub: ws zoom encode: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.TextMessage, reply}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleZoom serves GET /api/zoom?...
|
||||||
|
func (h *Hub) HandleZoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
t0, err0 := strconv.ParseFloat(q.Get("t0"), 64)
|
||||||
|
t1, err1 := strconv.ParseFloat(q.Get("t1"), 64)
|
||||||
|
if err0 != nil || err1 != nil || t1 <= t0 {
|
||||||
|
http.Error(w, "invalid t0/t1", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nStr := q.Get("n")
|
||||||
|
nVal, _ := strconv.Atoi(nStr)
|
||||||
|
n := zoomPoints(nVal, nStr != "")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
"type": "zoom",
|
"type": "zoom",
|
||||||
"signals": result,
|
"signals": h.zoomSlice(t0, t1, strings.Split(q.Get("signals"), ","), n),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("hub: zoom encode: %v", err)
|
log.Printf("hub: zoom encode: %v", err)
|
||||||
}
|
}
|
||||||
@@ -455,13 +495,28 @@ func (h *Hub) Run() {
|
|||||||
h.clients[c] = true
|
h.clients[c] = true
|
||||||
// Send current state to the new client.
|
// Send current state to the new client.
|
||||||
if sourcesMsg != nil {
|
if sourcesMsg != nil {
|
||||||
select { case c.send <- wsMessage{websocket.TextMessage, sourcesMsg}: default: }
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.TextMessage, sourcesMsg}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, src := range sourcesMap {
|
for _, src := range sourcesMap {
|
||||||
if src.configJS != nil {
|
if src.configJS != nil {
|
||||||
select { case c.send <- wsMessage{websocket.TextMessage, src.configJS}: default: }
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.TextMessage, src.configJS}:
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.TextMessage, h.trigger.stateMsg()}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
monoMsg, _ := json.Marshal(map[string]any{"type": "monotonicState", "enabled": h.monotonicTS})
|
||||||
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.TextMessage, monoMsg}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
// Notify the application layer so it can replay any persistent state
|
// Notify the application layer so it can replay any persistent state
|
||||||
// (e.g., MARTe2 connection status, forced/traced signals).
|
// (e.g., MARTe2 connection status, forced/traced signals).
|
||||||
h.onClientConnectMu.RLock()
|
h.onClientConnectMu.RLock()
|
||||||
@@ -469,7 +524,10 @@ func (h *Hub) Run() {
|
|||||||
h.onClientConnectMu.RUnlock()
|
h.onClientConnectMu.RUnlock()
|
||||||
if fn != nil {
|
if fn != nil {
|
||||||
fn(func(msg []byte) {
|
fn(func(msg []byte) {
|
||||||
select { case c.send <- wsMessage{websocket.TextMessage, msg}: default: }
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.TextMessage, msg}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +539,10 @@ func (h *Hub) Run() {
|
|||||||
|
|
||||||
case msg := <-h.broadcastCh:
|
case msg := <-h.broadcastCh:
|
||||||
for c := range h.clients {
|
for c := range h.clients {
|
||||||
select { case c.send <- wsMessage{websocket.TextMessage, msg}: default: }
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.TextMessage, msg}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case cmd := <-h.commandCh:
|
case cmd := <-h.commandCh:
|
||||||
@@ -494,6 +555,9 @@ func (h *Hub) Run() {
|
|||||||
connState: "connecting",
|
connState: "connecting",
|
||||||
timeSigCalib: make(map[string]float64),
|
timeSigCalib: make(map[string]float64),
|
||||||
lastPktNs: make(map[string]int64),
|
lastPktNs: make(map[string]int64),
|
||||||
|
lastFrameEndT: make(map[string]float64),
|
||||||
|
lastFrameMeasured: make(map[string]float64),
|
||||||
|
gapEMA: make(map[string]float64),
|
||||||
}
|
}
|
||||||
h.statsMu.Lock()
|
h.statsMu.Lock()
|
||||||
h.statsMap[cmd.sourceID] = &SourceStat{}
|
h.statsMap[cmd.sourceID] = &SourceStat{}
|
||||||
@@ -529,6 +593,7 @@ func (h *Hub) Run() {
|
|||||||
}
|
}
|
||||||
src.signals = cmd.sigs
|
src.signals = cmd.sigs
|
||||||
src.configSeq++
|
src.configSeq++
|
||||||
|
src.lastFrameEndT = make(map[string]float64)
|
||||||
cfgMsg, err := json.Marshal(map[string]any{
|
cfgMsg, err := json.Marshal(map[string]any{
|
||||||
"type": "config",
|
"type": "config",
|
||||||
"sourceId": cmd.sourceID,
|
"sourceId": cmd.sourceID,
|
||||||
@@ -581,6 +646,10 @@ func (h *Hub) Run() {
|
|||||||
log.Printf("hub: save sources: %v", err)
|
log.Printf("hub: save sources: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "setMonotonic":
|
||||||
|
h.monotonicTS = cmd.enabled
|
||||||
|
monoMsg, _ := json.Marshal(map[string]any{"type": "monotonicState", "enabled": h.monotonicTS})
|
||||||
|
h.broadcast(monoMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
case ts := <-h.dataCh:
|
case ts := <-h.dataCh:
|
||||||
@@ -607,6 +676,7 @@ func (h *Hub) Run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.triggerTick()
|
||||||
|
|
||||||
case <-statsTicker.C:
|
case <-statsTicker.C:
|
||||||
h.statsMu.RLock()
|
h.statsMu.RLock()
|
||||||
@@ -640,11 +710,26 @@ func writeFloat64s(buf []byte, off int, f []float64) int {
|
|||||||
|
|
||||||
// ─── Data serialisation ───────────────────────────────────────────────────────
|
// ─── Data serialisation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// maxPushPoints bounds the live push only. The zoom rings deliberately store
|
||||||
|
// every sample: decimating on the way in would cap the resolution a zoom can
|
||||||
|
// ever recover, and the browser already decimates for display.
|
||||||
const maxPushPoints = 50
|
const maxPushPoints = 50
|
||||||
const maxRingPoints = 20_000
|
|
||||||
|
// Zoom ring depth, in samples per signal (16 bytes each). ringCapTemporal
|
||||||
|
// holds 6 s of a 1 MSps waveform; ringCapScalar holds 100 000 packets.
|
||||||
const ringCapTemporal = 6_000_000
|
const ringCapTemporal = 6_000_000
|
||||||
const ringCapScalar = 100_000
|
const ringCapScalar = 100_000
|
||||||
|
|
||||||
|
// monotonicTolerance is the maximum inter-frame timestamp deviation (seconds)
|
||||||
|
// treated as jitter and snapped to the ideal gap. Larger deviations are
|
||||||
|
// preserved as genuine discontinuities (missing frames, rate changes).
|
||||||
|
const monotonicTolerance = 0.005 // 5 ms
|
||||||
|
|
||||||
|
// monotonicEMAAlpha is the smoothing factor for the inter-frame gap EMA.
|
||||||
|
// 0.01 gives a time constant of ~100 frames (~1 s at 100 Hz): fast enough to
|
||||||
|
// track real rate changes, slow enough to average out per-frame jitter.
|
||||||
|
const monotonicEMAAlpha = 0.01
|
||||||
|
|
||||||
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points
|
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points
|
||||||
// using the Largest-Triangle-Three-Buckets algorithm.
|
// using the Largest-Triangle-Three-Buckets algorithm.
|
||||||
func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
|
func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
|
||||||
@@ -667,10 +752,13 @@ func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
|
|||||||
}
|
}
|
||||||
avgT, avgV, cnt := 0.0, 0.0, 0
|
avgT, avgV, cnt := 0.0, 0.0, 0
|
||||||
for j := avgS; j < avgE; j++ {
|
for j := avgS; j < avgE; j++ {
|
||||||
avgT += tIn[j]; avgV += vIn[j]; cnt++
|
avgT += tIn[j]
|
||||||
|
avgV += vIn[j]
|
||||||
|
cnt++
|
||||||
}
|
}
|
||||||
if cnt > 0 {
|
if cnt > 0 {
|
||||||
avgT /= float64(cnt); avgV /= float64(cnt)
|
avgT /= float64(cnt)
|
||||||
|
avgV /= float64(cnt)
|
||||||
}
|
}
|
||||||
rS := int(float64(i)*every) + 1
|
rS := int(float64(i)*every) + 1
|
||||||
rE := int(float64(i+1)*every) + 1
|
rE := int(float64(i+1)*every) + 1
|
||||||
@@ -682,7 +770,8 @@ func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
|
|||||||
for j := rS; j < rE; j++ {
|
for j := rS; j < rE; j++ {
|
||||||
area := math.Abs((aT-avgT)*(vIn[j]-aV) - (aT-tIn[j])*(avgV-aV))
|
area := math.Abs((aT-avgT)*(vIn[j]-aV) - (aT-tIn[j])*(avgV-aV))
|
||||||
if area > maxArea {
|
if area > maxArea {
|
||||||
maxArea = area; next = j
|
maxArea = area
|
||||||
|
next = j
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outT[i+1], outV[i+1] = tIn[next], vIn[next]
|
outT[i+1], outV[i+1] = tIn[next], vIn[next]
|
||||||
@@ -710,11 +799,13 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
|||||||
if src.configSeq != src.configSeqAtCalib {
|
if src.configSeq != src.configSeqAtCalib {
|
||||||
src.configSeqAtCalib = src.configSeq
|
src.configSeqAtCalib = src.configSeq
|
||||||
src.timeSigCalib = make(map[string]float64)
|
src.timeSigCalib = make(map[string]float64)
|
||||||
|
src.lastFrameEndT = make(map[string]float64)
|
||||||
|
src.lastFrameMeasured = make(map[string]float64)
|
||||||
|
src.gapEMA = make(map[string]float64)
|
||||||
}
|
}
|
||||||
|
|
||||||
sigs := src.signals
|
sigs := src.signals
|
||||||
pfx := src.id + ":"
|
pfx := src.id + ":"
|
||||||
writeRing := h.shouldWriteRing()
|
|
||||||
|
|
||||||
type pairBuf struct {
|
type pairBuf struct {
|
||||||
t, v []float64
|
t, v []float64
|
||||||
@@ -766,6 +857,25 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
|||||||
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
|
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
|
||||||
anchorIsFirstSample = false
|
anchorIsFirstSample = false
|
||||||
}
|
}
|
||||||
|
if h.monotonicTS && dt > 0 {
|
||||||
|
nominalGap := float64(n) * dt
|
||||||
|
measuredAnchor := anchorTime
|
||||||
|
if prevMeasured, ok := src.lastFrameMeasured[sig.Name]; ok {
|
||||||
|
measuredGap := measuredAnchor - prevMeasured
|
||||||
|
prevEMA, hasEMA := src.gapEMA[sig.Name]
|
||||||
|
if !hasEMA {
|
||||||
|
prevEMA = nominalGap
|
||||||
|
}
|
||||||
|
src.gapEMA[sig.Name] = prevEMA*(1-monotonicEMAAlpha) + measuredGap*monotonicEMAAlpha
|
||||||
|
smoothedGap := src.gapEMA[sig.Name]
|
||||||
|
deviation := math.Abs(measuredGap - smoothedGap)
|
||||||
|
if deviation > 0 && deviation < monotonicTolerance {
|
||||||
|
anchorTime = src.lastFrameEndT[sig.Name] + smoothedGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src.lastFrameMeasured[sig.Name] = measuredAnchor
|
||||||
|
src.lastFrameEndT[sig.Name] = anchorTime
|
||||||
|
}
|
||||||
for k := 0; k < n; k++ {
|
for k := 0; k < n; k++ {
|
||||||
var t float64
|
var t float64
|
||||||
if anchorIsFirstSample {
|
if anchorIsFirstSample {
|
||||||
@@ -777,12 +887,10 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
|||||||
allV = append(allV, vals[k])
|
allV = append(allV, vals[k])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if writeRing {
|
|
||||||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
|
||||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||||
rb.write(ringT, ringV)
|
rb.write(allT, allV)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
h.trigger.feed(pfx+sig.Name, n, allT, allV)
|
||||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||||||
|
|
||||||
@@ -825,12 +933,10 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
|||||||
allV = append(allV, vals[k])
|
allV = append(allV, vals[k])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if writeRing {
|
|
||||||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
|
||||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||||
rb.write(ringT, ringV)
|
rb.write(allT, allV)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
h.trigger.feed(pfx+sig.Name, n, allT, allV)
|
||||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||||||
|
|
||||||
@@ -845,25 +951,22 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
|||||||
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
|
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
|
||||||
vs = append(vs, vals[0])
|
vs = append(vs, vals[0])
|
||||||
}
|
}
|
||||||
if writeRing {
|
|
||||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||||
rb.write(ts, vs)
|
rb.write(ts, vs)
|
||||||
}
|
}
|
||||||
}
|
h.trigger.feed(pfx+sig.Name, 1, ts, vs)
|
||||||
pairs[sig.Name] = pairBuf{t: ts, v: vs}
|
pairs[sig.Name] = pairBuf{t: ts, v: vs}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// n > 1, TimeModePacket: C++ sends samplingRate=0 so we interpolate
|
// n > 1, TimeModePacket: C++ sends samplingRate=0 so we interpolate
|
||||||
// per-element timestamps from wall-clock differences between packets.
|
// per-element timestamps from wall-clock differences between packets.
|
||||||
//
|
//
|
||||||
// Three fixes vs the naïve approach:
|
// Two fixes vs the naïve approach:
|
||||||
// 1. Use src.lastPktNs[name] for the single-packet case so dt is
|
// 1. Use src.lastPktNs[name] for the single-packet case so dt is
|
||||||
// estimated from the actual inter-packet gap, not 1/n.
|
// estimated from the actual inter-packet gap, not 1/n.
|
||||||
// 2. Send all n elements to the browser without LTTB so sinusoidal
|
// 2. Send all n elements to the browser without LTTB so sinusoidal
|
||||||
// waveforms are not degraded (packets arrive at ≤30 Hz, bandwidth
|
// waveforms are not degraded (packets arrive at ≤30 Hz, bandwidth
|
||||||
// is trivially acceptable).
|
// is trivially acceptable).
|
||||||
// 3. Always write the ring buffer regardless of shouldWriteRing() so
|
|
||||||
// the first zoom request immediately returns full-resolution data.
|
|
||||||
allT := make([]float64, 0, len(batch)*n)
|
allT := make([]float64, 0, len(batch)*n)
|
||||||
allV := make([]float64, 0, len(batch)*n)
|
allV := make([]float64, 0, len(batch)*n)
|
||||||
for bi, s := range batch {
|
for bi, s := range batch {
|
||||||
@@ -889,6 +992,25 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
|||||||
// lastPktNs will be recorded below so the next packet uses correct dt.
|
// lastPktNs will be recorded below so the next packet uses correct dt.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if h.monotonicTS && dtSec > 0 {
|
||||||
|
nominalGap := float64(n) * dtSec
|
||||||
|
measuredStart := wallSec
|
||||||
|
if prevMeasured, ok := src.lastFrameMeasured[sig.Name]; ok {
|
||||||
|
measuredGap := measuredStart - prevMeasured
|
||||||
|
prevEMA, hasEMA := src.gapEMA[sig.Name]
|
||||||
|
if !hasEMA {
|
||||||
|
prevEMA = nominalGap
|
||||||
|
}
|
||||||
|
src.gapEMA[sig.Name] = prevEMA*(1-monotonicEMAAlpha) + measuredGap*monotonicEMAAlpha
|
||||||
|
smoothedGap := src.gapEMA[sig.Name]
|
||||||
|
deviation := math.Abs(measuredGap - smoothedGap)
|
||||||
|
if deviation > 0 && deviation < monotonicTolerance {
|
||||||
|
wallSec = src.lastFrameEndT[sig.Name] + smoothedGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src.lastFrameMeasured[sig.Name] = measuredStart
|
||||||
|
src.lastFrameEndT[sig.Name] = wallSec
|
||||||
|
}
|
||||||
for j := 0; j < n; j++ {
|
for j := 0; j < n; j++ {
|
||||||
allT = append(allT, wallSec+float64(j)*dtSec)
|
allT = append(allT, wallSec+float64(j)*dtSec)
|
||||||
allV = append(allV, vals[j])
|
allV = append(allV, vals[j])
|
||||||
@@ -898,11 +1020,10 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
|||||||
src.lastPktNs[sig.Name] = batch[len(batch)-1].WallTime.UnixNano()
|
src.lastPktNs[sig.Name] = batch[len(batch)-1].WallTime.UnixNano()
|
||||||
}
|
}
|
||||||
if len(allT) > 0 {
|
if len(allT) > 0 {
|
||||||
// Ring: always populate (fix 3), LTTB only if it actually reduces size.
|
|
||||||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
|
||||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||||
rb.write(ringT, ringV)
|
rb.write(allT, allV)
|
||||||
}
|
}
|
||||||
|
h.trigger.feed(pfx+sig.Name, n, allT, allV)
|
||||||
// Live push: send all points without LTTB (fix 2).
|
// Live push: send all points without LTTB (fix 2).
|
||||||
pairs[sig.Name] = pairBuf{t: allT, v: allV}
|
pairs[sig.Name] = pairBuf{t: allT, v: allV}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
package wshub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trigger FSM states, matching the C++ StreamHub TriggerEngine and the strings
|
||||||
|
// expected by the web SPA's "triggerState" handler.
|
||||||
|
const (
|
||||||
|
trigIdle = "idle"
|
||||||
|
trigArmed = "armed"
|
||||||
|
trigCollecting = "collecting"
|
||||||
|
trigTriggered = "triggered"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureMarginSec is the extra delay past the post-trigger window before the
|
||||||
|
// capture is extracted, so the rings have received the last samples.
|
||||||
|
const captureMarginSec = 0.15
|
||||||
|
|
||||||
|
// autoRearmDelaySec is the pause between a completed capture and the automatic
|
||||||
|
// rearm in "normal" mode.
|
||||||
|
const autoRearmDelaySec = 0.2
|
||||||
|
|
||||||
|
// trigConfig is the client-settable part of the trigger.
|
||||||
|
type trigConfig struct {
|
||||||
|
signalKey string // "src:sig" or "src:sig[i]"
|
||||||
|
edge string // "rising" | "falling" | "both"
|
||||||
|
threshold float64
|
||||||
|
windowSec float64
|
||||||
|
prePercent float64
|
||||||
|
mode string // "normal" | "single"
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerEngine implements the hub-side trigger FSM. Its methods are safe to
|
||||||
|
// call from the WebSocket read goroutines and from Hub.Run() concurrently.
|
||||||
|
type triggerEngine struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg trigConfig
|
||||||
|
|
||||||
|
// Parsed form of cfg.signalKey, refreshed by SetConfig.
|
||||||
|
baseKey string // "src:sig"
|
||||||
|
elemIdx int // -1 when the key has no "[i]" suffix
|
||||||
|
|
||||||
|
state string
|
||||||
|
stopped bool
|
||||||
|
|
||||||
|
prevValue float64
|
||||||
|
prevValid bool
|
||||||
|
lastT float64
|
||||||
|
lastTOK bool
|
||||||
|
|
||||||
|
trigTime float64
|
||||||
|
firedPre float64
|
||||||
|
firedPost float64
|
||||||
|
firedValid bool
|
||||||
|
|
||||||
|
rearmAt float64 // wall-clock seconds; 0 when no rearm is pending
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTriggerEngine() *triggerEngine {
|
||||||
|
return &triggerEngine{
|
||||||
|
cfg: trigConfig{edge: "rising", windowSec: 1, prePercent: 20, mode: "normal"},
|
||||||
|
elemIdx: -1,
|
||||||
|
state: trigIdle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSignalKey splits "src:sig[3]" into ("src:sig", 3). A key without an
|
||||||
|
// element suffix yields an index of -1.
|
||||||
|
func parseSignalKey(key string) (string, int) {
|
||||||
|
if !strings.HasSuffix(key, "]") {
|
||||||
|
return key, -1
|
||||||
|
}
|
||||||
|
open := strings.LastIndexByte(key, '[')
|
||||||
|
if open < 0 {
|
||||||
|
return key, -1
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(key[open+1 : len(key)-1])
|
||||||
|
if err != nil || idx < 0 {
|
||||||
|
return key, -1
|
||||||
|
}
|
||||||
|
return key[:open], idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *triggerEngine) SetConfig(cfg trigConfig) {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
// Clamp to the bounds the web UI offers.
|
||||||
|
if cfg.windowSec < 1e-4 {
|
||||||
|
cfg.windowSec = 1e-4
|
||||||
|
}
|
||||||
|
if cfg.windowSec > 10 {
|
||||||
|
cfg.windowSec = 10
|
||||||
|
}
|
||||||
|
if cfg.prePercent < 0 {
|
||||||
|
cfg.prePercent = 0
|
||||||
|
}
|
||||||
|
if cfg.prePercent > 100 {
|
||||||
|
cfg.prePercent = 100
|
||||||
|
}
|
||||||
|
te.cfg = cfg
|
||||||
|
te.baseKey, te.elemIdx = parseSignalKey(cfg.signalKey)
|
||||||
|
te.prevValid = false
|
||||||
|
te.prevValue = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *triggerEngine) Config() trigConfig {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
return te.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *triggerEngine) Arm() {
|
||||||
|
te.mu.Lock()
|
||||||
|
te.state = trigArmed
|
||||||
|
te.prevValid = false
|
||||||
|
te.prevValue = 0
|
||||||
|
te.rearmAt = 0
|
||||||
|
te.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *triggerEngine) Disarm() {
|
||||||
|
te.mu.Lock()
|
||||||
|
te.state = trigIdle
|
||||||
|
te.stopped = false
|
||||||
|
te.prevValid = false
|
||||||
|
te.prevValue = 0
|
||||||
|
te.firedValid = false
|
||||||
|
te.rearmAt = 0
|
||||||
|
te.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *triggerEngine) SetStopped(v bool) {
|
||||||
|
te.mu.Lock()
|
||||||
|
te.stopped = v
|
||||||
|
if v {
|
||||||
|
te.rearmAt = 0
|
||||||
|
}
|
||||||
|
te.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *triggerEngine) Stopped() bool {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
return te.stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *triggerEngine) State() string {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
return te.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active reports whether a trigger signal is configured. The rings must stay
|
||||||
|
// populated from that moment on: a capture reaches back over the pre-trigger
|
||||||
|
// window, so waiting until the trigger arms would leave that window empty.
|
||||||
|
func (te *triggerEngine) Active() bool {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
return te.baseKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// latchWindowLocked freezes the pre/post split at fire time so later config
|
||||||
|
// edits do not change how the capture is rendered.
|
||||||
|
func (te *triggerEngine) latchWindowLocked(t float64) {
|
||||||
|
te.state = trigCollecting
|
||||||
|
te.trigTime = t
|
||||||
|
te.firedPre = te.cfg.windowSec * te.cfg.prePercent / 100
|
||||||
|
te.firedPost = te.cfg.windowSec - te.firedPre
|
||||||
|
te.firedValid = true
|
||||||
|
te.rearmAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force fires the trigger immediately at the most recent sample time (falling
|
||||||
|
// back to the current wall clock when no sample has been seen yet).
|
||||||
|
func (te *triggerEngine) Force() {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
if te.state == trigCollecting {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := float64(time.Now().UnixNano()) / 1e9
|
||||||
|
if te.lastTOK {
|
||||||
|
t = te.lastT
|
||||||
|
}
|
||||||
|
te.latchWindowLocked(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// feed passes a batch of full-resolution samples for one signal to the FSM.
|
||||||
|
// key is the fully-prefixed "src:sig" name; nElem is the signal's element count
|
||||||
|
// so that an "[i]"-suffixed configuration can select a single column out of the
|
||||||
|
// flattened element-major batch.
|
||||||
|
func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
|
||||||
|
if len(t) == 0 || len(t) != len(v) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
if key != te.baseKey {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
te.lastT = t[len(t)-1]
|
||||||
|
te.lastTOK = true
|
||||||
|
if te.state != trigArmed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step, start := 1, 0
|
||||||
|
if te.elemIdx >= 0 && nElem > 1 {
|
||||||
|
if te.elemIdx >= nElem {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step, start = nElem, te.elemIdx
|
||||||
|
}
|
||||||
|
thr := te.cfg.threshold
|
||||||
|
for i := start; i < len(t); i += step {
|
||||||
|
if !te.prevValid {
|
||||||
|
te.prevValue = v[i]
|
||||||
|
te.prevValid = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
up := te.prevValue < thr && v[i] >= thr
|
||||||
|
down := te.prevValue > thr && v[i] <= thr
|
||||||
|
te.prevValue = v[i]
|
||||||
|
fired := false
|
||||||
|
switch te.cfg.edge {
|
||||||
|
case "falling":
|
||||||
|
fired = down
|
||||||
|
case "both":
|
||||||
|
fired = up || down
|
||||||
|
default:
|
||||||
|
fired = up
|
||||||
|
}
|
||||||
|
if fired {
|
||||||
|
te.latchWindowLocked(t[i])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueCapture reports whether a collecting trigger's post-window has elapsed and
|
||||||
|
// returns the latched window.
|
||||||
|
func (te *triggerEngine) dueCapture(nowSec float64) (trigTime, pre, post float64, ok bool) {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
if te.state != trigCollecting || !te.firedValid {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
if nowSec < te.trigTime+te.firedPost+captureMarginSec {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
return te.trigTime, te.firedPre, te.firedPost, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// markTriggered completes a capture and schedules the automatic rearm when the
|
||||||
|
// engine runs in "normal" mode.
|
||||||
|
func (te *triggerEngine) markTriggered(nowSec float64) {
|
||||||
|
te.mu.Lock()
|
||||||
|
if te.state == trigCollecting {
|
||||||
|
te.state = trigTriggered
|
||||||
|
if te.cfg.mode != "single" && !te.stopped {
|
||||||
|
te.rearmAt = nowSec + autoRearmDelaySec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
te.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueRearm reports whether a pending automatic rearm has come due, consuming it.
|
||||||
|
func (te *triggerEngine) dueRearm(nowSec float64) bool {
|
||||||
|
te.mu.Lock()
|
||||||
|
defer te.mu.Unlock()
|
||||||
|
if te.state != trigTriggered || te.rearmAt == 0 || nowSec < te.rearmAt {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
te.rearmAt = 0
|
||||||
|
return !te.stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateMsg builds the JSON "triggerState" broadcast for the current FSM state.
|
||||||
|
func (te *triggerEngine) stateMsg() []byte {
|
||||||
|
te.mu.Lock()
|
||||||
|
m := map[string]any{
|
||||||
|
"type": "triggerState",
|
||||||
|
"state": te.state,
|
||||||
|
"mode": te.cfg.mode,
|
||||||
|
"stopped": te.stopped,
|
||||||
|
}
|
||||||
|
if te.firedValid {
|
||||||
|
m["trigTime"] = te.trigTime
|
||||||
|
}
|
||||||
|
te.mu.Unlock()
|
||||||
|
msg, _ := json.Marshal(m)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Hub integration ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// broadcastTriggerState pushes the current FSM state to every client.
|
||||||
|
func (h *Hub) broadcastTriggerState() {
|
||||||
|
h.broadcast(h.trigger.stateMsg())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTriggerCommand processes a trigger-related browser message. It returns
|
||||||
|
// false when the message type is not a trigger command.
|
||||||
|
func (h *Hub) handleTriggerCommand(t string, env map[string]interface{}) bool {
|
||||||
|
switch t {
|
||||||
|
case "setTrigger":
|
||||||
|
cfg := h.trigger.Config()
|
||||||
|
if s, ok := env["signal"].(string); ok {
|
||||||
|
cfg.signalKey = s
|
||||||
|
}
|
||||||
|
if s, ok := env["edge"].(string); ok {
|
||||||
|
cfg.edge = s
|
||||||
|
}
|
||||||
|
if s, ok := env["mode"].(string); ok {
|
||||||
|
cfg.mode = s
|
||||||
|
}
|
||||||
|
if f, ok := env["threshold"].(float64); ok {
|
||||||
|
cfg.threshold = f
|
||||||
|
}
|
||||||
|
if f, ok := env["windowSec"].(float64); ok {
|
||||||
|
cfg.windowSec = f
|
||||||
|
}
|
||||||
|
if f, ok := env["prePercent"].(float64); ok {
|
||||||
|
cfg.prePercent = f
|
||||||
|
}
|
||||||
|
h.trigger.SetConfig(cfg)
|
||||||
|
case "arm", "rearm":
|
||||||
|
h.trigger.Arm()
|
||||||
|
case "disarm":
|
||||||
|
h.trigger.Disarm()
|
||||||
|
case "trigStop":
|
||||||
|
stopped := !h.trigger.Stopped()
|
||||||
|
if b, ok := env["stopped"].(bool); ok {
|
||||||
|
stopped = b
|
||||||
|
}
|
||||||
|
h.trigger.SetStopped(stopped)
|
||||||
|
case "forceTrigger":
|
||||||
|
h.trigger.Force()
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.broadcastTriggerState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerTick services the trigger FSM; called from Hub.Run() on every push tick.
|
||||||
|
func (h *Hub) triggerTick() {
|
||||||
|
nowSec := float64(time.Now().UnixNano()) / 1e9
|
||||||
|
prev := h.trigger.State()
|
||||||
|
|
||||||
|
if trigTime, pre, post, ok := h.trigger.dueCapture(nowSec); ok {
|
||||||
|
if msg := h.buildTriggerCapture(trigTime, pre, post); msg != nil {
|
||||||
|
for c := range h.clients {
|
||||||
|
select {
|
||||||
|
case c.send <- wsMessage{websocket.BinaryMessage, msg}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.trigger.markTriggered(nowSec)
|
||||||
|
} else if h.trigger.dueRearm(nowSec) {
|
||||||
|
h.trigger.Arm()
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.trigger.State() != prev {
|
||||||
|
h.broadcastTriggerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTriggerCapture extracts [trigTime-pre, trigTime+post] from every ring
|
||||||
|
// buffer and encodes the version-2 binary capture frame:
|
||||||
|
//
|
||||||
|
// [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig]
|
||||||
|
// {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]}
|
||||||
|
func (h *Hub) buildTriggerCapture(trigTime, pre, post float64) []byte {
|
||||||
|
t0, t1 := trigTime-pre, trigTime+post
|
||||||
|
|
||||||
|
type sigSlice struct {
|
||||||
|
key string
|
||||||
|
t, v []float64
|
||||||
|
}
|
||||||
|
h.ringsMu.RLock()
|
||||||
|
keys := make([]string, 0, len(h.rings))
|
||||||
|
rings := make([]*sigRing, 0, len(h.rings))
|
||||||
|
for k, rb := range h.rings {
|
||||||
|
keys = append(keys, k)
|
||||||
|
rings = append(rings, rb)
|
||||||
|
}
|
||||||
|
h.ringsMu.RUnlock()
|
||||||
|
|
||||||
|
slices := make([]sigSlice, 0, len(keys))
|
||||||
|
total := 1 + 8 + 8 + 8 + 4
|
||||||
|
for i, k := range keys {
|
||||||
|
st, sv := rings[i].slice(t0, t1)
|
||||||
|
if len(st) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slices = append(slices, sigSlice{key: k, t: st, v: sv})
|
||||||
|
total += 2 + len(k) + 4 + len(st)*16
|
||||||
|
}
|
||||||
|
if len(slices) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, total)
|
||||||
|
buf[0] = 2
|
||||||
|
off := 1
|
||||||
|
binary.LittleEndian.PutUint64(buf[off:], math.Float64bits(trigTime))
|
||||||
|
off += 8
|
||||||
|
binary.LittleEndian.PutUint64(buf[off:], math.Float64bits(pre))
|
||||||
|
off += 8
|
||||||
|
binary.LittleEndian.PutUint64(buf[off:], math.Float64bits(post))
|
||||||
|
off += 8
|
||||||
|
binary.LittleEndian.PutUint32(buf[off:], uint32(len(slices)))
|
||||||
|
off += 4
|
||||||
|
for _, s := range slices {
|
||||||
|
binary.LittleEndian.PutUint16(buf[off:], uint16(len(s.key)))
|
||||||
|
off += 2
|
||||||
|
copy(buf[off:], s.key)
|
||||||
|
off += len(s.key)
|
||||||
|
binary.LittleEndian.PutUint32(buf[off:], uint32(len(s.t)))
|
||||||
|
off += 4
|
||||||
|
off = writeFloat64s(buf, off, s.t)
|
||||||
|
off = writeFloat64s(buf, off, s.v)
|
||||||
|
}
|
||||||
|
return buf
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package wshub
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseSignalKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
base string
|
||||||
|
idx int
|
||||||
|
}{
|
||||||
|
{"src:sig", "src:sig", -1},
|
||||||
|
{"src:sig[0]", "src:sig", 0},
|
||||||
|
{"src:sig[3]", "src:sig", 3},
|
||||||
|
{"src:sig[x]", "src:sig[x]", -1},
|
||||||
|
{"src:sig]", "src:sig]", -1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
base, idx := parseSignalKey(c.in)
|
||||||
|
if base != c.base || idx != c.idx {
|
||||||
|
t.Errorf("parseSignalKey(%q) = (%q,%d), want (%q,%d)",
|
||||||
|
c.in, base, idx, c.base, c.idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func armed(key, edge string, thr float64) *triggerEngine {
|
||||||
|
te := newTriggerEngine()
|
||||||
|
te.SetConfig(trigConfig{signalKey: key, edge: edge, threshold: thr,
|
||||||
|
windowSec: 1, prePercent: 20, mode: "normal"})
|
||||||
|
te.Arm()
|
||||||
|
return te
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedRisingEdge(t *testing.T) {
|
||||||
|
te := armed("src:sig", "rising", 0.5)
|
||||||
|
te.feed("src:sig", 1, []float64{1, 2, 3, 4}, []float64{0, 0.2, 0.9, 1.0})
|
||||||
|
if te.State() != trigCollecting {
|
||||||
|
t.Fatalf("state = %q, want collecting", te.State())
|
||||||
|
}
|
||||||
|
// Fires at the sample that crossed, i.e. t=3.
|
||||||
|
trigTime, pre, post, ok := te.dueCapture(1e9)
|
||||||
|
if !ok || trigTime != 3 {
|
||||||
|
t.Fatalf("dueCapture = (%v,%v), want trigTime 3", trigTime, ok)
|
||||||
|
}
|
||||||
|
if pre != 0.2 || post != 0.8 {
|
||||||
|
t.Errorf("pre/post = %v/%v, want 0.2/0.8", pre, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedFallingEdgeIgnoresRising(t *testing.T) {
|
||||||
|
te := armed("src:sig", "falling", 0.5)
|
||||||
|
te.feed("src:sig", 1, []float64{1, 2, 3}, []float64{0, 0.9, 1.0})
|
||||||
|
if te.State() != trigArmed {
|
||||||
|
t.Fatalf("state = %q, want armed (no falling edge)", te.State())
|
||||||
|
}
|
||||||
|
te.feed("src:sig", 1, []float64{4, 5}, []float64{0.6, 0.1})
|
||||||
|
if te.State() != trigCollecting {
|
||||||
|
t.Fatalf("state = %q, want collecting", te.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedIgnoresOtherSignals(t *testing.T) {
|
||||||
|
te := armed("src:sig", "rising", 0.5)
|
||||||
|
te.feed("src:other", 1, []float64{1, 2}, []float64{0, 1})
|
||||||
|
if te.State() != trigArmed {
|
||||||
|
t.Fatalf("state = %q, want armed", te.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedArrayElementSelection(t *testing.T) {
|
||||||
|
// 2-element signal, element-major: [e0,e1, e0,e1, ...]. Only element 1
|
||||||
|
// crosses the threshold.
|
||||||
|
te := armed("src:sig[1]", "rising", 0.5)
|
||||||
|
tt := []float64{1, 1, 2, 2}
|
||||||
|
vv := []float64{0, 0, 0, 1}
|
||||||
|
te.feed("src:sig", 2, tt, vv)
|
||||||
|
if te.State() != trigCollecting {
|
||||||
|
t.Fatalf("state = %q, want collecting", te.State())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element 0 never crosses, so a config on [0] must not fire.
|
||||||
|
te2 := armed("src:sig[0]", "rising", 0.5)
|
||||||
|
te2.feed("src:sig", 2, tt, vv)
|
||||||
|
if te2.State() != trigArmed {
|
||||||
|
t.Fatalf("state = %q, want armed", te2.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForceUsesLastSampleTime(t *testing.T) {
|
||||||
|
te := newTriggerEngine()
|
||||||
|
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 1e9,
|
||||||
|
windowSec: 2, prePercent: 50, mode: "single"})
|
||||||
|
te.Arm()
|
||||||
|
te.feed("src:sig", 1, []float64{10, 11, 12}, []float64{0, 0, 0})
|
||||||
|
if te.State() != trigArmed {
|
||||||
|
t.Fatalf("state = %q, want armed (threshold unreachable)", te.State())
|
||||||
|
}
|
||||||
|
te.Force()
|
||||||
|
trigTime, pre, post, ok := te.dueCapture(1e9)
|
||||||
|
if !ok || trigTime != 12 || pre != 1 || post != 1 {
|
||||||
|
t.Fatalf("dueCapture = (%v,%v,%v,%v), want (12,1,1,true)",
|
||||||
|
trigTime, pre, post, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForceFromIdle(t *testing.T) {
|
||||||
|
te := newTriggerEngine()
|
||||||
|
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising",
|
||||||
|
windowSec: 1, prePercent: 20, mode: "normal"})
|
||||||
|
te.Force()
|
||||||
|
if te.State() != trigCollecting {
|
||||||
|
t.Fatalf("state = %q, want collecting", te.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureMarginDelaysExtraction(t *testing.T) {
|
||||||
|
te := armed("src:sig", "rising", 0.5)
|
||||||
|
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1
|
||||||
|
// post = 0.8 s; capture is due at 1 + 0.8 + 0.15.
|
||||||
|
if _, _, _, ok := te.dueCapture(1.9); ok {
|
||||||
|
t.Error("capture extracted before the margin elapsed")
|
||||||
|
}
|
||||||
|
if _, _, _, ok := te.dueCapture(1.96); !ok {
|
||||||
|
t.Error("capture not extracted after the margin elapsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoRearmNormalMode(t *testing.T) {
|
||||||
|
te := armed("src:sig", "rising", 0.5)
|
||||||
|
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
|
||||||
|
te.markTriggered(100)
|
||||||
|
if te.State() != trigTriggered {
|
||||||
|
t.Fatalf("state = %q, want triggered", te.State())
|
||||||
|
}
|
||||||
|
if te.dueRearm(100.1) {
|
||||||
|
t.Error("rearmed before the delay elapsed")
|
||||||
|
}
|
||||||
|
if !te.dueRearm(100.3) {
|
||||||
|
t.Error("did not rearm after the delay elapsed")
|
||||||
|
}
|
||||||
|
if te.dueRearm(200) {
|
||||||
|
t.Error("rearm was not consumed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoAutoRearmInSingleMode(t *testing.T) {
|
||||||
|
te := newTriggerEngine()
|
||||||
|
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 0.5,
|
||||||
|
windowSec: 1, prePercent: 20, mode: "single"})
|
||||||
|
te.Arm()
|
||||||
|
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
|
||||||
|
te.markTriggered(100)
|
||||||
|
if te.dueRearm(200) {
|
||||||
|
t.Error("single mode must not auto-rearm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoppedSuppressesRearm(t *testing.T) {
|
||||||
|
te := armed("src:sig", "rising", 0.5)
|
||||||
|
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
|
||||||
|
te.SetStopped(true)
|
||||||
|
te.markTriggered(100)
|
||||||
|
if te.dueRearm(200) {
|
||||||
|
t.Error("stopped engine must not rearm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetConfigClamps(t *testing.T) {
|
||||||
|
te := newTriggerEngine()
|
||||||
|
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 100, prePercent: 500})
|
||||||
|
if cfg := te.Config(); cfg.windowSec != 10 || cfg.prePercent != 100 {
|
||||||
|
t.Errorf("upper clamp = %v/%v, want 10/100", cfg.windowSec, cfg.prePercent)
|
||||||
|
}
|
||||||
|
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 0, prePercent: -5})
|
||||||
|
if cfg := te.Config(); cfg.windowSec != 1e-4 || cfg.prePercent != 0 {
|
||||||
|
t.Errorf("lower clamp = %v/%v, want 1e-4/0", cfg.windowSec, cfg.prePercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveTracksConfiguredSignal(t *testing.T) {
|
||||||
|
te := newTriggerEngine()
|
||||||
|
if te.Active() {
|
||||||
|
t.Error("a fresh engine must not be active")
|
||||||
|
}
|
||||||
|
te.SetConfig(trigConfig{signalKey: "src:sig", windowSec: 1})
|
||||||
|
if !te.Active() {
|
||||||
|
t.Error("engine must be active once a signal is configured")
|
||||||
|
}
|
||||||
|
// Rings must keep filling after a capture completes, not just while armed.
|
||||||
|
te.Disarm()
|
||||||
|
if !te.Active() {
|
||||||
|
t.Error("engine must stay active after disarm while a signal is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package wshub
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestZoomPoints(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
n int
|
||||||
|
present bool
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{0, false, 2400}, // absent → default budget
|
||||||
|
{2400, true, 2400}, // explicit budget honoured
|
||||||
|
{0, true, 1 << 30}, // 0 → every sample in range
|
||||||
|
{-1, true, 1 << 30}, // negative → every sample in range
|
||||||
|
{5, true, 2400}, // implausibly small → default budget
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := zoomPoints(c.n, c.present); got != c.want {
|
||||||
|
t.Errorf("zoomPoints(%d,%v) = %d, want %d", c.n, c.present, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoomSliceReturnsFullResolution(t *testing.T) {
|
||||||
|
h := NewHub()
|
||||||
|
rb := newSigRing(1000)
|
||||||
|
ts := make([]float64, 500)
|
||||||
|
vs := make([]float64, 500)
|
||||||
|
for i := range ts {
|
||||||
|
ts[i] = float64(i) * 0.001 // 1 kHz
|
||||||
|
vs[i] = float64(i)
|
||||||
|
}
|
||||||
|
rb.write(ts, vs)
|
||||||
|
h.rings["s1:sig"] = rb
|
||||||
|
|
||||||
|
// A budget larger than the range must return every sample untouched.
|
||||||
|
res := h.zoomSlice(0.100, 0.199, []string{"s1:sig"}, 1<<30)
|
||||||
|
sd, ok := res["s1:sig"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("signal missing from zoom result")
|
||||||
|
}
|
||||||
|
if len(sd.T) != 100 {
|
||||||
|
t.Fatalf("got %d points, want 100", len(sd.T))
|
||||||
|
}
|
||||||
|
if sd.V[0] != 100 || sd.V[99] != 199 {
|
||||||
|
t.Errorf("value range = %v..%v, want 100..199", sd.V[0], sd.V[99])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small budget decimates but keeps the endpoints.
|
||||||
|
dec := h.zoomSlice(0.100, 0.199, []string{"s1:sig"}, 20)
|
||||||
|
if len(dec["s1:sig"].T) != 20 {
|
||||||
|
t.Errorf("decimated to %d points, want 20", len(dec["s1:sig"].T))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoomSliceUnknownSignal(t *testing.T) {
|
||||||
|
h := NewHub()
|
||||||
|
if res := h.zoomSlice(0, 1, []string{"nope", ""}, 100); len(res) != 0 {
|
||||||
|
t.Errorf("got %d entries, want 0", len(res))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -750,6 +750,7 @@ void StreamHub::OnWSCommand(const char *json, uint32 /*len*/, uint32 slotIdx) {
|
|||||||
else if (strcmp(type, "rearm") == 0) { HandleRearm(); }
|
else if (strcmp(type, "rearm") == 0) { HandleRearm(); }
|
||||||
else if (strcmp(type, "trigStop") == 0) { HandleTrigStop(json); }
|
else if (strcmp(type, "trigStop") == 0) { HandleTrigStop(json); }
|
||||||
else if (strcmp(type, "setTrigger") == 0) { HandleSetTrigger(json); }
|
else if (strcmp(type, "setTrigger") == 0) { HandleSetTrigger(json); }
|
||||||
|
else if (strcmp(type, "forceTrigger") == 0) { HandleForceTrigger(); }
|
||||||
else if (strcmp(type, "zoom") == 0) { HandleZoom(json, slotIdx); }
|
else if (strcmp(type, "zoom") == 0) { HandleZoom(json, slotIdx); }
|
||||||
else if (strcmp(type, "historyZoom") == 0) { HandleHistoryZoom(json, slotIdx); }
|
else if (strcmp(type, "historyZoom") == 0) { HandleHistoryZoom(json, slotIdx); }
|
||||||
else if (strcmp(type, "historyInfo") == 0) { HandleHistoryInfo(slotIdx); }
|
else if (strcmp(type, "historyInfo") == 0) { HandleHistoryInfo(slotIdx); }
|
||||||
@@ -1017,6 +1018,12 @@ void StreamHub::HandleRearm() {
|
|||||||
HandleArm();
|
HandleArm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void StreamHub::HandleForceTrigger() {
|
||||||
|
rearmPending_ = false;
|
||||||
|
(void) trigger_.Force();
|
||||||
|
BroadcastTriggerState();
|
||||||
|
}
|
||||||
|
|
||||||
void StreamHub::HandleTrigStop(const char *json) {
|
void StreamHub::HandleTrigStop(const char *json) {
|
||||||
/* {"type":"trigStop","stopped":bool} — absent "stopped" toggles. */
|
/* {"type":"trigStop","stopped":bool} — absent "stopped" toggles. */
|
||||||
bool stopped = !trigger_.GetStopped();
|
bool stopped = !trigger_.GetStopped();
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ private:
|
|||||||
void HandleRearm();
|
void HandleRearm();
|
||||||
void HandleTrigStop(const char *json);
|
void HandleTrigStop(const char *json);
|
||||||
void HandleSetTrigger(const char *json);
|
void HandleSetTrigger(const char *json);
|
||||||
|
void HandleForceTrigger();
|
||||||
void HandleZoom(const char *json, uint32 slotIdx);
|
void HandleZoom(const char *json, uint32 slotIdx);
|
||||||
void HandleHistoryZoom(const char *json, uint32 slotIdx);
|
void HandleHistoryZoom(const char *json, uint32 slotIdx);
|
||||||
void HandleHistoryInfo(uint32 slotIdx);
|
void HandleHistoryInfo(uint32 slotIdx);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ TriggerEngine::TriggerEngine()
|
|||||||
stopped_(false),
|
stopped_(false),
|
||||||
prevValue_(0.0),
|
prevValue_(0.0),
|
||||||
prevValid_(false),
|
prevValid_(false),
|
||||||
|
lastTime_(0.0),
|
||||||
|
lastTimeValid_(false),
|
||||||
trigTime_(0.0),
|
trigTime_(0.0),
|
||||||
firedPreSec_(0.0),
|
firedPreSec_(0.0),
|
||||||
firedPostSec_(0.0),
|
firedPostSec_(0.0),
|
||||||
@@ -82,6 +84,11 @@ bool TriggerEngine::GetStopped() const {
|
|||||||
void TriggerEngine::CheckSample(float64 t, float64 v) {
|
void TriggerEngine::CheckSample(float64 t, float64 v) {
|
||||||
(void) mutex_.FastLock();
|
(void) mutex_.FastLock();
|
||||||
|
|
||||||
|
/* Track the newest watched timestamp in every state so Force() has a
|
||||||
|
* reference time to latch the capture window around. */
|
||||||
|
lastTime_ = t;
|
||||||
|
lastTimeValid_ = true;
|
||||||
|
|
||||||
if (state_ != kTrigArmed) {
|
if (state_ != kTrigArmed) {
|
||||||
mutex_.FastUnLock();
|
mutex_.FastUnLock();
|
||||||
return;
|
return;
|
||||||
@@ -122,6 +129,25 @@ void TriggerEngine::CheckSample(float64 t, float64 v) {
|
|||||||
mutex_.FastUnLock();
|
mutex_.FastUnLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool TriggerEngine::Force() {
|
||||||
|
(void) mutex_.FastLock();
|
||||||
|
|
||||||
|
bool ok = lastTimeValid_ && (state_ != kTrigCollecting);
|
||||||
|
if (ok) {
|
||||||
|
state_ = kTrigCollecting;
|
||||||
|
trigTime_ = lastTime_;
|
||||||
|
firedPreSec_ = config_.windowSec * config_.prePercent / 100.0;
|
||||||
|
firedPostSec_ = config_.windowSec - firedPreSec_;
|
||||||
|
firedValid_ = true;
|
||||||
|
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
|
||||||
|
"TriggerEngine: forced at t=%.6f (pre=%.4fs post=%.4fs)",
|
||||||
|
trigTime_, firedPreSec_, firedPostSec_);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutex_.FastUnLock();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
TrigState TriggerEngine::GetState() const {
|
TrigState TriggerEngine::GetState() const {
|
||||||
(void) mutex_.FastLock();
|
(void) mutex_.FastLock();
|
||||||
TrigState ret = state_;
|
TrigState ret = state_;
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ public:
|
|||||||
*/
|
*/
|
||||||
void CheckSample(float64 t, float64 v);
|
void CheckSample(float64 t, float64 v);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Fire the trigger unconditionally at the most recent sample time of
|
||||||
|
* the watched signal, latching the pre/post window exactly as CheckSample
|
||||||
|
* does. Any state except COLLECTING → COLLECTING.
|
||||||
|
* @return false when no sample has been seen yet, or a capture is already
|
||||||
|
* being collected.
|
||||||
|
*/
|
||||||
|
bool Force();
|
||||||
|
|
||||||
/** @return Current FSM state. */
|
/** @return Current FSM state. */
|
||||||
TrigState GetState() const;
|
TrigState GetState() const;
|
||||||
|
|
||||||
@@ -127,6 +136,8 @@ private:
|
|||||||
bool stopped_;
|
bool stopped_;
|
||||||
float64 prevValue_; ///< Last sample (edge detection)
|
float64 prevValue_; ///< Last sample (edge detection)
|
||||||
bool prevValid_; ///< First-sample guard in ARMED state
|
bool prevValid_; ///< First-sample guard in ARMED state
|
||||||
|
float64 lastTime_; ///< Timestamp of the newest watched sample
|
||||||
|
bool lastTimeValid_;///< true once a watched sample has been seen
|
||||||
float64 trigTime_; ///< Latched trigger time (Unix s)
|
float64 trigTime_; ///< Latched trigger time (Unix s)
|
||||||
float64 firedPreSec_; ///< Window pre-part latched at fire time
|
float64 firedPreSec_; ///< Window pre-part latched at fire time
|
||||||
float64 firedPostSec_; ///< Window post-part latched at fire time
|
float64 firedPostSec_; ///< Window post-part latched at fire time
|
||||||
|
|||||||
Reference in New Issue
Block a user