fixed and improved ui
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
module udpstreamer-webui
|
||||
|
||||
go 1.21
|
||||
go 1.24.9
|
||||
|
||||
require marte2/common v0.0.0
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.1 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/parquet-go/bitpack v1.0.0 // indirect
|
||||
github.com/parquet-go/jsonlite v1.0.0 // indirect
|
||||
github.com/parquet-go/parquet-go v0.32.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/twpayne/go-geom v1.6.1 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
replace marte2/common => ../../Common/Client/go
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
|
||||
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
|
||||
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
|
||||
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
|
||||
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
|
||||
github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayMkf+znzM=
|
||||
github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
|
||||
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
|
||||
@@ -94,6 +94,7 @@ func main() {
|
||||
http.Handle("/", http.FileServer(http.FS(sub)))
|
||||
http.HandleFunc("/ws", hub.HandleWebSocket)
|
||||
http.HandleFunc("/api/zoom", hub.HandleZoom)
|
||||
http.HandleFunc("/api/export", hub.HandleExport)
|
||||
http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, buildVersion)
|
||||
})
|
||||
|
||||
+539
-111
@@ -476,14 +476,24 @@ 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 };
|
||||
// Horizontal value rulers. The on/off toggle is global, but each plot keeps its
|
||||
// own pair of normalized-division positions (rulerState), so dragging Y1 in
|
||||
// one plot does not move it in the others.
|
||||
const rulers = { mode: 'off', plotId: null };
|
||||
const rulerState = {}; // plotId → { yA, yB }
|
||||
|
||||
// Layout — [label, cssClass, cols, rows]
|
||||
function getRulerState(plotId) {
|
||||
if (!rulerState[plotId]) rulerState[plotId] = { yA: null, yB: null };
|
||||
return rulerState[plotId];
|
||||
}
|
||||
|
||||
// Layout — [label, cssClass, cols, rows, (optional) plotCount].
|
||||
// Custom (non-uniform) layouts carry an explicit plotCount; the grid template
|
||||
// and the spanning cells are defined in style.css under #plot-grid.<class>.
|
||||
const LAYOUTS = [
|
||||
['1×1', 'l1x1', 1, 1], ['1×2', 'l1x2', 1, 2], ['2×1', 'l2x1', 2, 1], ['1×3', 'l1x3', 1, 3],
|
||||
['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1],
|
||||
['1+2', 'l1p2', 2, 2, 3], // one plot spanning the top row, two below
|
||||
];
|
||||
let currentLayout = 'l1x1';
|
||||
let colFrs = [1]; // fractional column sizes (sum = cols)
|
||||
@@ -816,6 +826,7 @@ function onConfig(msg) {
|
||||
}
|
||||
buildSidebar();
|
||||
buildTrigSignalSelect();
|
||||
maybeRestoreViewLate();
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
@@ -925,12 +936,21 @@ function wsSend(obj) {
|
||||
function sendWindow() {
|
||||
wsSend({ type: 'setWindow', seconds: windowSec });
|
||||
}
|
||||
// trig.threshold is held in calibrated units. The hub's comparator runs on raw
|
||||
// trig.threshold is held in calibrated units. The hub.s comparator runs on raw
|
||||
// samples, so invert on the way out: raw = (calibrated - offset) / scale.
|
||||
function sendTrigConfig() {
|
||||
const cal = trig.signal ? calForKey(trig.signal) : Calib.IDENTITY;
|
||||
// A negative calibration gain flips the signal on screen (v_cal = v_raw·scale
|
||||
// + offset with scale < 0), so a calibrated rising edge is a raw FALLING
|
||||
// edge. The hub compares raw samples, so send the raw direction that matches
|
||||
// the edge the user picked on the calibrated trace.
|
||||
let edge = trig.edge;
|
||||
if (cal.scale < 0) {
|
||||
if (edge === 'rising') edge = 'falling';
|
||||
else if (edge === 'falling') edge = 'rising';
|
||||
}
|
||||
wsSend({
|
||||
type: 'setTrigger', signal: trig.signal, edge: trig.edge,
|
||||
type: 'setTrigger', signal: trig.signal, edge: edge,
|
||||
threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec,
|
||||
prePercent: trig.prePercent, mode: trig.mode, holdoffSec: trig.holdoffSec,
|
||||
});
|
||||
@@ -1838,15 +1858,9 @@ function drawCursorLines(u, p) {
|
||||
if (vNorm === null) return;
|
||||
const cy = u.valToPos(vNorm, 'y', true);
|
||||
if (cy < bbox.top || cy > bbox.top + bbox.height) return;
|
||||
// Un-transform normalized value back to real units for display
|
||||
// y_norm = (y_raw - offset) / divValue → y_raw = y_norm * divValue + offset
|
||||
const vs = sigVScale[vsKeyFor(p.id, key)];
|
||||
let vReal = vNorm;
|
||||
if (vs) {
|
||||
const dv = vs._resolvedDiv || vs.divValue || 1;
|
||||
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||
vReal = vNorm * dv + ofs;
|
||||
}
|
||||
// Calibrated value at the cursor time, from the raw source (matches
|
||||
// the hover and the cursor readouts in every display mode).
|
||||
const vReal = calibratedValueAt(key, val);
|
||||
const tc = getSigStyle(key).color;
|
||||
// Diamond marker at intersection
|
||||
ctx.fillStyle = tc;
|
||||
@@ -1860,7 +1874,7 @@ function drawCursorLines(u, p) {
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Value text next to diamond (real units)
|
||||
const str = Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString();
|
||||
const str = vReal === null ? '—' : (Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString());
|
||||
ctx.fillStyle = tc;
|
||||
ctx.font = '11px monospace';
|
||||
const currentAlign = ctx.textAlign;
|
||||
@@ -1898,6 +1912,8 @@ function rulerRawValue(p, yNorm) {
|
||||
// Draw the horizontal value rulers (called from the draw hook).
|
||||
function drawRulerLines(u, p) {
|
||||
if (rulers.mode !== 'on') return;
|
||||
const rs = rulerState[p.id];
|
||||
if (!rs) return;
|
||||
const { ctx, bbox } = u;
|
||||
if (!bbox) return;
|
||||
|
||||
@@ -1926,8 +1942,8 @@ function drawRulerLines(u, p) {
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
drawLine(rulers.yA, 'rgba(166,227,161,0.85)', 'Y1');
|
||||
drawLine(rulers.yB, 'rgba(243,139,168,0.85)', 'Y2');
|
||||
drawLine(rs.yA, 'rgba(166,227,161,0.85)', 'Y1');
|
||||
drawLine(rs.yB, 'rgba(243,139,168,0.85)', 'Y2');
|
||||
}
|
||||
|
||||
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
|
||||
@@ -2133,8 +2149,9 @@ function createUPlot(p) {
|
||||
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';
|
||||
const rs = rulerState[p.id];
|
||||
if (rs && rs.yA !== null && Math.abs(clientY - toY(rs.yA)) <= CURSOR_SNAP_PX) return 'A';
|
||||
if (rs && rs.yB !== null && Math.abs(clientY - toY(rs.yB)) <= CURSOR_SNAP_PX) return 'B';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2171,8 +2188,10 @@ function createUPlot(p) {
|
||||
|
||||
// Set cursor position immediately on mousedown
|
||||
if (yTarget) {
|
||||
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(e);
|
||||
else rulers.yB = _rulerValFromEvent(e);
|
||||
rulers.plotId = p.id; // the readout follows the plot whose rulers moved
|
||||
const rs = getRulerState(p.id);
|
||||
if (yTarget === 'A') rs.yA = _rulerValFromEvent(e);
|
||||
else rs.yB = _rulerValFromEvent(e);
|
||||
} else if (target === 'A') cursors.tA = _cursorValFromEvent(e);
|
||||
else cursors.tB = _cursorValFromEvent(e);
|
||||
updateCursorReadout();
|
||||
@@ -2180,8 +2199,9 @@ function createUPlot(p) {
|
||||
|
||||
const onMove = ev => {
|
||||
if (yTarget) {
|
||||
if (yTarget === 'A') rulers.yA = _rulerValFromEvent(ev);
|
||||
else rulers.yB = _rulerValFromEvent(ev);
|
||||
const rs = getRulerState(p.id);
|
||||
if (yTarget === 'A') rs.yA = _rulerValFromEvent(ev);
|
||||
else rs.yB = _rulerValFromEvent(ev);
|
||||
} else if (target === 'A') cursors.tA = _cursorValFromEvent(ev);
|
||||
else cursors.tB = _cursorValFromEvent(ev);
|
||||
updateCursorReadout();
|
||||
@@ -2753,7 +2773,19 @@ function updateCursorBtnVisibility() {
|
||||
under one — so a zoom, a pan or a new capture can leave them outside the
|
||||
viewport entirely, with no way to get them back: they are dragged by grabbing
|
||||
their line, and an off-screen line cannot be grabbed. */
|
||||
function resetRulers() {
|
||||
// Re-place every plot's rulers at the default ±2 divisions, like
|
||||
// resetCursors re-places the vertical cursors.
|
||||
plots.forEach(p => {
|
||||
const rs = getRulerState(p.id);
|
||||
rs.yA = -2; rs.yB = 2;
|
||||
});
|
||||
updateCursorReadout();
|
||||
cursorsDirty = true;
|
||||
}
|
||||
|
||||
function resetCursors() {
|
||||
resetRulers();
|
||||
const refPlot = plots.find(p => p.uplot);
|
||||
if (!refPlot) return;
|
||||
const { min, max } = refPlot.uplot.scales.x;
|
||||
@@ -2790,9 +2822,13 @@ 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;
|
||||
if (rulers.mode === 'on') {
|
||||
// Auto-place every plot at ±2 divisions from the centre on first use;
|
||||
// afterwards each plot keeps its own positions.
|
||||
plots.forEach(pl => {
|
||||
const rs = getRulerState(pl.id);
|
||||
if (rs.yA === null && rs.yB === null) { rs.yA = -2; rs.yB = 2; }
|
||||
});
|
||||
}
|
||||
updateCursorReadout();
|
||||
cursorsDirty = true;
|
||||
@@ -2809,16 +2845,9 @@ function getValueAtCursor(p, t) {
|
||||
if (!p.uplot || t === null) return null;
|
||||
const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null);
|
||||
if (!key) return null;
|
||||
const idx = p.traces.indexOf(key);
|
||||
if (idx < 0) return null;
|
||||
const vNorm = interpAtTime(p.uplot, idx + 1, t);
|
||||
if (vNorm === null) return null;
|
||||
// Un-normalize: y_norm = (y_raw - offset) / divValue
|
||||
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 * dv + ofs;
|
||||
// Interpolate the raw wire value and apply the calibration explicitly, so
|
||||
// cursor readouts match the hover in every display mode.
|
||||
return calibratedValueAt(key, t);
|
||||
}
|
||||
|
||||
// Update per-plot cursor value readouts (A, B, ΔV) for all plots.
|
||||
@@ -2851,6 +2880,64 @@ function rawFromNorm(p, key, vNorm) {
|
||||
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||
return vNorm * dv + ofs;
|
||||
}
|
||||
// Linear interpolation of a sorted (t, v) pair at absolute time tAbs. Returns
|
||||
// null outside the data's range — never fabricated, so an export or readout
|
||||
// cannot invent samples the signal never had.
|
||||
function interpSortedRaw(t, v, tAbs) {
|
||||
if (!t || t.length === 0) return null;
|
||||
if (tAbs < t[0] || tAbs > t[t.length - 1]) return null;
|
||||
let lo = 0, hi = t.length - 1;
|
||||
while (lo < hi) { const m = (lo + hi) >> 1; if (t[m] < tAbs) lo = m + 1; else hi = m; }
|
||||
if (lo === 0) return v[0] ?? null;
|
||||
const t0 = t[lo - 1], t1 = t[lo];
|
||||
const v0 = v[lo - 1], v1 = v[lo];
|
||||
if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
|
||||
return v0 + (tAbs - t0) / (t1 - t0) * (v1 - v0);
|
||||
}
|
||||
|
||||
// Binary-search linear interpolation of a circular buffer at time t.
|
||||
function interpCircular(buf, t) {
|
||||
if (!buf || buf.size === 0) return null;
|
||||
const { cap, size, head } = buf;
|
||||
const start = (size === cap) ? head : 0;
|
||||
const physAt = k => (start + k) % cap;
|
||||
let lo = 0, hi = size;
|
||||
while (lo < hi) { const m = (lo + hi) >> 1; if (buf.t[physAt(m)] < t) lo = m + 1; else hi = m; }
|
||||
if (lo === 0) return buf.v[physAt(0)] ?? null;
|
||||
if (lo >= size) return buf.v[physAt(size - 1)] ?? null;
|
||||
const t0 = buf.t[physAt(lo - 1)], t1 = buf.t[physAt(lo)];
|
||||
const v0 = buf.v[physAt(lo - 1)], v1 = buf.v[physAt(lo)];
|
||||
if (v0 == null || v1 == null) return v0 ?? v1 ?? null;
|
||||
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
|
||||
}
|
||||
|
||||
// Raw (uncalibrated) value of `key` at absolute time tAbs, from the best
|
||||
// available raw source: trigger snapshot → fetched zoom data → live push
|
||||
// buffer. All three store wire values, so calibration is applied here, at the
|
||||
// point of display, exactly once.
|
||||
function rawAtAbsTime(key, tAbs) {
|
||||
if (trig.snapshot) {
|
||||
const s = trig.snapshot[key];
|
||||
if (s && s.t.length) { const v = interpSortedRaw(s.t, s.v, tAbs); if (v != null) return v; }
|
||||
}
|
||||
for (const p of plots) {
|
||||
const zd = zoomData[p.id];
|
||||
if (!zd) continue;
|
||||
const s = zd.signals[key];
|
||||
if (s && s.t.length) { const v = interpSortedRaw(s.t, s.v, tAbs); if (v != null) return v; }
|
||||
}
|
||||
const buf = buffers[key];
|
||||
if (buf && buf.size) { const v = interpCircular(buf, tAbs); if (v != null) return v; }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calibrated value of `key` at axis time t. Under a trigger the axis is
|
||||
// relative to the trigger instant, so convert to absolute first.
|
||||
function calibratedValueAt(key, t) {
|
||||
const tAbs = (inTrigWindow() && trig.trigTime != null) ? trig.trigTime + t : t;
|
||||
const raw = rawAtAbsTime(key, tAbs);
|
||||
return raw === null ? null : Calib.applyCal(raw, calForKey(key));
|
||||
}
|
||||
|
||||
function hideHoverReadout() {
|
||||
document.getElementById('hover-readout').style.display = 'none';
|
||||
@@ -2870,13 +2957,14 @@ function showHoverReadout(p, e) {
|
||||
const tStr = inTrigWindow() ? 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;
|
||||
// rawFromNorm inverts the vscale transform, which Task 7 made operate on
|
||||
// calibrated values — so this is already in calibrated units.
|
||||
const unit = unitForKey(key);
|
||||
const val = vNorm === null ? '—'
|
||||
: (_fmtVal(rawFromNorm(p, key, vNorm)) + (unit ? ' ' + unit : ''));
|
||||
// Interpolate the raw wire value and apply the calibration explicitly,
|
||||
// so the hover is correct in every display mode (analog, digital,
|
||||
// mixed) and independent of the vscale state.
|
||||
const vCal = calibratedValueAt(key, t);
|
||||
const val = vCal === null ? '—'
|
||||
: (_fmtVal(vCal) + (unit ? ' ' + unit : ''));
|
||||
html += '<div class="hov-row"><span class="hov-dot" style="background:' +
|
||||
escHtml(getSigStyle(key).color) + '"></span>' +
|
||||
'<span class="hov-name">' + escHtml(name) + '</span>' +
|
||||
@@ -2894,17 +2982,25 @@ function showHoverReadout(p, e) {
|
||||
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.
|
||||
// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the plot
|
||||
// whose rulers were last moved, falling back to the first plot with a 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);
|
||||
let ref = null;
|
||||
if (rulers.plotId !== null) {
|
||||
const pl = plots.find(p => p.id === rulers.plotId);
|
||||
if (pl && pl.uplot && pl.traces.length > 0) ref = pl;
|
||||
}
|
||||
if (!ref) {
|
||||
ref = plots.find(p => p.uplot && p.traces.length > 0 &&
|
||||
rulerRawValue(p, 0) !== null) || null;
|
||||
}
|
||||
const rs = ref ? rulerState[ref.id] : null;
|
||||
const conv = y => (y === null || !ref || !rs) ? null : rulerRawValue(ref, y);
|
||||
const vA = conv(rs ? rs.yA : null), vB = conv(rs ? rs.yB : null);
|
||||
document.getElementById('cur-y1').textContent = 'Y1: ' + fmtVal(vA);
|
||||
document.getElementById('cur-y2').textContent = 'Y2: ' + fmtVal(vB);
|
||||
document.getElementById('cur-dy').textContent =
|
||||
@@ -3355,23 +3451,42 @@ function initPlotCfgBar(plotId, p) {
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Layout management
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
// Returns the number of plot cells in a layout (cols × rows).
|
||||
// Returns the number of plot cells in a layout. Custom layouts carry an
|
||||
// explicit plotCount; uniform ones are cols × rows.
|
||||
function layoutPlotCount(cls) {
|
||||
const entry = LAYOUTS.find(l => l[1] === cls);
|
||||
if (entry) {
|
||||
if (entry.length >= 5) return entry[4];
|
||||
return entry[2] * entry[3];
|
||||
}
|
||||
const m = cls.match(/^l(\d+)x(\d+)$/);
|
||||
return m ? parseInt(m[1]) * parseInt(m[2]) : 1;
|
||||
}
|
||||
|
||||
// Build a small SVG grid thumbnail for a given cols×rows layout.
|
||||
function layoutSVG(cols, rows) {
|
||||
// Build a small SVG grid thumbnail for a layout entry. Custom (non-uniform)
|
||||
// layouts draw their own cell arrangement.
|
||||
function layoutSVG(entry) {
|
||||
const W = 28, H = 20, GAP = 1.5, PAD = 1.5;
|
||||
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols;
|
||||
const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows;
|
||||
let rects = '';
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = (PAD + c * (cw + GAP)).toFixed(1);
|
||||
const y = (PAD + r * (ch + GAP)).toFixed(1);
|
||||
rects += `<rect x="${x}" y="${y}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
|
||||
if (entry[1] === 'l1p2') {
|
||||
// 1+2: one full-width cell on top, two side by side below.
|
||||
const cw = (W - PAD * 2 - GAP) / 2;
|
||||
const ch = (H - PAD * 2 - GAP) / 2;
|
||||
const y2 = (PAD + ch + GAP).toFixed(1);
|
||||
const x2 = (PAD + cw + GAP).toFixed(1);
|
||||
rects += `<rect x="${PAD}" y="${PAD}" width="${(W - PAD * 2).toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
|
||||
rects += `<rect x="${PAD}" y="${y2}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
|
||||
rects += `<rect x="${x2}" y="${y2}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
|
||||
} else {
|
||||
const [, , cols, rows] = entry;
|
||||
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols;
|
||||
const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = (PAD + c * (cw + GAP)).toFixed(1);
|
||||
const y = (PAD + r * (ch + GAP)).toFixed(1);
|
||||
rects += `<rect x="${x}" y="${y}" width="${cw.toFixed(1)}" height="${ch.toFixed(1)}" rx="1.5"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
|
||||
@@ -3401,7 +3516,7 @@ function applyLayout(cls) {
|
||||
|
||||
// Update button label
|
||||
const btn = document.getElementById('btn-layout');
|
||||
if (btn) btn.innerHTML = layoutSVG(cols, rows) + ' <span>' + label + '</span> ▾';
|
||||
if (btn) btn.innerHTML = layoutSVG(entry) + ' <span>' + label + '</span> ▾';
|
||||
|
||||
// Update active state in menu
|
||||
document.querySelectorAll('.layout-menu-item')
|
||||
@@ -3436,11 +3551,12 @@ function applyLayout(cls) {
|
||||
function buildLayoutMenu() {
|
||||
const menu = document.getElementById('layout-menu');
|
||||
|
||||
LAYOUTS.forEach(([label, cls, cols, rows]) => {
|
||||
LAYOUTS.forEach(entry => {
|
||||
const [label, cls] = entry;
|
||||
const item = document.createElement('button');
|
||||
item.className = 'layout-menu-item' + (cls === currentLayout ? ' active' : '');
|
||||
item.dataset.layout = cls;
|
||||
item.innerHTML = layoutSVG(cols, rows) + '<span>' + label + '</span>';
|
||||
item.innerHTML = layoutSVG(entry) + '<span>' + label + '</span>';
|
||||
item.addEventListener('click', () => {
|
||||
applyLayout(cls);
|
||||
menu.classList.remove('open');
|
||||
@@ -3467,9 +3583,20 @@ function buildLayoutMenu() {
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Export CSV (all plots) — fetches full-resolution data from ring
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
// Shared busy state for the export dropdown: prevents re-entry and shows
|
||||
// progress on the selector while a (possibly large) export runs.
|
||||
let exportBusy = false;
|
||||
function setExportBusy(busy) {
|
||||
exportBusy = busy;
|
||||
const sel = document.getElementById('export-select');
|
||||
if (!sel) return;
|
||||
sel.disabled = busy;
|
||||
const ph = sel.querySelector('option[value=""]');
|
||||
if (ph) ph.textContent = busy ? '\u23f3 Exporting\u2026' : '\u23ea Export';
|
||||
}
|
||||
|
||||
async function exportAllCSV() {
|
||||
const btn = document.getElementById('btn-csv-all');
|
||||
if (btn.disabled) return;
|
||||
if (exportBusy) return;
|
||||
|
||||
const inTrigMode = trig.enabled && trig.snapshot !== null;
|
||||
|
||||
@@ -3482,8 +3609,8 @@ async function exportAllCSV() {
|
||||
let t0, t1, relOffset = 0;
|
||||
if (inTrigMode) {
|
||||
// Export the full trigger window around the trigger event.
|
||||
t0 = trig.trigTime - trigPreSec();
|
||||
t1 = trig.trigTime + trigPostSec();
|
||||
t0 = trig.trigTime - activePreSec();
|
||||
t1 = trig.trigTime + activePostSec();
|
||||
relOffset = trig.trigTime;
|
||||
} else {
|
||||
// Use the current zoom range if active, else the rolling window.
|
||||
@@ -3504,60 +3631,52 @@ async function exportAllCSV() {
|
||||
t1 = plotNow;
|
||||
}
|
||||
}
|
||||
if (!(t1 > t0)) return;
|
||||
|
||||
// Show loading state.
|
||||
const origLabel = btn.textContent;
|
||||
btn.textContent = '⏳ Downloading…';
|
||||
btn.disabled = true;
|
||||
exportBusy = true;
|
||||
// Cap the export. A full window at a megasample rate is hundreds of MB raw
|
||||
// (the old exact-timestamp merge exploded into millions of rows and crashed
|
||||
// the tab); ask the hub for a min/max-decimated envelope — the same scope
|
||||
// style reduction the live view uses — and cap the number of rows.
|
||||
const BUDGET = 100000; // max rows per signal
|
||||
setExportBusy(true);
|
||||
|
||||
// Fetch full-resolution ring data (n=0 → no decimation).
|
||||
let ringSignals = null;
|
||||
try {
|
||||
ringSignals = await wsZoomRequest(t0, t1, 0, keys);
|
||||
} catch (e) {
|
||||
console.warn('CSV export: ring fetch failed, falling back to push buffer', e);
|
||||
} finally {
|
||||
btn.textContent = origLabel;
|
||||
btn.disabled = false;
|
||||
if (!inTrigMode) {
|
||||
try {
|
||||
ringSignals = await wsZoomRequest(t0, t1, BUDGET, keys);
|
||||
} catch (e) {
|
||||
console.warn('CSV export: ring fetch failed, falling back to local data', e);
|
||||
}
|
||||
}
|
||||
setExportBusy(false);
|
||||
|
||||
// Build per-signal time/value arrays.
|
||||
// Priority: ring buffer (full res) → trigger snapshot → push buffer.
|
||||
// Per-signal raw source: hub ring (whole window, decimated) → trigger
|
||||
// snapshot (already \u226420k pts) → local push buffer.
|
||||
const slices = keys.map(key => {
|
||||
const rd = ringSignals && ringSignals[key];
|
||||
if (rd && rd.t && rd.t.length > 0) {
|
||||
const t = rd.t, v = rd.v;
|
||||
if (inTrigMode) {
|
||||
return { t: Array.from(t).map(ts => ts - relOffset), v: Array.from(v) };
|
||||
}
|
||||
return { t: Array.from(t), v: Array.from(v) };
|
||||
if (!inTrigMode) {
|
||||
const rd = ringSignals && ringSignals[key];
|
||||
if (rd && rd.t && rd.t.length > 0) return { key, t: rd.t, v: rd.v };
|
||||
}
|
||||
// Fallback: push buffer or trigger snapshot.
|
||||
if (inTrigMode) {
|
||||
const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) };
|
||||
return { t: Array.from(raw.t).map(ts => ts - relOffset), v: Array.from(raw.v) };
|
||||
return { key, t: raw.t, v: raw.v };
|
||||
}
|
||||
const buf = buffers[key]; if (!buf) return { t: [], v: [] };
|
||||
const sl = getBufferSliceRange(buf, t0, t1);
|
||||
return { t: Array.from(sl.t), v: Array.from(sl.v) };
|
||||
const buf = buffers[key];
|
||||
const sl = buf ? getBufferSliceRange(buf, t0, t1) : { t: new Float64Array(0), v: new Float64Array(0) };
|
||||
return { key, t: sl.t, v: sl.v };
|
||||
});
|
||||
const present = slices.filter(s => s.t.length > 0);
|
||||
if (!present.length) return;
|
||||
|
||||
// Merge all timestamps and build aligned rows.
|
||||
const allT = new Set();
|
||||
slices.forEach(s => s.t.forEach(t => allT.add(t)));
|
||||
const sortedT = Array.from(allT).sort((a, b) => a - b);
|
||||
if (!sortedT.length) return;
|
||||
// Master time grid = the signal with the most samples; every other signal is
|
||||
// resampled onto it (linear, no extrapolation). Cells outside a signal's own
|
||||
// span stay empty rather than being fabricated, so continuous signals export
|
||||
// without holes and no value is invented.
|
||||
let master = present[0];
|
||||
present.forEach(s => { if (s.t.length > master.t.length) master = s; });
|
||||
|
||||
const lookups = slices.map(s => {
|
||||
const m = new Map();
|
||||
s.t.forEach((t, i) => m.set(t, s.v[i]));
|
||||
return m;
|
||||
});
|
||||
|
||||
// Strip "sourceId:" prefix from column headers for readability, and append
|
||||
// the effective unit. These values come straight from the ring/history/
|
||||
// snapshot and never pass through applyVScaleNorm, so calibrate them here.
|
||||
const cals = keys.map(k => calForKey(k));
|
||||
const cals = new Map(keys.map(k => [k, calForKey(k)]));
|
||||
const displayKeys = keys.map(k => {
|
||||
const name = k.includes(':') ? k.split(':').slice(1).join(':') : k;
|
||||
const u = unitForKey(k);
|
||||
@@ -3566,10 +3685,21 @@ async function exportAllCSV() {
|
||||
});
|
||||
const timeCol = '"' + (inTrigMode ? 'time_rel_s' : 'time_s') + '"';
|
||||
const hdr = [timeCol, ...displayKeys].join(',');
|
||||
const rows = sortedT.map(t =>
|
||||
[t.toFixed(9), ...lookups.map((lk, i) =>
|
||||
lk.has(t) ? Calib.applyCal(lk.get(t), cals[i]) : '')].join(',')
|
||||
);
|
||||
|
||||
const rows = new Array(master.t.length);
|
||||
for (let i = 0; i < master.t.length; i++) {
|
||||
const tAbs = master.t[i];
|
||||
const cells = present.map(s => {
|
||||
if (s === master) {
|
||||
return Calib.applyCal(master.v[i], cals.get(s.key));
|
||||
}
|
||||
const v = interpSortedRaw(s.t, s.v, tAbs);
|
||||
return v === null ? '' : Calib.applyCal(v, cals.get(s.key));
|
||||
});
|
||||
const tt = inTrigMode ? tAbs - relOffset : tAbs;
|
||||
rows[i] = [tt.toFixed(9), ...cells].join(',');
|
||||
}
|
||||
|
||||
const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
@@ -3958,6 +4088,7 @@ function onSources(msg) {
|
||||
});
|
||||
buildSidebar();
|
||||
if (statsOpen) _refreshStatsSelector();
|
||||
maybeRestoreViewLate();
|
||||
}
|
||||
|
||||
function addSourceWS(label, addr, multicastGroup, dataPort) {
|
||||
@@ -4582,7 +4713,303 @@ initSignalMenu();
|
||||
const cb = document.getElementById('cb-monotonic');
|
||||
if (cb) cb.checked = localStorage.getItem('udpscope.monotonic') === '1';
|
||||
}
|
||||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||||
// Export every stored sample of the plotted signals as a Parquet file, served
|
||||
// by the Go hub's /api/export. Full resolution (no decimation) and hole-free
|
||||
// (each signal keeps its own timestamps — long format). The file can be huge
|
||||
// (hundreds of MB at high rates), so stream it to disk when the File System
|
||||
// Access API is available instead of holding it in a Blob.
|
||||
async function exportParquet() {
|
||||
if (exportBusy) return;
|
||||
|
||||
const inTrigMode = trig.enabled && trig.snapshot !== null;
|
||||
const keys = [];
|
||||
plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); }));
|
||||
if (!keys.length) return;
|
||||
|
||||
// Same range resolution as the CSV export.
|
||||
let t0, t1;
|
||||
if (inTrigMode) {
|
||||
t0 = trig.trigTime - activePreSec();
|
||||
t1 = trig.trigTime + activePostSec();
|
||||
} else {
|
||||
const refPlot = plots.find(p => p.xRange);
|
||||
if (refPlot) {
|
||||
[t0, t1] = refPlot.xRange;
|
||||
} else {
|
||||
let plotNow = -Infinity;
|
||||
keys.forEach(k => {
|
||||
const buf = buffers[k];
|
||||
if (buf && buf.size > 0) {
|
||||
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
|
||||
if (t > plotNow) plotNow = t;
|
||||
}
|
||||
});
|
||||
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
|
||||
t0 = plotNow - windowSec;
|
||||
t1 = plotNow;
|
||||
}
|
||||
}
|
||||
if (!(t1 > t0)) return;
|
||||
|
||||
exportBusy = true;
|
||||
setExportBusy(true);
|
||||
try {
|
||||
const url = '/api/export?t0=' + t0.toFixed(9) + '&t1=' + t1.toFixed(9) +
|
||||
'&signals=' + encodeURIComponent(keys.join(','));
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
alert('Parquet export failed (HTTP ' + resp.status + ').\n\n' +
|
||||
'The /api/export endpoint is provided by the Go hub; the C++ ' +
|
||||
'StreamHub does not serve it.');
|
||||
return;
|
||||
}
|
||||
const filename = 'signals_' + Date.now() + '.parquet';
|
||||
if (window.showSaveFilePicker && resp.body) {
|
||||
try {
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: 'Parquet', accept: { 'application/vnd.apache.parquet': ['.parquet'] } }],
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await resp.body.pipeTo(writable);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') return; // user cancelled the picker
|
||||
console.warn('parquet export: file picker failed, falling back to Blob', e);
|
||||
}
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) {
|
||||
console.warn('parquet export failed', e);
|
||||
alert('Parquet export failed: ' + e.message);
|
||||
} finally {
|
||||
setExportBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Export dropdown: dispatch on selection, then reset to the placeholder so the
|
||||
// same format can be chosen again.
|
||||
document.getElementById('export-select').addEventListener('change', () => {
|
||||
const sel = document.getElementById('export-select');
|
||||
const fmt = sel.value;
|
||||
sel.value = '';
|
||||
if (fmt === 'csv') exportAllCSV();
|
||||
else if (fmt === 'parquet') exportParquet();
|
||||
});
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
View-state persistence (cookie)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
// The whole client view — layout, plots (traces/titles/modes), window, trigger
|
||||
// configuration, rulers, sources — is serialised into one cookie so a reload
|
||||
// restores the previous view. Cookies are size-limited, so the state degrades
|
||||
// gracefully (rulers → trigger → sources → traces) when it would not fit.
|
||||
const VIEW_COOKIE = 'udpscope.view';
|
||||
const VIEW_COOKIE_MAX = 3500; // encoded chars; browsers cap cookies at ~4 KiB
|
||||
|
||||
function packViewState() {
|
||||
const state = {
|
||||
v: 1,
|
||||
windowSec: windowSec,
|
||||
layout: currentLayout,
|
||||
plots: plots.map(p => ({
|
||||
title: p.title,
|
||||
mode: p.mode,
|
||||
traces: p.traces.map(k => {
|
||||
const colon = k.indexOf(':');
|
||||
const name = colon >= 0 ? k.slice(colon + 1) : k;
|
||||
return { key: k, label: srcLabelForKey(k), name };
|
||||
}),
|
||||
})),
|
||||
trig: {
|
||||
enabled: trig.enabled, signal: trig.signal, edge: trig.edge,
|
||||
threshold: trig.threshold, windowSec: trig.windowSec,
|
||||
prePercent: trig.prePercent, mode: trig.mode, holdoffSec: trig.holdoffSec,
|
||||
},
|
||||
rulers: {
|
||||
mode: rulers.mode,
|
||||
plotId: plots.findIndex(p => p.id === rulers.plotId),
|
||||
states: plots.map(p => {
|
||||
const rs = rulerState[p.id];
|
||||
return rs ? { yA: rs.yA, yB: rs.yB } : { yA: null, yB: null };
|
||||
}),
|
||||
},
|
||||
sources: Object.values(sourcesMap).map(s => ({
|
||||
label: s.label || s.addr || s.id, addr: s.addr,
|
||||
})),
|
||||
};
|
||||
let s = JSON.stringify(state);
|
||||
const tooBig = () => encodeURIComponent(s).length > VIEW_COOKIE_MAX;
|
||||
if (tooBig()) { delete state.rulers; s = JSON.stringify(state); }
|
||||
if (tooBig()) { delete state.trig; s = JSON.stringify(state); }
|
||||
if (tooBig()) { delete state.sources; s = JSON.stringify(state); }
|
||||
if (tooBig()) {
|
||||
state.plots = state.plots.map(p => ({ title: p.title, mode: p.mode }));
|
||||
s = JSON.stringify(state);
|
||||
}
|
||||
if (tooBig()) { state.plots = []; s = JSON.stringify(state); }
|
||||
return s;
|
||||
}
|
||||
|
||||
// Saves are gated until the saved view has been re-applied (phase 2) or the
|
||||
// grace timeout fires: otherwise the very first periodic save would overwrite
|
||||
// the cookie with the not-yet-restored (empty) state and destroy it.
|
||||
let _viewSaveReady = false;
|
||||
function saveViewState() {
|
||||
if (!_viewSaveReady) return;
|
||||
try {
|
||||
const s = packViewState();
|
||||
document.cookie = VIEW_COOKIE + '=' + encodeURIComponent(s) +
|
||||
'; path=/; max-age=31536000; SameSite=Lax';
|
||||
} catch (e) {
|
||||
console.warn('view cookie save failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function readViewState() {
|
||||
try {
|
||||
const prefix = VIEW_COOKIE + '=';
|
||||
const m = document.cookie.split('; ').find(c => c.startsWith(prefix));
|
||||
if (!m) return null;
|
||||
const st = JSON.parse(decodeURIComponent(m.slice(prefix.length)));
|
||||
return (st && st.v === 1) ? st : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1 (init): layout, plot cards (titles/modes), window, rulers. Traces and
|
||||
// the trigger need sources/signals loaded, so they are applied in phase 2.
|
||||
function restoreViewState() {
|
||||
const st = readViewState();
|
||||
if (!st) return;
|
||||
|
||||
if (st.layout && LAYOUTS.some(l => l[1] === st.layout)) applyLayout(st.layout);
|
||||
|
||||
const plotState = st.plots || [];
|
||||
plotState.forEach((ps, i) => {
|
||||
const p = plots[i];
|
||||
if (!p) return;
|
||||
if (ps.title && ps.title !== 'Plot ' + p.id) {
|
||||
p.title = ps.title;
|
||||
const tEl = document.getElementById('ptitle-' + p.id);
|
||||
if (tEl) tEl.textContent = ps.title;
|
||||
const inp = document.querySelector('#pcfg-' + p.id + ' .pcfg-title-input');
|
||||
if (inp) inp.value = ps.title;
|
||||
}
|
||||
if (ps.mode && ps.mode !== p.mode) {
|
||||
p.mode = ps.mode;
|
||||
document.querySelectorAll('#pcfg-' + p.id + ' .pcfg-mode-btn')
|
||||
.forEach(b => b.classList.toggle('active', b.dataset.mode === ps.mode));
|
||||
}
|
||||
});
|
||||
|
||||
if (st.windowSec != null) {
|
||||
windowSec = st.windowSec;
|
||||
const sel = document.getElementById('window-select');
|
||||
if (sel && [...sel.options].some(o => o.value === String(st.windowSec))) {
|
||||
sel.value = String(st.windowSec);
|
||||
}
|
||||
}
|
||||
|
||||
if (st.rulers) {
|
||||
rulers.mode = st.rulers.mode === 'on' ? 'on' : 'off';
|
||||
const btn = document.getElementById('btn-ruler');
|
||||
if (btn) btn.classList.toggle('active', rulers.mode === 'on');
|
||||
(st.rulers.states || []).forEach((rs, i) => {
|
||||
const p = plots[i];
|
||||
if (!p || !rs) return;
|
||||
const cur = getRulerState(p.id);
|
||||
cur.yA = rs.yA; cur.yB = rs.yB;
|
||||
});
|
||||
if (st.rulers.plotId != null && plots[st.rulers.plotId]) {
|
||||
rulers.plotId = plots[st.rulers.plotId].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild a saved trace key against the current sources: source ids change
|
||||
// across restarts, so match by label and fall back to the saved id-key.
|
||||
function restoreTraceKey(entry) {
|
||||
const src = Object.values(sourcesMap).find(s => (s.label || s.id) === entry.label);
|
||||
return src ? (src.id + ':' + entry.name) : entry.key;
|
||||
}
|
||||
|
||||
// Phase 2 (first sources + signals): reconcile sources, re-apply traces, then
|
||||
// the trigger configuration and the window to the hub.
|
||||
let _viewLateRestored = false;
|
||||
function maybeRestoreViewLate() {
|
||||
if (_viewLateRestored) return;
|
||||
const st = readViewState();
|
||||
if (!st) { _viewLateRestored = true; return; }
|
||||
|
||||
// Add any saved sources the hub does not already have (it persists its own).
|
||||
const known = Object.values(sourcesMap).map(s => (s.label || s.addr || s.id) + '\u0000' + s.addr);
|
||||
let added = false;
|
||||
(st.sources || []).forEach(sv => {
|
||||
const k = (sv.label || sv.addr) + '\u0000' + (sv.addr || '');
|
||||
if (!known.includes(k)) { addSourceWS(sv.label, sv.addr, sv.multicastGroup, sv.dataPort); added = true; }
|
||||
});
|
||||
if (added) return; // re-enter when the new sources appear
|
||||
|
||||
// Traces and the trigger selector need at least one source with signals.
|
||||
if (!Object.values(sourcesMap).some(s => (s.signals || []).length > 0)) return;
|
||||
_viewLateRestored = true;
|
||||
_viewSaveReady = true;
|
||||
|
||||
(st.plots || []).forEach((ps, i) => {
|
||||
const p = plots[i];
|
||||
if (!p) return;
|
||||
(ps.traces || []).forEach(t => addTraceTo(p.id, restoreTraceKey(t)));
|
||||
});
|
||||
|
||||
if (st.windowSec != null) {
|
||||
windowSec = st.windowSec;
|
||||
sendWindow();
|
||||
}
|
||||
|
||||
const t = st.trig;
|
||||
if (t) {
|
||||
trig.edge = t.edge || trig.edge;
|
||||
if (t.threshold != null) trig.threshold = t.threshold;
|
||||
if (t.windowSec != null) trig.windowSec = t.windowSec;
|
||||
if (t.prePercent != null) trig.prePercent = t.prePercent;
|
||||
trig.mode = t.mode || trig.mode;
|
||||
if (t.holdoffSec != null) trig.holdoffSec = t.holdoffSec;
|
||||
trig.signal = t.signal || '';
|
||||
const el = id => document.getElementById(id);
|
||||
if (el('trig-edge')) el('trig-edge').value = trig.edge;
|
||||
if (el('trig-window')) el('trig-window').value = String(trig.windowSec);
|
||||
if (el('trig-mode')) el('trig-mode').value = trig.mode;
|
||||
if (el('trig-holdoff')) el('trig-holdoff').value = trig.holdoffSec;
|
||||
if (el('trig-pre')) el('trig-pre').value = String(trig.prePercent);
|
||||
if (el('trig-pre-val')) el('trig-pre-val').textContent = trig.prePercent + '%';
|
||||
refreshTrigThresholdField();
|
||||
const selSig = document.getElementById('trig-signal');
|
||||
if (selSig && trig.signal) {
|
||||
const base = trig.signal.replace(/\[\d+\]$/, '');
|
||||
if ([...selSig.options].some(o => o.value === base)) selSig.value = base;
|
||||
}
|
||||
if (t.enabled) openTrigBar(true);
|
||||
else updateTrigStatusBadge('idle');
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic save keeps the cookie current without wiring every control; the
|
||||
// pagehide save captures the final state on close/reload.
|
||||
setInterval(saveViewState, 3000);
|
||||
window.addEventListener('pagehide', saveViewState);
|
||||
// Hub down / never connected: stop gating after 10 s so layout, window and
|
||||
// rulers still persist even though traces and the trigger could not be
|
||||
// restored.
|
||||
setTimeout(() => { _viewSaveReady = true; }, 10000);
|
||||
|
||||
|
||||
document.getElementById('history-badge').addEventListener('click', toggleHistoryPanel);
|
||||
document.getElementById('btn-hist-cancel').addEventListener('click', toggleHistoryPanel);
|
||||
document.getElementById('btn-hist-apply').addEventListener('click', applyHistoryBudget);
|
||||
@@ -4592,6 +5019,7 @@ document.getElementById('stats-source-sel').addEventListener('change', e => {
|
||||
statsSelectedSrc = e.target.value || null;
|
||||
renderStats();
|
||||
});
|
||||
restoreViewState();
|
||||
resolveHub().then(connectWS);
|
||||
requestAnimationFrame(renderDirtyPlots);
|
||||
fetch('/version').then(r => r.text()).then(v => {
|
||||
|
||||
@@ -41,7 +41,11 @@
|
||||
<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-fit" class="ctrl-btn">Fit</button>
|
||||
<button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button>
|
||||
<select id="export-select" class="ctrl-select" title="Export the visible signals">
|
||||
<option value="" disabled selected>⬇ Export</option>
|
||||
<option value="csv" title="Export the visible signals as CSV (decimated to a bounded row count)">CSV</option>
|
||||
<option value="parquet" title="Export every stored sample (full resolution, no holes) as Parquet — requires the Go hub">Parquet</option>
|
||||
</select>
|
||||
<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-pause-global" class="ctrl-btn">⏸ Pause</button>
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height:100%; background:var(--bg); color:var(--text);
|
||||
font-family:'Segoe UI',system-ui,sans-serif; font-size:14px; overflow:hidden; }
|
||||
/* Uniform 0.9x compaction — scales every element (fonts, bars, plots,
|
||||
spacing) while reflowing layout. `zoom` (Chrome/Edge/Safari, Firefox 126+)
|
||||
is preferred over `transform: scale` because it reflows, so fixed-position
|
||||
bars and JS-computed offsets stay aligned. */
|
||||
html { zoom: 0.9; }
|
||||
::-webkit-scrollbar { width:6px; }
|
||||
::-webkit-scrollbar-track { background:var(--mantle); }
|
||||
::-webkit-scrollbar-thumb { background:var(--surface1); border-radius:3px; }
|
||||
@@ -256,6 +261,9 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
#plot-grid.l2x3 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr 1fr; }
|
||||
#plot-grid.l1x4 { grid-template-columns:1fr; grid-template-rows:1fr 1fr 1fr 1fr; }
|
||||
#plot-grid.l4x1 { grid-template-columns:1fr 1fr 1fr 1fr; grid-template-rows:1fr; }
|
||||
/* 1+2 layout: one plot spanning the top row, two side by side below. */
|
||||
#plot-grid.l1p2 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; }
|
||||
#plot-grid.l1p2 .plot-card:first-child { grid-column: 1 / -1; }
|
||||
|
||||
/* ── Plot card ────────────────────────────────────────────────── */
|
||||
.plot-card {
|
||||
|
||||
+14
-2
@@ -1,7 +1,19 @@
|
||||
module marte2/common
|
||||
|
||||
go 1.21
|
||||
go 1.24.9
|
||||
|
||||
require github.com/gorilla/websocket v1.5.1
|
||||
|
||||
require golang.org/x/net v0.17.0 // indirect
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/parquet-go/bitpack v1.0.0 // indirect
|
||||
github.com/parquet-go/jsonlite v1.0.0 // indirect
|
||||
github.com/parquet-go/parquet-go v0.32.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/twpayne/go-geom v1.6.1 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
|
||||
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
|
||||
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
|
||||
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
|
||||
github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayMkf+znzM=
|
||||
github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
|
||||
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
|
||||
@@ -422,6 +422,26 @@ func (hw *historyWriter) window() float64 {
|
||||
return hw.windowSec
|
||||
}
|
||||
|
||||
// coversWindow reports whether the archive file for key currently spans at
|
||||
// least sec seconds. When true, backfillCaptureHead can reconstruct a capture's
|
||||
// front out of the archive, so the trigger need not wait for the ring to cover
|
||||
// the whole window on its own.
|
||||
func (hw *historyWriter) coversWindow(key string, sec float64) bool {
|
||||
if !hw.enabled() || !(sec > 0) {
|
||||
return false
|
||||
}
|
||||
hw.mu.RLock()
|
||||
hf, ok := hw.files[key]
|
||||
hw.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
hf.mu.RLock()
|
||||
span := hf.tNewest - hf.tOldest
|
||||
hf.mu.RUnlock()
|
||||
return span >= sec
|
||||
}
|
||||
|
||||
// setWindow points the archive at the timespan the clients are looking at, and
|
||||
// re-sizes the files that no longer match it. It reports whether any file's
|
||||
// geometry changed, which invalidates what clients know about the archive.
|
||||
|
||||
@@ -92,6 +92,14 @@ type triggerEngine struct {
|
||||
bufGrowth float64
|
||||
bufKnown bool
|
||||
bufRateOK bool
|
||||
// bufCoverage is the maximum span (seconds) the ring can reach at its
|
||||
// current bucket and capacity — the gate must never demand more than this,
|
||||
// or a ring whose coverage is below the window can never satisfy it. 0 =
|
||||
// unknown (no measurable rate).
|
||||
bufCoverage float64
|
||||
// bufArchived is true when the disk history already spans the trigger
|
||||
// window, so a short capture's front can be back-filled from it.
|
||||
bufArchived bool
|
||||
// Reference point the growth is measured against.
|
||||
bufRefSpan, bufRefWall float64
|
||||
|
||||
@@ -179,6 +187,7 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
|
||||
if base != te.baseKey {
|
||||
// The buffer measurement belongs to the old signal's ring.
|
||||
te.bufKnown, te.bufRateOK = false, false
|
||||
te.bufCoverage, te.bufArchived = 0, false
|
||||
}
|
||||
te.baseKey, te.elemIdx = base, idx
|
||||
te.prevValid = false
|
||||
@@ -285,13 +294,16 @@ const bufGrowthIntervalSec = 0.5
|
||||
const bufGrowthSmooth = 0.5
|
||||
|
||||
// setBuffered records how far back the trigger signal's ring reaches, at wall
|
||||
// clock now, and derives how fast that is growing. Pass known=false when there
|
||||
// is no such ring.
|
||||
func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
// clock now, and derives how fast that is growing. coverage is the maximum
|
||||
// span (seconds) the ring can reach at its current bucket/capacity; archived
|
||||
// says the disk history already spans the trigger window. Pass known=false when
|
||||
// there is no ring to measure.
|
||||
func (te *triggerEngine) setBuffered(span, coverage float64, archived, known bool, now float64) {
|
||||
te.mu.Lock()
|
||||
defer te.mu.Unlock()
|
||||
if !known {
|
||||
te.bufKnown, te.bufRateOK = false, false
|
||||
te.bufCoverage, te.bufArchived = 0, false
|
||||
return
|
||||
}
|
||||
if !te.bufKnown {
|
||||
@@ -299,6 +311,8 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
te.bufRefSpan, te.bufRefWall = span, now
|
||||
}
|
||||
te.bufSpan = span
|
||||
te.bufCoverage = coverage
|
||||
te.bufArchived = archived
|
||||
dt := now - te.bufRefWall
|
||||
if dt < bufGrowthIntervalSec {
|
||||
return
|
||||
@@ -336,9 +350,17 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
// anyway. A full one grows only as fast as its incoming samples free space —
|
||||
// re-bucketing to a longer window replaces dense old samples with sparse new
|
||||
// ones — and it is that case, growth well below 1, where firing on the
|
||||
// pre-window alone delivers a capture whose front has been overwritten by the
|
||||
// time it is read. In the steady state growth is 0 and need is the whole
|
||||
// window, which a ring tuned for that window already exceeds, so nothing waits.
|
||||
//
|
||||
// Two escapes keep an armed trigger from staying deaf forever:
|
||||
//
|
||||
// - archived — the disk history already spans the window, so the front of a
|
||||
// capture can be back-filled from it; the ring only needs to
|
||||
// hold the pre-window worth of recent data.
|
||||
// - coverage — never demand more than the ring can physically reach. If its
|
||||
// coverage saturates below the window (a measured source rate
|
||||
// that over-estimates the true one), the gate opens once the
|
||||
// ring is full anyway and a short capture is delivered instead
|
||||
// of deafness.
|
||||
func (te *triggerEngine) fillNeedLocked() float64 {
|
||||
pre := te.cfg.windowSec * te.cfg.prePercent / 100
|
||||
growth := 0.0 // until measured, assume the buffer will not fill on its own
|
||||
@@ -349,6 +371,14 @@ func (te *triggerEngine) fillNeedLocked() float64 {
|
||||
if need < pre {
|
||||
need = pre
|
||||
}
|
||||
if te.bufArchived {
|
||||
// The archive back-fills the front; the ring holds the post-trigger
|
||||
// window live, so the pre-window is all it needs to have reached.
|
||||
return pre
|
||||
}
|
||||
if te.bufCoverage > 0 && need > te.bufCoverage {
|
||||
need = te.bufCoverage
|
||||
}
|
||||
return need
|
||||
}
|
||||
|
||||
@@ -655,19 +685,32 @@ func (h *Hub) refreshTriggerFill() {
|
||||
return
|
||||
}
|
||||
now := float64(time.Now().UnixNano()) / 1e9
|
||||
key := h.trigger.baseSignalKey()
|
||||
var rb *sigRing
|
||||
if key := h.trigger.baseSignalKey(); key != "" {
|
||||
if key != "" {
|
||||
rb = h.getRing(key)
|
||||
}
|
||||
if rb == nil {
|
||||
// Nothing to measure. Do not gate on a signal the hub does not carry:
|
||||
// that would leave the trigger armed forever, which is worse than a
|
||||
// short capture.
|
||||
h.trigger.setBuffered(0, false, now)
|
||||
h.trigger.setBuffered(0, 0, false, false, now)
|
||||
return
|
||||
}
|
||||
_, span := rb.stats()
|
||||
h.trigger.setBuffered(span, true, now)
|
||||
// Maximum span the ring can ever reach at its current bucket/capacity, in
|
||||
// seconds. The gate must never demand more than this, or a ring whose
|
||||
// coverage is below the window (a measured source rate that over-estimates
|
||||
// the true one) can never satisfy it.
|
||||
coverage := 0.0
|
||||
if rate := rb.sourceRate(); rate > 0 {
|
||||
coverage = float64(ringCoverage(rb.bucketSize(), rb.capacity())) / rate
|
||||
}
|
||||
// If the disk archive already spans the trigger window, the front of a
|
||||
// short capture can be back-filled from it, so the ring need not cover the
|
||||
// whole window on its own.
|
||||
archived := h.hist.coversWindow(key, h.trigger.Config().windowSec)
|
||||
h.trigger.setBuffered(span, coverage, archived, true, now)
|
||||
}
|
||||
|
||||
// triggerTick services the trigger FSM; called from Hub.Run() on every push tick.
|
||||
|
||||
@@ -123,7 +123,7 @@ func (s pulseTrainSim) run(t *testing.T, key string) pulseTrainResult {
|
||||
tick := now + s.batchSec
|
||||
h.retuneRings(tick)
|
||||
_, span := h.rings[key].stats()
|
||||
h.trigger.setBuffered(span, true, tick)
|
||||
h.trigger.setBuffered(span, 0, false, true, tick)
|
||||
|
||||
if trigTime, pre, post, ok := h.trigger.dueCapture(tick); ok {
|
||||
if buf := h.buildTriggerCapture(trigTime, pre, post); buf != nil {
|
||||
|
||||
@@ -297,9 +297,9 @@ func TestCollectingIsBroadcast(t *testing.T) {
|
||||
// later. It forgets any earlier measurement first, so the rate is the one
|
||||
// asked for rather than a blend with it.
|
||||
func setFill(te *triggerEngine, span, growth, now float64) {
|
||||
te.setBuffered(0, false, now)
|
||||
te.setBuffered(span-growth, true, now)
|
||||
te.setBuffered(span, true, now+1)
|
||||
te.setBuffered(0, 0, false, false, now)
|
||||
te.setBuffered(span-growth, 0, false, true, now)
|
||||
te.setBuffered(span, 0, false, true, now+1)
|
||||
}
|
||||
|
||||
// What has to hold is that the buffer spans the whole window by the time the
|
||||
@@ -417,9 +417,9 @@ func TestForceIgnoresFillGate(t *testing.T) {
|
||||
// interval, so they refresh the span and leave the seeded rate alone.
|
||||
func seedFillNow(te *triggerEngine, span, growth float64) {
|
||||
now := float64(time.Now().UnixNano()) / 1e9
|
||||
te.setBuffered(0, false, now-1)
|
||||
te.setBuffered(span-growth, true, now-1)
|
||||
te.setBuffered(span, true, now)
|
||||
te.setBuffered(0, 0, false, false, now-1)
|
||||
te.setBuffered(span-growth, 0, false, true, now-1)
|
||||
te.setBuffered(span, 0, false, true, now)
|
||||
}
|
||||
|
||||
// While it holds off, the trigger looks identical to one that is ignoring
|
||||
@@ -505,3 +505,55 @@ func drainStates(t *testing.T, h *Hub) []map[string]any {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A ring whose coverage saturates below the window (measured source rate that
|
||||
// over-estimates the true one) can never satisfy the full-window need. The
|
||||
// coverage clamp must open the gate once the ring is full, delivering a short
|
||||
// capture rather than staying deaf forever.
|
||||
func TestFillNeedClampedToCoverage(t *testing.T) {
|
||||
te := newTriggerEngine()
|
||||
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2})
|
||||
setFill(te, 50, 0, 100) // ring full at 50 s, no growth
|
||||
te.mu.Lock()
|
||||
te.bufCoverage = 50 // the ring can never reach further back
|
||||
te.mu.Unlock()
|
||||
|
||||
if need := te.fillNeedLocked(); need != 50 {
|
||||
t.Errorf("need = %v, want 50 (clamped to coverage, not the 60 s window)", need)
|
||||
}
|
||||
if f := te.fillLocked(); f < 1 {
|
||||
t.Errorf("fillLocked = %v, want >= 1: a full ring below the window must still open the gate", f)
|
||||
}
|
||||
|
||||
// Without the clamp the gate would stay shut forever.
|
||||
te.mu.Lock()
|
||||
te.bufCoverage = 0
|
||||
te.mu.Unlock()
|
||||
if f := te.fillLocked(); f >= 1 {
|
||||
t.Errorf("baseline: fillLocked = %v, want < 1 without a coverage clamp", f)
|
||||
}
|
||||
}
|
||||
|
||||
// When the disk archive already spans the window it can back-fill the front of
|
||||
// a capture, so the gate must only require the ring to have reached the
|
||||
// pre-window, not the whole window.
|
||||
func TestFillNeedArchiveLowersToPreWindow(t *testing.T) {
|
||||
te := newTriggerEngine()
|
||||
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2})
|
||||
setFill(te, 30, 0, 100) // ring holds only 30 s, no growth → need 60 without archive
|
||||
te.mu.Lock()
|
||||
te.bufArchived = true
|
||||
te.mu.Unlock()
|
||||
|
||||
if want := 12.0; te.fillNeedLocked() != want { // 60 * 0.20
|
||||
t.Errorf("need = %v, want %v (archive lowers to the pre-window)", te.fillNeedLocked(), want)
|
||||
}
|
||||
|
||||
// A ring holding just the pre-window opens the gate once archived.
|
||||
te.mu.Lock()
|
||||
te.bufSpan = 12
|
||||
te.mu.Unlock()
|
||||
if f := te.fillLocked(); f < 1 {
|
||||
t.Errorf("fillLocked = %v, want >= 1 with pre-window buffered and the archive available", f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ core:
|
||||
$(MAKE) -C Source/Components/DataSources/UDPStreamerClient -f Makefile.gcc
|
||||
$(MAKE) -C Source/Components/GAMs/SineArrayGAM -f Makefile.gcc
|
||||
$(MAKE) -C Source/Components/GAMs/TimeArrayGAM -f Makefile.gcc
|
||||
$(MAKE) -C Source/Components/GAMs/PulseGeneratorGAM -f Makefile.gcc
|
||||
$(MAKE) -C Source/Components/GAMs/SlowControlGAM -f Makefile.gcc
|
||||
$(MAKE) -C Source/Components/Interfaces/TCPLogger -f Makefile.gcc
|
||||
$(MAKE) -C Source/Components/Interfaces/DebugService -f Makefile.gcc
|
||||
|
||||
@@ -37,6 +39,8 @@ clean:
|
||||
$(MAKE) -C Source/Components/DataSources/UDPStreamerClient -f Makefile.gcc clean
|
||||
$(MAKE) -C Source/Components/GAMs/SineArrayGAM -f Makefile.gcc clean
|
||||
$(MAKE) -C Source/Components/GAMs/TimeArrayGAM -f Makefile.gcc clean
|
||||
$(MAKE) -C Source/Components/GAMs/PulseGeneratorGAM -f Makefile.gcc clean
|
||||
$(MAKE) -C Source/Components/GAMs/SlowControlGAM -f Makefile.gcc clean
|
||||
$(MAKE) -C Source/Components/Interfaces/TCPLogger -f Makefile.gcc clean
|
||||
$(MAKE) -C Source/Components/Interfaces/DebugService -f Makefile.gcc clean
|
||||
$(MAKE) -C Test/Components/DataSources/UDPStreamer -f Makefile.gcc clean
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
all:
|
||||
$(MAKE) -C SineArrayGAM -f Makefile.gcc
|
||||
$(MAKE) -C TimeArrayGAM -f Makefile.gcc
|
||||
$(MAKE) -C PulseGeneratorGAM -f Makefile.gcc
|
||||
$(MAKE) -C SlowControlGAM -f Makefile.gcc
|
||||
|
||||
clean:
|
||||
$(MAKE) -C SineArrayGAM -f Makefile.gcc clean
|
||||
$(MAKE) -C TimeArrayGAM -f Makefile.gcc clean
|
||||
$(MAKE) -C PulseGeneratorGAM -f Makefile.gcc clean
|
||||
$(MAKE) -C SlowControlGAM -f Makefile.gcc clean
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
|
||||
namespace MARTe {
|
||||
|
||||
/* Out-of-line definitions for class-scope static const members that are
|
||||
* ODR-used (e.g. passed through varargs in REPORT_ERROR). */
|
||||
const uint32 UDPSServer::UDPS_SERVER_MAX_UDP_PAYLOAD;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor / Destructor
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,6 +92,16 @@ bool UDPSServer::Initialise(StructuredDataI &data) {
|
||||
|
||||
uint32 mps = UDPS_SERVER_DEFAULT_MAX_PAYLOAD;
|
||||
(void)data.Read("MaxPayloadSize", mps);
|
||||
/* A payload larger than the largest legal UDP datagram cannot be sent —
|
||||
* sendto would fail with EMSGSIZE and the whole cycle would be dropped. */
|
||||
if (mps > UDPS_SERVER_MAX_UDP_PAYLOAD) {
|
||||
REPORT_ERROR_STATIC(ErrorManagement::Warning,
|
||||
"UDPSServer: MaxPayloadSize %u exceeds the UDP datagram "
|
||||
"limit (%u); clamping to %u.",
|
||||
mps, UDPS_SERVER_MAX_UDP_PAYLOAD,
|
||||
UDPS_SERVER_MAX_UDP_PAYLOAD);
|
||||
mps = UDPS_SERVER_MAX_UDP_PAYLOAD;
|
||||
}
|
||||
maxPayloadSize = mps;
|
||||
|
||||
uint32 timeoutSecs = UDPS_SERVER_DEFAULT_CLIENT_TIMEOUT_S;
|
||||
|
||||
@@ -58,6 +58,11 @@ public:
|
||||
/** Default maximum UDP payload size (bytes, EXCLUDING the 17-byte header). */
|
||||
static const uint32 UDPS_SERVER_DEFAULT_MAX_PAYLOAD = 1400u;
|
||||
|
||||
/** Largest legal UDP payload: IPv4 datagrams cap at 65507 bytes, of which
|
||||
* 17 are the UDPS header. A larger MaxPayloadSize makes every sendto fail
|
||||
* with EMSGSIZE, so the configured value is clamped to this. */
|
||||
static const uint32 UDPS_SERVER_MAX_UDP_PAYLOAD = 65507u - UDPS_HEADER_SIZE;
|
||||
|
||||
UDPSServer();
|
||||
~UDPSServer();
|
||||
|
||||
|
||||
+206
-67
@@ -4,28 +4,40 @@
|
||||
# A producer only: no StreamHub, no clients. Point whatever consumer you like at
|
||||
# the UDP port (StreamHub, the Go hub, or Test/E2E tooling).
|
||||
#
|
||||
# Each channel is a 1000-element float32 array published every 1 ms by a 1 kHz
|
||||
# real-time thread — 1000 samples x 1000 Hz = 1 Msps per channel. A parallel
|
||||
# uint64 time array gives every sample its own timestamp (TimeMode=FullArray),
|
||||
# so consumers reconstruct the waveform at full rate rather than one point per
|
||||
# cycle.
|
||||
# Each channel is an N-element float32 array published every cycle by a
|
||||
# real-time thread — 10000 samples x 100 Hz = 1 Msps per channel by default. A
|
||||
# parallel uint64 time array gives every sample its own timestamp
|
||||
# (TimeMode=FullArray), so consumers reconstruct the waveform at full rate
|
||||
# rather than one point per cycle. When a cycle exceeds the UDP datagram limit
|
||||
# it is split across several datagrams (fragmented) and reassembled by the
|
||||
# receiver.
|
||||
#
|
||||
# One extra channel, HV, emulates a charged-capacitor pulse discharge on a
|
||||
# high-voltage bus: an emulated EPICS "start" setpoint (SlowControlGAM) raises
|
||||
# a rising edge, on which PulseGeneratorGAM ramps the output to -40 kV in 1 ms,
|
||||
# holds it flat for the emulated EPICS "duration" setpoint, then discharges
|
||||
# back to 0 over 100 ms. Always-on gaussian noise (3-sigma ~ +-1000 V) and
|
||||
# random +-5000 V EMI spikes (on and off phase) ride on top.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_udp_producer.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# -n <CHANNELS> Number of 1 Msps channels (default 4, max 13 — see below)
|
||||
# -n <CHANNELS> Number of 1 Msps sine channels (default 4, max 12 — see below)
|
||||
# -p <PORT> UDP port to stream on (default 44501)
|
||||
# -t <MS> HV trigger period (time between pulses, default 5000)
|
||||
# -d <MS> HV plateau duration (EPICS "duration" setpoint, default 500)
|
||||
# -b <TARGET> Build target (default: $TARGET or x86-linux)
|
||||
# -s Skip the component rebuild
|
||||
# -k Keep the generated .cfg on exit and print its path
|
||||
# -h Show this help
|
||||
#
|
||||
# Why 13 channels max: one cycle is TimeArray(8000 B) + CHANNELS x 4000 B, and
|
||||
# it is sent as a single datagram to keep the receiver's fragment-reassembly
|
||||
# pool from evicting in-flight cycles (which shows up as periodic gaps in the
|
||||
# trace). A UDP datagram tops out at 65507 B, so 8000 + 4000*13 + headroom fits
|
||||
# and 14 does not.
|
||||
# Channels are capped at 12 as a practical bound on the bytes the background
|
||||
# thread copies and sends each cycle (the uint64 time array plus (CHANNELS+1)
|
||||
# float32 arrays). Cycles larger than one UDP datagram are fragmented into
|
||||
# 60000 B datagrams and reassembled by the receiver by sequence counter, so the
|
||||
# datagram limit no longer caps the channel count — only the per-cycle send
|
||||
# budget does.
|
||||
#
|
||||
# Environment:
|
||||
# MARTe2_DIR must be set (or source env.sh first)
|
||||
@@ -39,63 +51,87 @@ CHANNELS=4
|
||||
PORT=44501
|
||||
SKIP_BUILD=0
|
||||
KEEP_CFG=0
|
||||
HV_TRIG_PERIOD=5000
|
||||
HV_PLATEAU_MS=500
|
||||
|
||||
MAX_CHANNELS=13
|
||||
MAX_CHANNELS=12
|
||||
|
||||
while getopts "n:p:b:skh" opt; do
|
||||
case "$opt" in
|
||||
n) CHANNELS="$OPTARG" ;;
|
||||
p) PORT="$OPTARG" ;;
|
||||
b) BUILD_TARGET="$OPTARG" ;;
|
||||
s) SKIP_BUILD=1 ;;
|
||||
k) KEEP_CFG=1 ;;
|
||||
h) sed -n '2,33p' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
|
||||
esac
|
||||
while getopts "n:p:t:d:b:skh" opt; do
|
||||
case "$opt" in
|
||||
n) CHANNELS="$OPTARG" ;;
|
||||
p) PORT="$OPTARG" ;;
|
||||
t) HV_TRIG_PERIOD="$OPTARG" ;;
|
||||
d) HV_PLATEAU_MS="$OPTARG" ;;
|
||||
b) BUILD_TARGET="$OPTARG" ;;
|
||||
s) SKIP_BUILD=1 ;;
|
||||
k) KEEP_CFG=1 ;;
|
||||
h)
|
||||
sed -n '2,43p' "$0" | sed 's/^# \?//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: -$OPTARG" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validate ──────────────────────────────────────────────────────────────────
|
||||
if ! [[ "$CHANNELS" =~ ^[0-9]+$ ]] || (( CHANNELS < 1 || CHANNELS > MAX_CHANNELS )); then
|
||||
echo "ERROR: -n must be 1..${MAX_CHANNELS} (got '${CHANNELS}')." >&2
|
||||
exit 1
|
||||
if ! [[ "$CHANNELS" =~ ^[0-9]+$ ]] || ((CHANNELS < 1 || CHANNELS > MAX_CHANNELS)); then
|
||||
echo "ERROR: -n must be 1..${MAX_CHANNELS} (got '${CHANNELS}')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${MARTe2_DIR:-}" || -z "${MARTe2_Components_DIR:-}" ]]; then
|
||||
echo "ERROR: MARTe2_DIR / MARTe2_Components_DIR not set." >&2
|
||||
echo " source ${SCRIPT_DIR}/env.sh" >&2
|
||||
exit 1
|
||||
echo "ERROR: MARTe2_DIR / MARTe2_Components_DIR not set." >&2
|
||||
echo " source ${SCRIPT_DIR}/env.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTeApp.ex"
|
||||
if [[ ! -x "$MARTE2_BIN" ]]; then
|
||||
echo "ERROR: MARTeApp.ex not found at ${MARTE2_BIN}" >&2
|
||||
exit 1
|
||||
echo "ERROR: MARTeApp.ex not found at ${MARTE2_BIN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_BUILD" -eq 0 ]]; then
|
||||
echo "==> Building components (TARGET=${BUILD_TARGET})..."
|
||||
make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" core 2>&1 | tail -5
|
||||
echo "==> Building components (TARGET=${BUILD_TARGET})..."
|
||||
make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" core 2>&1 | tail -5
|
||||
fi
|
||||
|
||||
# ── Generate the config ───────────────────────────────────────────────────────
|
||||
# Distinct amplitude/frequency/phase per channel so traces stay tellable apart
|
||||
# (and so a shared-Y-axis view has a spread of magnitudes to cope with).
|
||||
AMPS=(1.0 2.5 0.5 5.0 1.5 3.0 0.8 4.0 2.0 0.3 6.0 1.2 3.5)
|
||||
FREQS=(1000 2000 5000 500 10000 3000 20000 1500 7000 50000 800 4000 15000)
|
||||
AMPS=(1.0 2.5 0.5 5.0 1.5 3.0 0.8 4.0 2.0 0.3 6.0 1.2 3.5)
|
||||
FREQS=(1000 2000 5000 500 10000 3000 20000 1500 7000 50000 800 4000 15000)
|
||||
PHASES=(0.0 0.7854 1.5708 2.3562 3.1416 3.9270 4.7124 5.4978 0.3927 1.1781 1.9635 2.7489 3.5343)
|
||||
|
||||
ELEMS=1000 # samples per cycle
|
||||
RATE=1000 # cycles per second -> 1 Msps
|
||||
CYCLE_BYTES=$(( 8 * ELEMS + CHANNELS * 4 * ELEMS ))
|
||||
PAYLOAD=$(( CYCLE_BYTES + 2000 )) # headroom for header + descriptors
|
||||
ELEMS=10000 # samples per cycle
|
||||
RATE=100 # cycles per second -> 1 Msps
|
||||
CYCLE_BYTES=$((8 * ELEMS + (CHANNELS + 1) * 4 * ELEMS + 8))
|
||||
# UDP datagrams cap at 65507 B (IPv4) minus the 17 B UDPS header; keep a margin
|
||||
# and use 60000 B of payload per fragment.
|
||||
MAX_UDP_PAYLOAD=60000
|
||||
if ((CYCLE_BYTES + 2000 <= MAX_UDP_PAYLOAD)); then
|
||||
PAYLOAD=$((CYCLE_BYTES + 2000)) # whole cycle in one datagram
|
||||
else
|
||||
PAYLOAD=${MAX_UDP_PAYLOAD} # fragment the cycle across datagrams
|
||||
fi
|
||||
FRAGMENTS=$(((CYCLE_BYTES + MAX_UDP_PAYLOAD - 1) / MAX_UDP_PAYLOAD))
|
||||
|
||||
sine_gams=""; iogam_in=""; iogam_out=""; stream_sigs=""; func_list="TimerGAM"
|
||||
sine_gams=""
|
||||
iogam_in=""
|
||||
iogam_out=""
|
||||
stream_sigs=""
|
||||
func_list="TimerGAM"
|
||||
|
||||
for (( i = 1; i <= CHANNELS; i++ )); do
|
||||
k=$(( i - 1 ))
|
||||
amp="${AMPS[$k]}"; frq="${FREQS[$k]}"; pha="${PHASES[$k]}"
|
||||
for ((i = 1; i <= CHANNELS; i++)); do
|
||||
k=$((i - 1))
|
||||
amp="${AMPS[$k]}"
|
||||
frq="${FREQS[$k]}"
|
||||
pha="${PHASES[$k]}"
|
||||
|
||||
sine_gams+="
|
||||
sine_gams+="
|
||||
+SineGAM${i} = {
|
||||
Class = SineArrayGAM
|
||||
Frequency = ${frq}.0
|
||||
@@ -113,21 +149,21 @@ for (( i = 1; i <= CHANNELS; i++ )); do
|
||||
}
|
||||
}
|
||||
"
|
||||
iogam_in+="
|
||||
iogam_in+="
|
||||
Ch${i} = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
NumberOfDimensions = 1
|
||||
NumberOfElements = ${ELEMS}
|
||||
}"
|
||||
iogam_out+="
|
||||
iogam_out+="
|
||||
Ch${i} = {
|
||||
DataSource = Streamer
|
||||
Type = float32
|
||||
NumberOfDimensions = 1
|
||||
NumberOfElements = ${ELEMS}
|
||||
}"
|
||||
stream_sigs+="
|
||||
stream_sigs+="
|
||||
Ch${i} = {
|
||||
Type = float32
|
||||
Unit = \"V\"
|
||||
@@ -138,13 +174,113 @@ for (( i = 1; i <= CHANNELS; i++ )); do
|
||||
TimeMode = \"FullArray\"
|
||||
TimeSignal = TimeArray
|
||||
}"
|
||||
func_list+=", SineGAM${i}"
|
||||
func_list+=", SineGAM${i}"
|
||||
done
|
||||
func_list+=", TimeArrayGAM1, StreamerGAM"
|
||||
hv_gams="
|
||||
+SlowControlGAM1 = {
|
||||
Class = SlowControlGAM
|
||||
TriggerPeriodMs = ${HV_TRIG_PERIOD}
|
||||
TriggerWidthMs = 10
|
||||
PlateauMs = ${HV_PLATEAU_MS}
|
||||
CycleFrequency = ${RATE}
|
||||
OutputSignals = {
|
||||
HVTrigger = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
}
|
||||
HVPlateauMs = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+PulseGeneratorGAM1 = {
|
||||
Class = PulseGeneratorGAM
|
||||
SamplingRate = 1000000.0
|
||||
RampUpMs = 1.0
|
||||
RampDownMs = 100.0
|
||||
HighLevel = -40000.0
|
||||
NoiseStdDev = 333.33
|
||||
EMIAmplitude = 5000.0
|
||||
EMIProbabilityPerSample = 0.00001
|
||||
EMISpikeSamples = 5
|
||||
AutoTriggerPeriodMs = 0.0
|
||||
InputSignals = {
|
||||
HVTrigger = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
}
|
||||
HVPlateauMs = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
}
|
||||
}
|
||||
OutputSignals = {
|
||||
HV = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
NumberOfDimensions = 1
|
||||
NumberOfElements = ${ELEMS}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
|
||||
iogam_in+="
|
||||
HV = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
NumberOfDimensions = 1
|
||||
NumberOfElements = ${ELEMS}
|
||||
}
|
||||
HVTrigger = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
}
|
||||
HVPlateauMs = {
|
||||
DataSource = DDB1
|
||||
Type = float32
|
||||
}"
|
||||
iogam_out+="
|
||||
HV = {
|
||||
DataSource = Streamer
|
||||
Type = float32
|
||||
NumberOfDimensions = 1
|
||||
NumberOfElements = ${ELEMS}
|
||||
}
|
||||
HVTrigger = {
|
||||
DataSource = Streamer
|
||||
Type = float32
|
||||
}
|
||||
HVPlateauMs = {
|
||||
DataSource = Streamer
|
||||
Type = float32
|
||||
}"
|
||||
stream_sigs+="
|
||||
HV = {
|
||||
Type = float32
|
||||
Unit = \"V\"
|
||||
NumberOfDimensions = 1
|
||||
NumberOfElements = ${ELEMS}
|
||||
RangeMin = -50000.0
|
||||
RangeMax = 10000.0
|
||||
TimeMode = \"FullArray\"
|
||||
TimeSignal = TimeArray
|
||||
}
|
||||
HVTrigger = {
|
||||
Type = float32
|
||||
Unit = \"1\"
|
||||
}
|
||||
HVPlateauMs = {
|
||||
Type = float32
|
||||
Unit = \"ms\"
|
||||
}"
|
||||
func_list+=", TimeArrayGAM1, SlowControlGAM1, PulseGeneratorGAM1, StreamerGAM"
|
||||
|
||||
CFG="$(mktemp /tmp/udp_producer_XXXXXX.cfg)"
|
||||
|
||||
cat > "$CFG" <<EOF
|
||||
cat >"$CFG" <<EOF
|
||||
/**
|
||||
* udp_producer — auto-generated by run_udp_producer.sh
|
||||
* ${CHANNELS} channel(s), ${ELEMS} elem x ${RATE} Hz = 1 Msps each, port ${PORT}.
|
||||
@@ -171,7 +307,7 @@ cat > "$CFG" <<EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
${sine_gams}
|
||||
${sine_gams}${hv_gams}
|
||||
// Expands the cycle's scalar timestamp into one timestamp per sample, so
|
||||
// consumers place all ${ELEMS} samples instead of collapsing them to a point.
|
||||
+TimeArrayGAM1 = {
|
||||
@@ -239,9 +375,8 @@ ${sine_gams}
|
||||
+Streamer = {
|
||||
Class = UDPStreamer
|
||||
Port = ${PORT}
|
||||
// One cycle is ${CYCLE_BYTES} B; sizing the payload above that sends each
|
||||
// cycle as a single datagram, which keeps the receiver's reassembly pool
|
||||
// from evicting in-flight cycles and gapping the trace.
|
||||
// One cycle is ${CYCLE_BYTES} B; it is sent as ${FRAGMENTS} datagram(s)
|
||||
// of up to ${PAYLOAD} B each (the receiver reassembles by counter).
|
||||
MaxPayloadSize = ${PAYLOAD}
|
||||
PublishingMode = "Strict"
|
||||
Signals = {
|
||||
@@ -292,27 +427,31 @@ ${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/GAMs/IOGAM:\
|
||||
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
|
||||
${BUILD_DIR}/Components/GAMs/SineArrayGAM:\
|
||||
${BUILD_DIR}/Components/GAMs/TimeArrayGAM:\
|
||||
${BUILD_DIR}/Components/GAMs/PulseGeneratorGAM:\
|
||||
${BUILD_DIR}/Components/GAMs/SlowControlGAM:\
|
||||
${BUILD_DIR}/Components/Interfaces/UDPStream:\
|
||||
${LD_LIBRARY_PATH:-}"
|
||||
|
||||
cleanup() {
|
||||
if [[ "$KEEP_CFG" -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "==> Config kept at ${CFG}"
|
||||
else
|
||||
rm -f "$CFG"
|
||||
fi
|
||||
if [[ "$KEEP_CFG" -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "==> Config kept at ${CFG}"
|
||||
else
|
||||
rm -f "$CFG"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo ""
|
||||
echo "==> Streaming on udp/${PORT}"
|
||||
echo " Channels : ${CHANNELS} x 1 Msps (${ELEMS} elem @ ${RATE} Hz)"
|
||||
for (( i = 1; i <= CHANNELS; i++ )); do
|
||||
k=$(( i - 1 ))
|
||||
printf ' Ch%-2d %8s Hz %s V\n' "$i" "${FREQS[$k]}" "${AMPS[$k]}"
|
||||
for ((i = 1; i <= CHANNELS; i++)); do
|
||||
k=$((i - 1))
|
||||
printf ' Ch%-2d %8s Hz %s V\n' "$i" "${FREQS[$k]}" "${AMPS[$k]}"
|
||||
done
|
||||
echo " Cycle : ${CYCLE_BYTES} B (MaxPayloadSize ${PAYLOAD})"
|
||||
echo " HV pulse: -40000 V, ramp up 1 ms, flat ${HV_PLATEAU_MS} ms, discharge 100 ms"
|
||||
echo " noise +-1000 V (3 sigma), EMI +-5000 V, trigger every ${HV_TRIG_PERIOD} ms"
|
||||
echo " Cycle : ${CYCLE_BYTES} B (${FRAGMENTS} x ${PAYLOAD} B datagram)"
|
||||
echo " Config : ${CFG}"
|
||||
echo ""
|
||||
echo " Consume with e.g.:"
|
||||
@@ -322,7 +461,7 @@ echo " Press Ctrl-C to stop."
|
||||
echo ""
|
||||
|
||||
exec "${MARTE2_BIN}" \
|
||||
-l RealTimeLoader \
|
||||
-f "${CFG}" \
|
||||
-s Running \
|
||||
-m StateMachine:START
|
||||
-l RealTimeLoader \
|
||||
-f "${CFG}" \
|
||||
-s Running \
|
||||
-m StateMachine:START
|
||||
|
||||
Reference in New Issue
Block a user