Implemented and fixed many issues

This commit is contained in:
Martino Ferrari
2026-08-21 23:24:48 +02:00
parent 14d5351a81
commit e03c60db25
52 changed files with 7726 additions and 769 deletions
+6 -3
View File
@@ -58,7 +58,7 @@ UDP 8081 telemetry, TcpLogger 8082 (REPORT_ERROR → "LOG <LEVEL> <desc>" lines)
|---|---| |---|---|
| `Source/Components/DataSources/UDPStreamer/` | Output DataSource; UDP I/O on bg thread, RT thread only spinlock+memcpy in `Synchronise()` | | `Source/Components/DataSources/UDPStreamer/` | Output DataSource; UDP I/O on bg thread, RT thread only spinlock+memcpy in `Synchronise()` |
| `Source/Components/DataSources/UDPStreamerClient/` | Input DataSource (shared `UDPSClient`), double-buffered ready/scratch | | `Source/Components/DataSources/UDPStreamerClient/` | Input DataSource (shared `UDPSClient`), double-buffered ready/scratch |
| `Source/Components/GAMs/` | `SineArrayGAM` (float32 sine, continuous phase), `TimeArrayGAM` (us-timer → per-sample timestamp array) | | `Source/Components/GAMs/` | `SineArrayGAM` (float32 sine, continuous phase), `TimeArrayGAM` (us-timer → per-sample timestamp array; `Anchor = FirstSample|LastSample|Continuous`, use `Continuous` for contiguous sources so a lost RT cycle cannot hole the time base) |
| `Source/Components/Interfaces/DebugService/` | Registry patching, `DebugBrokerWrapper.h`, TCP/UDP services | | `Source/Components/Interfaces/DebugService/` | Registry patching, `DebugBrokerWrapper.h`, TCP/UDP services |
| `Source/Components/Interfaces/TCPLogger/` | `LoggerConsumerI` forwarding `REPORT_ERROR` to ≤8 TCP clients | | `Source/Components/Interfaces/TCPLogger/` | `LoggerConsumerI` forwarding `REPORT_ERROR` to ≤8 TCP clients |
| `Source/Components/Interfaces/UDPStream/` | Plain-C++ helpers (not MARTe2 Objects): `UDPSClient` (auto-reconnect + fragment reassembly), `UDPSServer` (not thread-safe — owner's Execute thread only) | | `Source/Components/Interfaces/UDPStream/` | Plain-C++ helpers (not MARTe2 Objects): `UDPSClient` (auto-reconnect + fragment reassembly), `UDPSServer` (not thread-safe — owner's Execute thread only) |
@@ -140,8 +140,11 @@ cd Client/streamhub-qt && cmake -B build && cmake --build build
with long options: `--host HOST --port 8090` (single-dash misparsed). Single with long options: `--host HOST --port 8090` (single-dash misparsed). Single
GUI thread, 60 Hz QTimer repaint. GUI thread, 60 Hz QTimer repaint.
- **StreamHub config** is *not* a MARTe2 `RealTimeApplication`: `Hub = { WSPort - **StreamHub config** is *not* a MARTe2 `RealTimeApplication`: `Hub = { WSPort
MaxPoints PushRate MaxPushPoints RingTemporal RingScalar +Recorder{...} MaxPoints PushRate MaxPushPoints RingTemporal RingScalar RingMaxMB AllowedOrigins
Sources={id={Label Addr Port}} }`. `+History` keys: `Directory` (required), +Recorder{...} Sources={id={Label Addr Port}} }`. `AllowedOrigins` is the
WebSocket Origin allowlist — without it a browser serving the SPA from a
different port than the hub is rejected 403.
`+History` keys: `Directory` (required),
`DurationHours` (1), `Decimation` (1), `FlushIntervalSec` (5), `DurationHours` (1), `Decimation` (1), `FlushIntervalSec` (5),
`MinDiskFreeMB` (500). `.shist` files: 64-byte header ('SHR1') + circular `MinDiskFreeMB` (500). `.shist` files: 64-byte header ('SHR1') + circular
(t,v) float64 pairs. (t,v) float64 pairs.
+39 -3
View File
@@ -314,7 +314,7 @@ Hub-side trigger with the web client's semantics (config: signal key
``` ```
IDLE →[arm]→ ARMED IDLE →[arm]→ ARMED
ARMED →[edge crossing]→ COLLECTING (latches trigTime, pre/postSec) ARMED →[edge crossing]→ COLLECTING (latches trigTime, pre/postSec)
COLLECTING →[post window + margin elapsed]→ TRIGGERED (broadcast binary v2 capture) COLLECTING →[every source produced past the window]→ TRIGGERED (broadcast binary v2 capture)
TRIGGERED →[auto-rearm (normal, ~200 ms) | rearm (single)]→ ARMED TRIGGERED →[auto-rearm (normal, ~200 ms) | rearm (single)]→ ARMED
any →[disarm]→ IDLE any →[disarm]→ IDLE
``` ```
@@ -325,6 +325,30 @@ sample of the configured signal. The capture is assembled in the push loop from
LTTB-capped at 20 000 points/signal, and broadcast as a binary version-2 frame. LTTB-capped at 20 000 points/signal, and broadcast as a binary version-2 frame.
A `stopped` flag (`trigStop`) freezes auto-rearm. A `stopped` flag (`trigStop`) freezes auto-rearm.
COLLECTING is left on the **data's** clock, not `clock_gettime()`: `trigTime`
comes from sample timestamps, and a source that free-runs on its own clock sits
seconds away from wall time, so a wall-clock deadline chops exactly that offset
off every capture's tail. `UDPSourceSession::ProducerNewestTime()` reports how
far a source has produced — counting only signals actually timestamped from a
time signal, since PACKET-timed ones (the time array itself included) are
stamped on arrival and would just report "now".
Sources are harvested **one at a time**, each as soon as *it* passes
`trigTime + postSec + 0.15 s` (`BeginTriggerCapture` / `HarvestTriggerCapture` /
`FinishTriggerCapture`, the frame accumulating across push ticks). Making every
source wait for the slowest lets the leaders' rings roll past the pre-trigger
region before it is ever read. A 2 s wall-clock watchdog per capture bounds the
wait for a source that stopped advancing; it is harvested short, with a warning
naming the source and how far it got.
Because `RingTemporal` only holds ~1 s at 1 MSps, `setTrigger` publishes the
requested window and the push loop calls `GrowRingsForTrigger()`: each ring
measures its own rate (`Count() / TimeSpan()` — UDPS sources usually report
`samplingRate = 0`) and is grown in place to `rate × (window + 0.5 s) × 1.2`,
clamped per signal to `RingMaxMB`. `SignalRingBuffer::Grow()` preserves
contents *and* `totalWritten`, so live push cursors stay valid. Without this a
long window only ever captures its tail.
### Configuration File (MARTe2 cfg format) ### Configuration File (MARTe2 cfg format)
``` ```
@@ -334,9 +358,11 @@ Hub = {
PushRate = 30 // push loop Hz PushRate = 30 // push loop Hz
MaxPushPoints = 50 // LTTB cap per signal per tick MaxPushPoints = 50 // LTTB cap per signal per tick
StatsRate = 1 // stats broadcast Hz StatsRate = 1 // stats broadcast Hz
RingTemporal = 1000000 // ring capacity (points) for multi-element signals RingTemporal = 1000000 // initial ring capacity (points) for multi-element signals
RingScalar = 100000 // ring capacity (points) for scalar signals RingScalar = 100000 // ring capacity (points) for scalar signals
RingMaxMB = 128 // per-signal ceiling when a trigger window grows a ring
SourcesFile = "streamhub_sources.json" // dynamic-source persistence SourcesFile = "streamhub_sources.json" // dynamic-source persistence
AllowedOrigins = "http://127.0.0.1:8099,http://localhost:8099" // see below
Sources = { Sources = {
App1 = { App1 = {
Label = "MARTe2 App 1" Label = "MARTe2 App 1"
@@ -358,6 +384,16 @@ Sources added at runtime (`addSource`) get generated ids `s1, s2, …`;
`saveSources` persists them to `SourcesFile` (JSON array of `saveSources` persists them to `SourcesFile` (JSON array of
`{label, addr, multicastGroup?, dataPort?}`), reloaded at start-up. `{label, addr, multicastGroup?, dataPort?}`), reloaded at start-up.
`AllowedOrigins` is a comma/space-separated allowlist of `scheme://host[:port]`
values accepted in the WebSocket `Origin` header (max 8 entries, 128 chars
each), matching the Go hub's option. Without it the handshake only accepts an
`Origin` whose host matches the request `Host` — so a browser that loaded the
SPA from a *different* port than the hub (the `run_streamhub.sh` layout, SPA on
8099 and hub on 8090) is rejected with 403. Non-browser clients send no `Origin`
and are unaffected. This is the CSWSH guard of RFC 6455 §10.2: browsers attach
cookies to cross-origin WebSocket handshakes, so `Origin` is the only thing
distinguishing a legitimate page from an attacker's.
### Build ### Build
```bash ```bash
@@ -401,7 +437,7 @@ binary frames carry data push payloads.
| `sources` | `sources:[{id, label, addr:"host:port", state}]` | On connect; after add/remove/getSources; on first CONFIG | | `sources` | `sources:[{id, label, addr:"host:port", state}]` | On connect; after add/remove/getSources; on first CONFIG |
| `config` | `sourceId`, `publishMode`, `signals:[{name, typeCode, quantType, numDimensions, numRows, numCols, rangeMin, rangeMax, timeMode, samplingRate, timeSignalIdx, unit}]` | After CONFIG received from source | | `config` | `sourceId`, `publishMode`, `signals:[{name, typeCode, quantType, numDimensions, numRows, numCols, rangeMin, rangeMax, timeMode, samplingRate, timeSignalIdx, unit}]` | After CONFIG received from source |
| `stats` | `sources:{id:{state, totalReceived, totalLost, rateHz, rateStdHz, fragsPerCycle, bytesPerCycle, cycleAvgMs, cycleStdMs, cycleMinMs, cycleMaxMs, cycleHistMin, cycleHistMax, cycleHist:[20]}}` | At `StatsRate` Hz | | `stats` | `sources:{id:{state, totalReceived, totalLost, rateHz, rateStdHz, fragsPerCycle, bytesPerCycle, cycleAvgMs, cycleStdMs, cycleMinMs, cycleMaxMs, cycleHistMin, cycleHistMax, cycleHist:[20]}}` | At `StatsRate` Hz |
| `triggerState` | `state` (`"idle"`\|`"armed"`\|`"collecting"`\|`"triggered"`), `mode`, `stopped`, `trigTime?` | On any trigger FSM transition | | `triggerState` | `state` (`"idle"`\|`"armed"`\|`"collecting"`\|`"triggered"`), `mode`, `stopped`, `trigTime?`, `preSec?`, `postSec?` | On any trigger FSM transition |
| `zoom` | `reqId`, `signals:{"src:sig":{t:[…], v:[…]}}` (`t` printed `%.17g`, `v` `%.9g`) | Unicast reply to `zoom` | | `zoom` | `reqId`, `signals:{"src:sig":{t:[…], v:[…]}}` (`t` printed `%.17g`, `v` `%.9g`) | Unicast reply to `zoom` |
| `maxPointsUpdated` | `maxPoints` | After ring buffer resize | | `maxPointsUpdated` | `maxPoints` | After ring buffer resize |
| `calibration` | `cal:[{source, signal, scale, offset, unit}]` | On connect; after an accepted `setCalibration`; after a successful `reloadConfig` | | `calibration` | `cal:[{source, signal, scale, offset, unit}]` | On connect; after an accepted `setCalibration`; after a successful `reloadConfig` |
+9 -1
View File
@@ -160,7 +160,15 @@ void Hub::onTriggerState(const std::string& json) {
trigger_.trigTime = msg.trigTime; trigger_.trigTime = msg.trigTime;
trigger_.hasTrigTime = true; trigger_.hasTrigTime = true;
} }
if (msg.state == "idle") { trigger_.hasTrigTime = false; } if (msg.hasWindow) {
trigger_.firedPreS = msg.preSec;
trigger_.firedPostS = msg.postSec;
trigger_.hasFiredWin = true;
}
if (msg.state == "idle") {
trigger_.hasTrigTime = false;
trigger_.hasFiredWin = false;
}
Q_EMIT triggerStateChanged(); Q_EMIT triggerStateChanged();
} }
+5
View File
@@ -60,6 +60,11 @@ struct TriggerCfgState {
bool stopped = false; bool stopped = false;
bool hasTrigTime = false; bool hasTrigTime = false;
double trigTime = 0.0; double trigTime = 0.0;
/* Window the hub latched at fire time. Not the same as windowSec/prePercent
* above, which are editable and may have moved on since the trigger fired. */
bool hasFiredWin = false;
double firedPreS = 0.0;
double firedPostS = 0.0;
}; };
/** Per-signal vertical scale state (oscilloscope style). */ /** Per-signal vertical scale state (oscilloscope style). */
+230 -74
View File
@@ -78,6 +78,49 @@ static double normalizeY(double raw, const VScale& vs) {
return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos; return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos;
} }
/* Resolve the one scale every trace shares in unified mode: same rules as the
* per-signal version applied to the union of the plot — range takes the union
* of the declared ranges, auto fits the union of the data. */
static void resolveUnifiedVScale(VScale& vs,
const std::vector<PlotAssignment>& slots,
const std::vector<Source>& sources,
const std::vector<std::vector<double> >& vStore) {
if (vs.mode == 2) {
vs.resolvedDiv = std::max(vs.divValue, 1e-30);
vs.resolvedOffset = vs.offset;
return;
}
double mn = 1e300, mx = -1e300;
if (vs.mode == 1) {
for (const auto& a : slots) {
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
if (a.signalIdx < 0 ||
a.signalIdx >= (int)sources[a.sourceIdx].signals.size()) continue;
const auto& m = sources[a.sourceIdx].signals[a.signalIdx].meta;
if (!(m.rangeMax > m.rangeMin)) continue;
if (m.rangeMin < mn) mn = m.rangeMin;
if (m.rangeMax > mx) mx = m.rangeMax;
}
if (mx > mn) {
vs.resolvedDiv = std::max((mx - mn) / 8.0, 1e-30);
vs.resolvedOffset = (mn + mx) / 2.0;
return;
}
mn = 1e300; mx = -1e300; /* no usable range: fall through to auto */
}
for (const auto& vv : vStore) {
for (double v : vv) {
if (!std::isfinite(v)) continue;
if (v < mn) mn = v;
if (v > mx) mx = v;
}
}
if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30);
vs.resolvedOffset = (mx + mn) / 2.0;
}
static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) { static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) {
mn = 1e300; mx = -1e300; mn = 1e300; mx = -1e300;
for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } } for (double x : v) { if (std::isfinite(x)) { if (x < mn) mn = x; if (x > mx) mx = x; } }
@@ -189,6 +232,50 @@ void PlotCanvas::drawMarker(QPainter& p, double cx, double cy, int marker, doubl
} }
} }
/** @brief What the plot renders on the trigger-relative axis, if anything. */
struct TrigView {
bool rel = false; /* render against t - trig instead of wall clock */
bool fromCap = false; /* data comes from the capture frame, not the ring */
double trigT = 0.0;
double preS = 0.0;
double postS = 0.0;
};
/* Two ways to end up in trigger-relative time. Either a v2 capture frame has
* arrived, or a trigger has fired and its window is still filling. In the
* second case the hub sends nothing until the whole window has been produced —
* several seconds for a long window at a high rate — so the trace is drawn from
* the local rings onto the final axis, growing left to right. Filling wins
* over the last capture: once a new trigger fires the old waveform is history.
* A capture latches its own pre/post at fire time, so later edits in the
* trigger bar must not move the axis of a finished capture. */
static TrigView resolveTrigView(Hub* hub, const GlobalView* gv, bool paused) {
TrigView tv;
if (!gv->trigView) { return tv; }
const TriggerCfgState& t = hub->trigger();
if (!paused && t.status == "collecting" && t.hasTrigTime) {
tv.rel = true;
tv.trigT = t.trigTime;
/* Prefer the window the hub latched at fire time; the local config is
* only a fallback for hubs that do not report it, and may have been
* edited since the trigger fired. */
tv.preS = t.hasFiredWin ? t.firedPreS
: t.windowSec * t.prePercent * 0.01;
tv.postS = t.hasFiredWin ? t.firedPostS : t.windowSec - tv.preS;
return tv;
}
const CaptureFrame* cap = hub->capture();
if (cap != nullptr) {
tv.rel = true;
tv.fromCap = true;
tv.trigT = cap->trigTime;
tv.preS = cap->preSec;
tv.postS = cap->postSec;
}
return tv;
}
void PlotCanvas::paintEvent(QPaintEvent*) { void PlotCanvas::paintEvent(QPaintEvent*) {
QPainter p(this); QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::Antialiasing, true);
@@ -205,13 +292,14 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
p.fillRect(rect(), col::base()); p.fillRect(rect(), col::base());
p.fillRect(r, col::crust()); p.fillRect(r, col::crust());
const CaptureFrame* cap = hub->capture();
const bool trigView = (cap != nullptr) && gv->trigView;
auto& zc = hub->zoomCache(w_->plotIdx_); auto& zc = hub->zoomCache(w_->plotIdx_);
auto& hc = hub->histZoomCache(w_->plotIdx_); auto& hc = hub->histZoomCache(w_->plotIdx_);
const bool paused = w_->paused_; const bool paused = w_->paused_;
bool& live = w_->live_; bool& live = w_->live_;
const TrigView tv = resolveTrigView(hub, gv, paused);
const CaptureFrame* cap = hub->capture();
/* ── pause snapshot ─────────────────────────────────────────────────── */ /* ── pause snapshot ─────────────────────────────────────────────────── */
auto& snap = w_->snap_; auto& snap = w_->snap_;
if (paused) { if (paused) {
@@ -239,15 +327,15 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
/* ── gather data per slot ───────────────────────────────────────────── */ /* ── gather data per slot ───────────────────────────────────────────── */
std::vector<std::vector<double>> tStore(slots.size()), vStore(slots.size()); std::vector<std::vector<double>> tStore(slots.size()), vStore(slots.size());
const bool liveHiRes = !trigView && live && !paused && const bool liveHiRes = !tv.rel && live && !paused &&
gv->windowSec <= kLiveHiResMaxWin && zc.valid && gv->windowSec <= kLiveHiResMaxWin && zc.valid &&
(zc.t1 - zc.t0) >= gv->windowSec * 0.9 && (wallNow - zc.t1) < 3.0; (zc.t1 - zc.t0) >= gv->windowSec * 0.9 && (wallNow - zc.t1) < 3.0;
const bool useZoomData = !trigView && !paused && zc.valid && const bool useZoomData = !tv.rel && !paused && zc.valid &&
(liveHiRes || (liveHiRes ||
(!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9)); (!live && zc.t0 <= w_->plotXMin_ + 1e-9 && zc.t1 >= w_->plotXMax_ - 1e-9));
bool useHistData = !trigView && !paused && !live && hc.valid && bool useHistData = !tv.rel && !paused && !live && hc.valid &&
hc.t0 <= w_->plotXMin_ + 1e-9 && hc.t1 >= w_->plotXMax_ - 1e-9; hc.t0 <= w_->plotXMin_ + 1e-9 && hc.t1 >= w_->plotXMax_ - 1e-9;
if (useHistData) { if (useHistData) {
bool any = false; bool any = false;
@@ -271,17 +359,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
const auto& sig = sources[a.sourceIdx].signals[a.signalIdx]; const auto& sig = sources[a.sourceIdx].signals[a.signalIdx];
const std::string key = hub->slotKey(a); const std::string key = hub->slotKey(a);
if (trigView) { if (tv.fromCap) {
for (const auto& cs : cap->signals) { for (const auto& cs : cap->signals) {
if (cs.key != key) continue; if (cs.key != key) continue;
size_t n = std::min(cs.t.size(), cs.v.size()); size_t n = std::min(cs.t.size(), cs.v.size());
tStore[si].reserve(n); vStore[si].reserve(n); tStore[si].reserve(n); vStore[si].reserve(n);
for (size_t i = 0; i < n; i++) { for (size_t i = 0; i < n; i++) {
tStore[si].push_back(cs.t[i] - cap->trigTime); tStore[si].push_back(cs.t[i] - tv.trigT);
vStore[si].push_back(cs.v[i]); vStore[si].push_back(cs.v[i]);
} }
break; break;
} }
} else if (tv.rel) {
/* Filling: local ring, clipped to the (absolute) trigger window and
* shifted onto the trigger-relative axis. */
sig.buf.readRange(tv.trigT - tv.preS, tv.trigT + tv.postS,
tStore[si], vStore[si]);
for (size_t i = 0; i < tStore[si].size(); i++) {
tStore[si][i] -= tv.trigT;
}
} else if (useZoomData) { } else if (useZoomData) {
bool found = false; bool found = false;
for (const auto& zs : zc.pts) { for (const auto& zs : zc.pts) {
@@ -302,11 +398,18 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
resolveVScale(a, sig, vStore[si]); resolveVScale(a, sig, vStore[si]);
} }
if (w_->vMode_ == 3) {
resolveUnifiedVScale(w_->uniVS_, slots, sources, vStore);
}
/* ── X range ────────────────────────────────────────────────────────── */ /* ── X range ────────────────────────────────────────────────────────── */
double xMin, xMax; double xMin, xMax;
if (trigView) { if (tv.rel) {
if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; } if (w_->trigZoomed_) { xMin = w_->plotXMin_; xMax = w_->plotXMax_; }
else { xMin = -cap->preSec; xMax = cap->postSec; } /* Full window from the start, even while filling: a trace growing into
* a fixed axis reads as progress, whereas an axis that grows with the
* data shifts the whole trace every frame. */
else { xMin = -tv.preS; xMax = tv.postS; }
} else if (live && !paused) { } else if (live && !paused) {
if (liveHiRes) { xMax = zc.t1; xMin = zc.t1 - gv->windowSec; } if (liveHiRes) { xMax = zc.t1; xMin = zc.t1 - gv->windowSec; }
else { xMax = wallNow; xMin = wallNow - gv->windowSec; } else { xMax = wallNow; xMin = wallNow - gv->windowSec; }
@@ -319,19 +422,25 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
/* ── grid + ticks ───────────────────────────────────────────────────── */ /* ── grid + ticks ───────────────────────────────────────────────────── */
p.setPen(QPen(QColor(0x31,0x32,0x44,160), 1.0)); p.setPen(QPen(QColor(0x31,0x32,0x44,160), 1.0));
/* Y grid: 9 division lines */ /* Y grid: 9 division lines */
const auto& av = (w_->vMode_ == 0 && w_->activeSlot_ >= 0 && /* Which scale labels the axis: the active signal's in normal mode, the one
w_->activeSlot_ < (int)slots.size()) * the whole plot shares in unified mode (where nothing has to be selected).
? slots[w_->activeSlot_].vs : VScale(); * Banded modes have no single scale, so they keep the plain division numbers. */
const VScale* axisVS = nullptr;
if (w_->vMode_ == 0 && w_->activeSlot_ >= 0 &&
w_->activeSlot_ < (int)slots.size()) {
axisVS = &slots[w_->activeSlot_].vs;
} else if (w_->vMode_ == 3) {
axisVS = &w_->uniVS_;
}
p.setFont(QFont(font().family(), 8)); p.setFont(QFont(font().family(), 8));
for (int d = -4; d <= 4; d++) { for (int d = -4; d <= 4; d++) {
double y = yToPx(d, r); double y = yToPx(d, r);
p.setPen(QPen(QColor(0x31,0x32,0x44, d==0?220:120), d==0?1.2:1.0)); p.setPen(QPen(QColor(0x31,0x32,0x44, d==0?220:120), d==0?1.2:1.0));
p.drawLine(QPointF(r.left(), y), QPointF(r.right(), y)); p.drawLine(QPointF(r.left(), y), QPointF(r.right(), y));
QString lbl; QString lbl;
if (w_->vMode_ == 0 && w_->activeSlot_ >= 0 && if (axisVS != nullptr) {
w_->activeSlot_ < (int)slots.size()) { lbl = fmtVal(axisVS->resolvedOffset +
double rawVal = av.resolvedOffset + (d - av.screenPos) * av.resolvedDiv; (d - axisVS->screenPos) * axisVS->resolvedDiv);
lbl = fmtVal(rawVal);
} else { } else {
lbl = QString::number(d); lbl = QString::number(d);
} }
@@ -346,7 +455,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
p.setPen(QPen(QColor(0x31,0x32,0x44,120), 1.0)); p.setPen(QPen(QColor(0x31,0x32,0x44,120), 1.0));
p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom())); p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()));
p.setPen(QColor(0xa6,0xad,0xc8)); p.setPen(QColor(0xa6,0xad,0xc8));
QString xl = trigView ? fmtVal(xv) + "s" : QString::number(xv, 'f', 3); QString xl = tv.rel ? fmtVal(xv) + "s" : QString::number(xv, 'f', 3);
int flags = (t==0?Qt::AlignLeft:(t==10?Qt::AlignRight:Qt::AlignHCenter)) int flags = (t==0?Qt::AlignLeft:(t==10?Qt::AlignRight:Qt::AlignHCenter))
| Qt::AlignTop; | Qt::AlignTop;
p.drawText(QRectF(x-40, r.bottom()+2, 80, 14), flags, xl); p.drawText(QRectF(x-40, r.bottom()+2, 80, 14), flags, xl);
@@ -396,8 +505,10 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
if (w_->vMode_ == 1) bandNormalize(vDec, vNorm, myKi, nTraces, true); if (w_->vMode_ == 1) bandNormalize(vDec, vNorm, myKi, nTraces, true);
else if (w_->vMode_ == 2) bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed); else if (w_->vMode_ == 2) bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed);
else { else {
/* unified shares one scale, normal gives each trace its own */
const VScale& nvs = (w_->vMode_ == 3) ? w_->uniVS_ : a.vs;
vNorm.resize(nOut); vNorm.resize(nOut);
for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], a.vs); for (size_t k = 0; k < nOut; k++) vNorm[k] = normalizeY(vDec[k], nvs);
} }
QColor c = sig.color; QColor c = sig.color;
@@ -423,7 +534,7 @@ void PlotCanvas::paintEvent(QPaintEvent*) {
} }
/* trigger instant marker at t=0 */ /* trigger instant marker at t=0 */
if (trigView) { if (tv.rel) {
double x = xToPx(0.0, xMin, xMax, r); double x = xToPx(0.0, xMin, xMax, r);
p.setPen(QPen(QColor(255,255,0,200), 1.5, Qt::DashLine)); p.setPen(QPen(QColor(255,255,0,200), 1.5, Qt::DashLine));
p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom())); p.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()));
@@ -476,8 +587,7 @@ void PlotCanvas::wheelEvent(QWheelEvent* e) {
Hub* hub = w_->hub_; Hub* hub = w_->hub_;
GlobalView* gv = w_->gv_; GlobalView* gv = w_->gv_;
auto& slots = w_->slots_; auto& slots = w_->slots_;
const CaptureFrame* cap = hub->capture(); const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
const bool trigView = (cap != nullptr) && gv->trigView;
bool& live = w_->live_; bool& live = w_->live_;
double dy = e->angleDelta().y(); double dy = e->angleDelta().y();
@@ -488,43 +598,51 @@ void PlotCanvas::wheelEvent(QWheelEvent* e) {
const double now = nowSec(); const double now = nowSec();
auto enterTrigZoom = [&]() { auto enterTrigZoom = [&]() {
if (trigView && !w_->trigZoomed_) { if (tv.rel && !w_->trigZoomed_) {
w_->setStoredX(-cap->preSec, cap->postSec); w_->setStoredX(-tv.preS, tv.postS);
w_->trigZoomed_ = true; w_->trigZoomed_ = true;
} }
}; };
auto xZoomStored = [&](double f) { auto xZoomStored = [&](double f) {
if (trigView) enterTrigZoom(); if (tv.rel) enterTrigZoom();
if (now - w_->lastHistPushMs_ > 0.6) { w_->pushZoomHist(); w_->lastHistPushMs_ = now; } if (now - w_->lastHistPushMs_ > 0.6) { w_->pushZoomHist(); w_->lastHistPushMs_ = now; }
double cx = (w_->plotXMin_ + w_->plotXMax_) * 0.5; double cx = (w_->plotXMin_ + w_->plotXMax_) * 0.5;
double half = (w_->plotXMax_ - w_->plotXMin_) * 0.5 * f; double half = (w_->plotXMax_ - w_->plotXMin_) * 0.5 * f;
w_->setStoredX(cx - half, cx + half); w_->setStoredX(cx - half, cx + half);
}; };
auto makeManual = [&](PlotAssignment& a) { /* Seed manual from the resolved values so the gesture sticks. */
if (a.vs.mode != 2) { auto makeManual = [&](VScale& vs) {
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30); if (vs.mode != 2) {
a.vs.offset = a.vs.resolvedOffset; vs.divValue = std::max(vs.resolvedDiv, 1e-30);
a.vs.mode = 2; vs.offset = vs.resolvedOffset;
vs.mode = 2;
} }
}; };
/* Scroll adjusts the scale the axis is labelled with: the active signal's in
* normal mode, the plot's shared one in unified mode (nothing to select). */
VScale* wheelVS = nullptr;
if (w_->vMode_ == 3) {
wheelVS = &w_->uniVS_;
} else if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) {
wheelVS = &slots[w_->activeSlot_].vs;
}
if (ctrl) { if (ctrl) {
if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
else xZoomStored(factor); else xZoomStored(factor);
} else if (shift) { } else if (shift) {
if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) { if (wheelVS != nullptr) {
auto& a = slots[w_->activeSlot_]; makeManual(*wheelVS);
makeManual(a); wheelVS->screenPos += (dy > 0) ? 0.5 : -0.5;
a.vs.screenPos += (dy > 0) ? 0.5 : -0.5;
} }
} else { } else {
if (w_->activeSlot_ >= 0 && w_->activeSlot_ < (int)slots.size()) { if (wheelVS != nullptr) {
auto& a = slots[w_->activeSlot_]; makeManual(*wheelVS);
makeManual(a); wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30);
a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30);
} else { } else {
if (!trigView && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0); if (!tv.rel && live) gv->windowSec = std::clamp(gv->windowSec*factor, 1e-4, 3600.0);
else xZoomStored(factor); else xZoomStored(factor);
} }
} }
@@ -549,8 +667,7 @@ void PlotCanvas::mouseMoveEvent(QMouseEvent* e) {
GlobalView* gv = w_->gv_; GlobalView* gv = w_->gv_;
Hub* hub = w_->hub_; Hub* hub = w_->hub_;
const QRectF r = plotRect(); const QRectF r = plotRect();
const CaptureFrame* cap = hub->capture(); const TrigView tv = resolveTrigView(hub, gv, w_->paused_);
const bool trigView = (cap != nullptr) && gv->trigView;
bool& live = w_->live_; bool& live = w_->live_;
if (dragCursor_ != 0) { if (dragCursor_ != 0) {
@@ -560,11 +677,11 @@ void PlotCanvas::mouseMoveEvent(QMouseEvent* e) {
return; return;
} }
if (panning_) { if (panning_) {
if (trigView && !w_->trigZoomed_) { if (tv.rel && !w_->trigZoomed_) {
w_->setStoredX(-cap->preSec, cap->postSec); w_->setStoredX(-tv.preS, tv.postS);
w_->trigZoomed_ = true; w_->trigZoomed_ = true;
} }
if (!trigView && live) { w_->initPlotX(nowSec()); live = false; } if (!tv.rel && live) { w_->initPlotX(nowSec()); live = false; }
double dxPix = e->pos().x() - lastPos_.x(); double dxPix = e->pos().x() - lastPos_.x();
lastPos_ = e->pos(); lastPos_ = e->pos();
double xRange = w_->plotXMax_ - w_->plotXMin_; double xRange = w_->plotXMax_ - w_->plotXMin_;
@@ -677,11 +794,10 @@ void PlotWidget::onCaptureReceived() {
void PlotWidget::tick() { void PlotWidget::tick() {
Hub* hub = hub_; Hub* hub = hub_;
GlobalView* gv = gv_; GlobalView* gv = gv_;
const CaptureFrame* cap = hub->capture(); const TrigView tv = resolveTrigView(hub, gv, paused_);
const bool trigView = (cap != nullptr) && gv->trigView;
const double now = nowSec(); const double now = nowSec();
if (!trigView && !paused_) { if (!tv.rel && !paused_) {
std::string csv; std::string csv;
for (const auto& a : slots_) { for (const auto& a : slots_) {
std::string k = hub->slotKey(a); std::string k = hub->slotKey(a);
@@ -736,7 +852,11 @@ void PlotWidget::rebuildHeader() {
auto* b = new QToolButton(header_); auto* b = new QToolButton(header_);
b->setCheckable(true); b->setCheckable(true);
b->setChecked(activeSlot_ == i); b->setChecked(activeSlot_ == i);
b->setText(QString("%1 %2/div") /* In unified mode every badge would repeat the same div value, which
* the header's Y-Scale button already shows — so show just the name. */
b->setText(vMode_ == 3
? QString::fromStdString(sig.meta.name)
: QString("%1 %2/div")
.arg(QString::fromStdString(sig.meta.name)) .arg(QString::fromStdString(sig.meta.name))
.arg(fmtVal(a.vs.resolvedDiv))); .arg(fmtVal(a.vs.resolvedDiv)));
QColor c = sig.color; QColor c = sig.color;
@@ -797,11 +917,17 @@ void PlotWidget::rebuildHeader() {
headerLay_->addWidget(fit); headerLay_->addWidget(fit);
} }
/* N / D / M */ /* N / U / D / M */
const char* vl[3] = {"N", "D", "M"}; const char* vl[4] = {"N", "U", "D", "M"};
for (int vm = 0; vm < 3; vm++) { const char* vtip[4] = {"Normal: one vertical scale per signal",
"Unified: one vertical scale shared by every signal",
"Digital", "Mixed"};
const int vmode[4] = {0, 3, 1, 2};
for (int i = 0; i < 4; i++) {
const int vm = vmode[i];
auto* vb = new QToolButton(header_); auto* vb = new QToolButton(header_);
vb->setText(vl[vm]); vb->setText(vl[i]);
vb->setToolTip(vtip[i]);
vb->setCheckable(true); vb->setCheckable(true);
vb->setChecked(vMode_ == vm); vb->setChecked(vMode_ == vm);
connect(vb, &QToolButton::clicked, this, [this, vm]() { connect(vb, &QToolButton::clicked, this, [this, vm]() {
@@ -810,9 +936,57 @@ void PlotWidget::rebuildHeader() {
headerLay_->addWidget(vb); headerLay_->addWidget(vb);
} }
/* Unified mode's single scale belongs to the plot, not to any one signal,
* so it is edited from here rather than from a badge's context menu. */
if (vMode_ == 3) {
auto* yb = new QToolButton(header_);
yb->setText(QString("Y-Scale: %1/div").arg(fmtVal(uniVS_.resolvedDiv)));
yb->setToolTip("Vertical scale shared by every signal in this plot");
connect(yb, &QToolButton::clicked, this, [this, yb]() {
showUnifiedVScaleMenu(yb->mapToGlobal(QPoint(0, yb->height())));
});
headerLay_->addWidget(yb);
}
headerLay_->addStretch(1); headerLay_->addStretch(1);
} }
/** Populate @a vs with the Auto/Range/Manual entries driving @a evs. */
void PlotWidget::buildVScaleMenu(QMenu* vs, VScale& evs) {
const char* modes[] = {"Auto", "Range", "Manual"};
for (int mm = 0; mm < 3; mm++) {
QAction* act = vs->addAction(modes[mm]);
act->setCheckable(true); act->setChecked(evs.mode == mm);
connect(act, &QAction::triggered, this, [this, &evs, mm]() {
evs.mode = mm; rebuildHeader(); canvas_->update();
});
}
vs->addSeparator();
vs->addAction("Manual V/div…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division",
evs.mode==2?evs.divValue:evs.resolvedDiv, -1e12, 1e12, 6, &ok);
if (ok) { evs.divValue = v; evs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Offset…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value",
evs.mode==2?evs.offset:evs.resolvedOffset, -1e12, 1e12, 6, &ok);
if (ok) { evs.offset = v; evs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Position (div)…", [this, &evs]() {
bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center",
evs.screenPos, -8, 8, 2, &ok);
if (ok) { evs.screenPos = v; canvas_->update(); }
});
}
void PlotWidget::showUnifiedVScaleMenu(const QPoint& globalPos) {
QMenu m;
m.addAction("Y-Scale — all signals")->setEnabled(false);
m.addSeparator();
buildVScaleMenu(&m, uniVS_);
m.exec(globalPos);
}
void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) { void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) {
auto& sources = hub_->sources(); auto& sources = hub_->sources();
if (slotIdx < 0 || slotIdx >= (int)slots_.size()) return; if (slotIdx < 0 || slotIdx >= (int)slots_.size()) return;
@@ -846,30 +1020,12 @@ void PlotWidget::showBadgeMenu(int slotIdx, const QPoint& globalPos) {
connect(dg, &QAction::toggled, this, [&](bool on){ a.vs.digitalInMixed = on; canvas_->update(); }); connect(dg, &QAction::toggled, this, [&](bool on){ a.vs.digitalInMixed = on; canvas_->update(); });
} }
/* In unified mode the plot has one scale for every trace, so it is edited
* from the header's Y-Scale button instead of from any one signal. */
if (vMode_ != 3) {
m.addSeparator(); m.addSeparator();
QMenu* vs = m.addMenu("V-scale"); buildVScaleMenu(m.addMenu("V-scale"), a.vs);
const char* modes[] = {"Auto", "Range", "Manual"};
for (int mm = 0; mm < 3; mm++) {
QAction* act = vs->addAction(modes[mm]);
act->setCheckable(true); act->setChecked(a.vs.mode == mm);
connect(act, &QAction::triggered, this, [&, mm]() { a.vs.mode = mm; rebuildHeader(); canvas_->update(); });
} }
vs->addSeparator();
vs->addAction("Manual V/div…", [&]() {
bool ok; double v = QInputDialog::getDouble(this, "V/div", "Units per division",
a.vs.mode==2?a.vs.divValue:a.vs.resolvedDiv, -1e12, 1e12, 6, &ok);
if (ok) { a.vs.divValue = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Offset…", [&]() {
bool ok; double v = QInputDialog::getDouble(this, "Offset", "Center value",
a.vs.mode==2?a.vs.offset:a.vs.resolvedOffset, -1e12, 1e12, 6, &ok);
if (ok) { a.vs.offset = v; a.vs.mode = 2; rebuildHeader(); canvas_->update(); }
});
vs->addAction("Position (div)…", [&]() {
bool ok; double v = QInputDialog::getDouble(this, "Position", "Divisions from center",
a.vs.screenPos, -8, 8, 2, &ok);
if (ok) { a.vs.screenPos = v; canvas_->update(); }
});
m.addSeparator(); m.addSeparator();
m.addAction("Remove from plot", [&]() { m.addAction("Remove from plot", [&]() {
+5 -1
View File
@@ -21,6 +21,7 @@
class QHBoxLayout; class QHBoxLayout;
class QToolButton; class QToolButton;
class QLabel; class QLabel;
class QMenu;
namespace shq { namespace shq {
@@ -69,6 +70,8 @@ private:
friend class PlotCanvas; friend class PlotCanvas;
void rebuildHeader(); void rebuildHeader();
void buildVScaleMenu(QMenu* vs, VScale& evs);
void showUnifiedVScaleMenu(const QPoint& globalPos);
void showBadgeMenu(int slotIdx, const QPoint& globalPos); void showBadgeMenu(int slotIdx, const QPoint& globalPos);
void pushZoomHist(); void pushZoomHist();
void initPlotX(double tMax); void initPlotX(double tMax);
@@ -87,7 +90,8 @@ private:
bool paused_ = false; bool paused_ = false;
double plotXMin_ = 0.0; double plotXMin_ = 0.0;
double plotXMax_ = 0.0; double plotXMax_ = 0.0;
int vMode_ = 0; /* 0 normal 1 digital 2 mixed */ int vMode_ = 0; /* 0 normal 1 digital 2 mixed 3 unified */
VScale uniVS_; /* the one scale every trace shares in mode 3 */
int activeSlot_ = -1; int activeSlot_ = -1;
bool trigZoomed_ = false; bool trigZoomed_ = false;
+6
View File
@@ -825,11 +825,17 @@ void App::onTriggerState(const std::string& json) {
trigger_.trigTime = msg.trigTime; trigger_.trigTime = msg.trigTime;
trigger_.hasTrigTime = true; trigger_.hasTrigTime = true;
} }
if (msg.hasWindow) {
trigger_.firedPreS = msg.preSec;
trigger_.firedPostS = msg.postSec;
trigger_.hasFiredWin = true;
}
/* Double-buffer semantics: the last recorded capture stays on display /* Double-buffer semantics: the last recorded capture stays on display
* (even while re-armed/collecting) and is only replaced when a new * (even while re-armed/collecting) and is only replaced when a new
* capture frame has been fully received and parsed (handleBinary v2). */ * capture frame has been fully received and parsed (handleBinary v2). */
if (msg.state == "idle") { if (msg.state == "idle") {
trigger_.hasTrigTime = false; trigger_.hasTrigTime = false;
trigger_.hasFiredWin = false;
} }
} }
+11 -2
View File
@@ -61,6 +61,11 @@ struct TriggerState {
bool stopped = false; bool stopped = false;
bool hasTrigTime = false; bool hasTrigTime = false;
double trigTime = 0.0; double trigTime = 0.0;
/* Window the hub latched at fire time. Not the same as windowSec/prePercent
* above, which are editable and may have moved on since the trigger fired. */
bool hasFiredWin = false;
double firedPreS = 0.0;
double firedPostS = 0.0;
}; };
/** Per-signal vertical scale state (oscilloscope style). */ /** Per-signal vertical scale state (oscilloscope style). */
@@ -183,9 +188,12 @@ public:
plotXMax_[i] = tMax; plotXMax_[i] = tMax;
} }
/** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed. */ /** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed 3=unified. */
int& plotVMode(int i) { return plotVMode_[i]; } int& plotVMode(int i) { return plotVMode_[i]; }
/** @brief The one scale every trace shares in unified mode (vMode 3). */
VScale& plotUnifiedVS(int i) { return plotUniVS_[i]; }
/* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */ /* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */
bool& cursorsOn() { return cursorsOn_; } bool& cursorsOn() { return cursorsOn_; }
double& cursorA() { return cursorA_; } double& cursorA() { return cursorA_; }
@@ -302,7 +310,8 @@ private:
double windowSec_ = 10.0; /* live scroll window width */ double windowSec_ = 10.0; /* live scroll window width */
double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */ double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */
double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */ double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */
int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed */ int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed 3=unified */
VScale plotUniVS_[kMaxPlotSlots]; /* shared scale used by vMode 3 */
/* Cursors (global) */ /* Cursors (global) */
bool cursorsOn_ = false; bool cursorsOn_ = false;
+192 -61
View File
@@ -85,6 +85,49 @@ static double normalizeY(double raw, const VScale& vs) {
return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos; return (raw - vs.resolvedOffset) / vs.resolvedDiv + vs.screenPos;
} }
/** Resolve the one scale every trace shares in unified mode.
*
* Same rules as the per-signal version, applied to the union of the plot:
* range takes the union of the declared ranges, auto fits the union of the
* data. Signals whose slot is empty contribute nothing. */
static void resolveUnifiedVScale(VScale& vs,
const std::vector<PlotAssignment>& slots,
const std::vector<Source>& sources,
const std::vector<std::vector<double> >& vStore) {
if (vs.mode == 2) { /* manual */
vs.resolvedDiv = std::max(vs.divValue, 1e-30);
vs.resolvedOffset = vs.offset;
return;
}
double mn = 1e300, mx = -1e300;
if (vs.mode == 1) { /* range: union of every declared range */
for (const auto& a : slots) {
if (a.sourceIdx < 0 || a.sourceIdx >= (int)sources.size()) continue;
const auto& m = sources[a.sourceIdx].signals[a.signalIdx].meta;
if (!(m.rangeMax > m.rangeMin)) continue;
if (m.rangeMin < mn) mn = m.rangeMin;
if (m.rangeMax > mx) mx = m.rangeMax;
}
if (mx > mn) {
vs.resolvedDiv = std::max((mx - mn) / 8.0, 1e-30);
vs.resolvedOffset = (mn + mx) / 2.0;
return;
}
mn = 1e300; mx = -1e300; /* no usable range: fall through to auto */
}
for (const auto& vv : vStore) {
for (double v : vv) {
if (!std::isfinite(v)) continue;
if (v < mn) mn = v;
if (v > mx) mx = v;
}
}
if (!std::isfinite(mn) || mn > mx) { mn = -1.0; mx = 1.0; }
if (mn == mx) { mn -= 1.0; mx += 1.0; }
vs.resolvedDiv = std::max((mx - mn) / 6.0, 1e-30);
vs.resolvedOffset = (mx + mn) / 2.0;
}
/** Min/max of a vector (returns false if empty/non-finite). */ /** Min/max of a vector (returns false if empty/non-finite). */
static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) { static bool dataMinMax(const std::vector<double>& v, double& mn, double& mx) {
mn = 1e300; mx = -1e300; mn = 1e300; mx = -1e300;
@@ -149,9 +192,36 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
const double wallNow = std::chrono::duration<double>( const double wallNow = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
/* Trigger view: render the hub capture relative to the trigger instant */ /* Trigger view: render the hub capture relative to the trigger instant.
*
* Two ways to end up in trigger-relative time. Either a v2 capture frame
* has arrived (trigView), or a trigger has fired and its window is still
* filling (trigFill). In the second case the hub sends nothing until the
* whole window has been produced — several seconds for a long window at a
* high rate — so the trace is drawn from this client's own rings on the
* final axis, growing left to right. Filling wins over the previous
* capture: once a new trigger fires, the stale waveform is history. */
const CaptureFrame* cap = app.capture(); const CaptureFrame* cap = app.capture();
const bool trigView = (cap != nullptr) && app.showTrigBar(); const TriggerState& trg = app.trigger();
/* Prefer the window the hub latched at fire time; the local config is only
* a fallback for hubs that do not report it, and may have been edited
* since the trigger fired. */
const double fillPreS = trg.hasFiredWin ? trg.firedPreS
: trg.windowSec * trg.prePercent * 0.01;
const double fillPostS = trg.hasFiredWin ? trg.firedPostS
: trg.windowSec - fillPreS;
const bool trigFill = app.showTrigBar() && !paused &&
trg.status == "collecting" && trg.hasTrigTime;
const bool trigView = (cap != nullptr) && app.showTrigBar() && !trigFill;
const bool trigRel = trigView || trigFill;
/* Window edges of whatever is on screen. A capture latches its own
* pre/post at fire time, so later edits in the trigger bar must not move
* the axis of a finished capture. */
const double trigT = trigView ? cap->trigTime : trg.trigTime;
const double trigPreS = trigView ? cap->preSec : fillPreS;
const double trigPostS = trigView ? cap->postSec : fillPostS;
/* Hi-res zoom cache for this plot */ /* Hi-res zoom cache for this plot */
auto& zc = app.zoomCache(plotIdx); auto& zc = app.zoomCache(plotIdx);
@@ -194,13 +264,13 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
* from decimated pushes) undersamples the visible range. Periodically * from decimated pushes) undersamples the visible range. Periodically
* fetch a fresh ~2400-pt slice from the hub raw ring and anchor the X * fetch a fresh ~2400-pt slice from the hub raw ring and anchor the X
* axis to the fetched slice (scope-style refresh at the fetch rate). */ * axis to the fetched slice (scope-style refresh at the fetch rate). */
const bool liveHiRes = !trigView && live && !paused && const bool liveHiRes = !trigRel && live && !paused &&
app.windowSec() <= kLiveHiResMaxWin && app.windowSec() <= kLiveHiResMaxWin &&
zc.valid && zc.valid &&
(zc.t1 - zc.t0) >= app.windowSec() * 0.9 && (zc.t1 - zc.t0) >= app.windowSec() * 0.9 &&
(wallNow - zc.t1) < 3.0; (wallNow - zc.t1) < 3.0;
const bool useZoomData = !trigView && !paused && zc.valid && const bool useZoomData = !trigRel && !paused && zc.valid &&
(liveHiRes || (liveHiRes ||
(!live && (!live &&
zc.t0 <= app.plotXMin(plotIdx) + 1e-9 && zc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
@@ -215,7 +285,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
const bool haveHistCover = hc.valid && const bool haveHistCover = hc.valid &&
hc.t0 <= app.plotXMin(plotIdx) + 1e-9 && hc.t0 <= app.plotXMin(plotIdx) + 1e-9 &&
hc.t1 >= app.plotXMax(plotIdx) - 1e-9; hc.t1 >= app.plotXMax(plotIdx) - 1e-9;
bool useHistData = !trigView && !paused && !live && haveHistCover; bool useHistData = !trigRel && !paused && !live && haveHistCover;
if (useHistData) { if (useHistData) {
/* Check that at least one signal has actual data points */ /* Check that at least one signal has actual data points */
bool anyData = false; bool anyData = false;
@@ -231,7 +301,12 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
* copied tens of MB per signal per frame. A 10% margin keeps a sample on * copied tens of MB per signal per frame. A 10% margin keeps a sample on
* each side so the later fine clip still has its boundary points. */ * each side so the later fine clip still has its boundary points. */
double visT0, visT1; double visT0, visT1;
if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); } if (trigFill) {
/* Absolute bounds of the trigger window: the ring is indexed on the
* hub clock, the axis on trigger-relative time. */
visT0 = trigT - trigPreS; visT1 = trigT + trigPostS;
}
else if (live) { visT1 = wallNow; visT0 = wallNow - app.windowSec(); }
else { visT1 = app.plotXMax(plotIdx); visT0 = app.plotXMin(plotIdx); } else { visT1 = app.plotXMax(plotIdx); visT0 = app.plotXMin(plotIdx); }
{ {
double margin = (visT1 - visT0) * 0.1; double margin = (visT1 - visT0) * 0.1;
@@ -272,6 +347,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
break; break;
} }
} else if (trigFill) {
/* Live ring, clipped to the (absolute) window and shifted onto the
* trigger-relative axis. visT0/visT1 already carry a margin, so
* clip here rather than reusing readBase. */
(void) sig.buf.readRange(trigT - trigPreS, trigT + trigPostS,
tStore[si], vStore[si]);
for (size_t i = 0; i < tStore[si].size(); i++) {
tStore[si][i] -= trigT;
}
} else if (useZoomData) { } else if (useZoomData) {
bool found = false; bool found = false;
for (const auto& zs : zc.signals) { for (const auto& zs : zc.signals) {
@@ -302,6 +386,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
resolveVScale(a, sig, vStore[si]); resolveVScale(a, sig, vStore[si]);
} }
VScale& uniVS = app.plotUnifiedVS(plotIdx);
if (vMode == 3) {
resolveUnifiedVScale(uniVS, slots, sources, vStore);
}
/* clamp active slot */ /* clamp active slot */
if (actSlot >= (int)slots.size()) actSlot = -1; if (actSlot >= (int)slots.size()) actSlot = -1;
@@ -326,9 +415,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
ImVec4(0.067f,0.067f,0.106f,1.f)); ImVec4(0.067f,0.067f,0.106f,1.f));
char badge[80]; char badge[80];
/* show vscale info: resolved div value */ /* Show the div value actually in force: the plot's shared one in
* unified mode, this signal's otherwise. */
char dvbuf[16]; char dvbuf[16];
fmtVal(dvbuf, sizeof(dvbuf), a.vs.resolvedDiv); fmtVal(dvbuf, sizeof(dvbuf),
(vMode == 3) ? uniVS.resolvedDiv : a.vs.resolvedDiv);
snprintf(badge, sizeof(badge), "%s %s/div##b%d", snprintf(badge, sizeof(badge), "%s %s/div##b%d",
sig.meta.name.c_str(), dvbuf, i); sig.meta.name.c_str(), dvbuf, i);
@@ -398,7 +489,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
/* Back / Fit / Reset (zoom history) */ /* Back / Fit / Reset (zoom history) */
if (!live || (trigView && app.trigZoomed(plotIdx))) { if (!live || (trigRel && app.trigZoomed(plotIdx))) {
ImGui::SameLine(); ImGui::SameLine();
auto& hist = app.zoomHist(plotIdx); auto& hist = app.zoomHist(plotIdx);
if (hist.empty()) { ImGui::BeginDisabled(); } if (hist.empty()) { ImGui::BeginDisabled(); }
@@ -408,7 +499,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
if (hist.empty()) { ImGui::EndDisabled(); } if (hist.empty()) { ImGui::EndDisabled(); }
ImGui::SameLine(); ImGui::SameLine();
if (trigView) { if (trigRel) {
/* Reset to full capture window */ /* Reset to full capture window */
if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) { if (ImGui::SmallButton(ICON_FA_EXPAND " Reset##zr")) {
app.trigZoomed(plotIdx) = false; app.trigZoomed(plotIdx) = false;
@@ -440,10 +531,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */ /* Norm/Dig/Mix mode — compact toggle buttons matching SmallButton height */
ImGui::SameLine(); ImGui::SameLine();
{ {
static const char* kVLabels[] = {"N", "D", "M"}; static const char* kVLabels[] = {"N", "U", "D", "M"};
static const char* kVTooltips[] = {"Normal", "Digital", "Mixed"}; static const char* kVTooltips[] = {
for (int vm = 0; vm < 3; vm++) { "Normal: one vertical scale per signal",
char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[vm], plotIdx, vm); "Unified: one vertical scale shared by every signal",
"Digital", "Mixed" };
static const int kVModes[] = {0, 3, 1, 2};
for (int i = 0; i < 4; i++) {
const int vm = kVModes[i];
char vmId[16]; snprintf(vmId, sizeof(vmId), "%s##vm%d_%d", kVLabels[i], plotIdx, vm);
bool sel = (vMode == vm); bool sel = (vMode == vm);
if (sel) { if (sel) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f)); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.537f,0.706f,0.980f,0.4f));
@@ -451,28 +547,41 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
if (ImGui::SmallButton(vmId)) { vMode = vm; } if (ImGui::SmallButton(vmId)) { vMode = vm; }
if (sel) { ImGui::PopStyleColor(2); } if (sel) { ImGui::PopStyleColor(2); }
if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[vm]); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", kVTooltips[i]); }
if (vm < 2) { ImGui::SameLine(0.f, 1.f); } if (i < 3) { ImGui::SameLine(0.f, 1.f); }
} }
} }
/* ── VScale toolbar (shown when an active signal is selected) ───────── */ /* ── VScale toolbar ──────────────────────────────────────────────────── *
* Normal mode edits the active signal's scale; unified mode edits the one
* scale the whole plot shares, so it needs no selection. */
VScale *toolVS = static_cast<VScale *>(0);
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
auto& a = slots[actSlot]; toolVS = &slots[actSlot].vs;
} else if (vMode == 3) {
toolVS = &uniVS;
}
if (toolVS != static_cast<VScale *>(0)) {
VScale& tvs = *toolVS;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f,2.f));
if (vMode == 3) {
ImGui::TextDisabled("all signals");
ImGui::SameLine(0.f,10.f);
}
/* mode buttons */ /* mode buttons */
static const char* kModeLabels[] = {"Auto","Range","Manual"}; static const char* kModeLabels[] = {"Auto","Range","Manual"};
for (int m = 0; m < 3; m++) { for (int m = 0; m < 3; m++) {
bool sel = (a.vs.mode == m); bool sel = (tvs.mode == m);
if (sel) { if (sel) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::PushStyleColor(ImGuiCol_Button,
ImVec4(0.537f,0.706f,0.980f,0.3f)); ImVec4(0.537f,0.706f,0.980f,0.3f));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(0.537f,0.706f,0.980f,1.f)); ImVec4(0.537f,0.706f,0.980f,1.f));
} }
if (ImGui::SmallButton(kModeLabels[m])) { a.vs.mode = m; } if (ImGui::SmallButton(kModeLabels[m])) { tvs.mode = m; }
if (sel) ImGui::PopStyleColor(2); if (sel) ImGui::PopStyleColor(2);
if (m < 2) ImGui::SameLine(0.f,2.f); if (m < 2) ImGui::SameLine(0.f,2.f);
} }
@@ -480,23 +589,23 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* resolved info */ /* resolved info */
char rbuf[24], obuf[24]; char rbuf[24], obuf[24];
fmtVal(rbuf, sizeof(rbuf), a.vs.resolvedDiv); fmtVal(rbuf, sizeof(rbuf), tvs.resolvedDiv);
fmtVal(obuf, sizeof(obuf), a.vs.resolvedOffset); fmtVal(obuf, sizeof(obuf), tvs.resolvedOffset);
if (a.vs.mode == 2) { /* manual: editable */ if (tvs.mode == 2) { /* manual: editable */
ImGui::SetNextItemWidth(70.f); ImGui::SetNextItemWidth(70.f);
ImGui::InputDouble("V/div##vd", &a.vs.divValue, 0,0,"%.4g"); ImGui::InputDouble("V/div##vd", &tvs.divValue, 0,0,"%.4g");
ImGui::SameLine(0.f,4.f); ImGui::SameLine(0.f,4.f);
ImGui::SetNextItemWidth(80.f); ImGui::SetNextItemWidth(80.f);
ImGui::InputDouble("Offset##vo", &a.vs.offset, 0,0,"%.4g"); ImGui::InputDouble("Offset##vo", &tvs.offset, 0,0,"%.4g");
} else { } else {
ImGui::TextDisabled("%s/div @%s", rbuf, obuf); ImGui::TextDisabled("%s/div @%s", rbuf, obuf);
} }
ImGui::SameLine(0.f,10.f); ImGui::SameLine(0.f,10.f);
ImGui::SetNextItemWidth(50.f); ImGui::SetNextItemWidth(50.f);
float sp = (float)a.vs.screenPos; float sp = (float)tvs.screenPos;
if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) { if (ImGui::InputFloat("Pos(div)##vp", &sp, 0,0,"%.1f")) {
a.vs.screenPos = sp; tvs.screenPos = sp;
} }
ImGui::PopStyleVar(); ImGui::PopStyleVar();
@@ -539,7 +648,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) { if (ImPlot::BeginPlot(plotId, ImVec2(-1.f,-1.f), plotFlags)) {
/* Both axes locked so ImPlot never overrides our explicit limits. */ /* Both axes locked so ImPlot never overrides our explicit limits. */
ImPlot::SetupAxes(trigView ? "t - trig (s)" : "Time (s)", nullptr, ImPlot::SetupAxes(trigRel ? "t - trig (s)" : "Time (s)", nullptr,
ImPlotAxisFlags_Lock, ImPlotAxisFlags_Lock,
ImPlotAxisFlags_Lock); ImPlotAxisFlags_Lock);
@@ -549,13 +658,17 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* X axis: trig view → capture window (zoomable); live → wall clock; else stored */ /* X axis: trig view → capture window (zoomable); live → wall clock; else stored */
double xMin, xMax; double xMin, xMax;
bool& trigZm = app.trigZoomed(plotIdx); bool& trigZm = app.trigZoomed(plotIdx);
if (trigView) { if (trigRel) {
if (trigZm) { if (trigZm) {
xMin = app.plotXMin(plotIdx); xMin = app.plotXMin(plotIdx);
xMax = app.plotXMax(plotIdx); xMax = app.plotXMax(plotIdx);
} else { } else {
xMin = -cap->preSec; /* Full window from the start, even while filling: a trace that
xMax = cap->postSec; * grows into a fixed axis reads as progress; an axis that
* grows with the data makes the whole trace shift every
* frame and the time base meaningless. */
xMin = -trigPreS;
xMax = trigPostS;
} }
} else if (live && !paused) { } else if (live && !paused) {
if (liveHiRes) { if (liveHiRes) {
@@ -568,7 +681,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} else { } else {
xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx); xMin = app.plotXMin(plotIdx); xMax = app.plotXMax(plotIdx);
} }
if (trigView || (live && !paused) || !live) { if (trigRel || (live && !paused) || !live) {
if (xMax > xMin) { if (xMax > xMin) {
ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always); ImPlot::SetupAxisLimits(ImAxis_X1, xMin, xMax, ImGuiCond_Always);
} }
@@ -579,8 +692,16 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
static char yTickBufs[9][20]; static char yTickBufs[9][20];
static const char* yTickLabels[9]; static const char* yTickLabels[9];
const VScale *axisVS = static_cast<const VScale *>(0);
if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) { if (vMode == 0 && actSlot >= 0 && actSlot < (int)slots.size()) {
const auto& av = slots[actSlot].vs; axisVS = &slots[actSlot].vs;
} else if (vMode == 3) {
/* Unified: the shared scale labels the axis for every trace at
* once, so no signal has to be selected first. */
axisVS = &uniVS;
}
if (axisVS != static_cast<const VScale *>(0)) {
const VScale& av = *axisVS;
for (int d = 0; d < 9; d++) { for (int d = 0; d < 9; d++) {
double divPos = yTickVals[d]; double divPos = yTickVals[d];
double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv; double rawVal = av.resolvedOffset + (divPos - av.screenPos) * av.resolvedDiv;
@@ -636,15 +757,15 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* Helper: enter zoomed mode for trigger view (seed from capture window) */ /* Helper: enter zoomed mode for trigger view (seed from capture window) */
auto enterTrigZoom = [&]() { auto enterTrigZoom = [&]() {
if (trigView && !trigZm) { if (trigRel && !trigZm) {
app.setPlotX(plotIdx, -cap->preSec, cap->postSec); app.setPlotX(plotIdx, -trigPreS, trigPostS);
trigZm = true; trigZm = true;
} }
}; };
/* Helper: X-zoom the stored range by factor around center */ /* Helper: X-zoom the stored range by factor around center */
auto xZoomStored = [&](double factor) { auto xZoomStored = [&](double factor) {
if (trigView) { enterTrigZoom(); } if (trigRel) { enterTrigZoom(); }
if (now - lastHistPush[plotIdx] > 0.6) { if (now - lastHistPush[plotIdx] > 0.6) {
app.pushZoomHist(plotIdx); app.pushZoomHist(plotIdx);
lastHistPush[plotIdx] = now; lastHistPush[plotIdx] = now;
@@ -659,37 +780,45 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
const double zoomOut = 1.25; const double zoomOut = 1.25;
double factor = (wheel > 0.f) ? zoomIn : zoomOut; double factor = (wheel > 0.f) ? zoomIn : zoomOut;
/* Scroll adjusts the scale the axis is labelled with: the
* active signal's in normal mode, the plot's shared one in
* unified mode (where there is nothing to select). */
VScale *wheelVS = static_cast<VScale *>(0);
if (vMode == 3) {
wheelVS = &uniVS;
} else if (actSlot >= 0 && actSlot < (int)slots.size()) {
wheelVS = &slots[actSlot].vs;
}
/* Seed manual from the resolved values so the gesture sticks. */
auto latchManual = [](VScale& v) {
if (v.mode != 2) {
v.divValue = std::max(v.resolvedDiv, 1e-30);
v.offset = v.resolvedOffset;
v.mode = 2;
}
};
if (ctrl) { if (ctrl) {
/* ── X zoom ─────────────────────────────────────────── */ /* ── X zoom ─────────────────────────────────────────── */
if (!trigView && live) { if (!trigRel && live) {
app.setWindowSec(app.windowSec() * factor); app.setWindowSec(app.windowSec() * factor);
} else { } else {
xZoomStored(factor); xZoomStored(factor);
} }
} else if (shift) { } else if (shift) {
/* ── Y offset of active signal ───────────────────────── */ /* ── Y pan ───────────────────────────────────────────── */
if (actSlot >= 0 && actSlot < (int)slots.size()) { if (wheelVS != static_cast<VScale *>(0)) {
auto& a = slots[actSlot]; latchManual(*wheelVS);
if (a.vs.mode != 2) { wheelVS->screenPos += (wheel > 0.f) ? 0.5 : -0.5;
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
a.vs.offset = a.vs.resolvedOffset;
a.vs.mode = 2;
}
a.vs.screenPos += (wheel > 0.f) ? 0.5 : -0.5;
} }
} else { } else {
/* ── Y zoom of active signal ─────────────────────────── */ /* ── Y zoom ──────────────────────────────────────────── */
if (actSlot >= 0 && actSlot < (int)slots.size()) { if (wheelVS != static_cast<VScale *>(0)) {
auto& a = slots[actSlot]; latchManual(*wheelVS);
if (a.vs.mode != 2) { wheelVS->divValue = std::max(wheelVS->divValue * factor, 1e-30);
a.vs.divValue = std::max(a.vs.resolvedDiv, 1e-30);
a.vs.offset = a.vs.resolvedOffset;
a.vs.mode = 2;
}
a.vs.divValue = std::max(a.vs.divValue * factor, 1e-30);
} else { } else {
/* No active signal: plain scroll → X zoom */ /* No active signal: plain scroll → X zoom */
if (!trigView && live) { if (!trigRel && live) {
app.setWindowSec(app.windowSec() * factor); app.setWindowSec(app.windowSec() * factor);
} else { } else {
xZoomStored(factor); xZoomStored(factor);
@@ -701,8 +830,8 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
/* Right-drag → X pan. Transition live→non-live on drag start; /* Right-drag → X pan. Transition live→non-live on drag start;
* in trigger view, enter trigger-zoom mode. */ * in trigger view, enter trigger-zoom mode. */
if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) { if (ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
if (trigView) { enterTrigZoom(); } if (trigRel) { enterTrigZoom(); }
if (!trigView && live) { if (!trigRel && live) {
app.initPlotX(plotIdx, wallNow); app.initPlotX(plotIdx, wallNow);
live = false; live = false;
lastHistPush[plotIdx] = now; lastHistPush[plotIdx] = now;
@@ -721,7 +850,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
/* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */ /* ── Hi-res WS zoom requests (suppressed while paused) ──────────── */
if (!trigView && !paused) { if (!trigRel && !paused) {
std::string csv; std::string csv;
for (const auto& a : slots) { for (const auto& a : slots) {
std::string k = app.slotKey(a); std::string k = app.slotKey(a);
@@ -821,9 +950,11 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} else if (vMode == 2) { /* mixed */ } else if (vMode == 2) { /* mixed */
bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed); bandNormalize(vDec, vNorm, myKi, nTraces, a.vs.digitalInMixed);
} else { } else {
/* unified shares one scale, normal gives each trace its own */
const VScale& nvs = (vMode == 3) ? uniVS : a.vs;
vNorm.resize(nOut); vNorm.resize(nOut);
for (size_t k = 0; k < nOut; k++) { for (size_t k = 0; k < nOut; k++) {
vNorm[k] = normalizeY(vDec[k], a.vs); vNorm[k] = normalizeY(vDec[k], nvs);
} }
} }
@@ -836,7 +967,7 @@ void RenderPlotPanel(App& app, int plotIdx, bool& paused) {
} }
/* Trigger instant marker (capture view: t = 0) */ /* Trigger instant marker (capture view: t = 0) */
if (trigView) { if (trigRel) {
double t0m = 0.0; double t0m = 0.0;
ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f), ImPlot::DragLineX(900, &t0m, ImVec4(1.f,1.f,0.f,0.8f),
1.5f, ImPlotDragToolFlags_NoInputs); 1.5f, ImPlotDragToolFlags_NoInputs);
+6
View File
@@ -458,6 +458,12 @@ bool ParseTriggerState(const std::string& json, TriggerStateMsg& out) {
double tt = 0.0; double tt = 0.0;
out.hasTrigTime = jsonGetDouble(json.c_str(), "trigTime", tt); out.hasTrigTime = jsonGetDouble(json.c_str(), "trigTime", tt);
out.trigTime = tt; out.trigTime = tt;
double pre = 0.0, post = 0.0;
out.hasWindow = jsonGetDouble(json.c_str(), "preSec", pre) &&
jsonGetDouble(json.c_str(), "postSec", post);
out.preSec = pre;
out.postSec = post;
return true; return true;
} }
+5
View File
@@ -109,6 +109,11 @@ struct TriggerStateMsg {
bool stopped = false; bool stopped = false;
bool hasTrigTime = false; bool hasTrigTime = false;
double trigTime = 0.0; double trigTime = 0.0;
/* Window latched at fire time, sent alongside trigTime. Older hubs omit
* it, hence hasWindow — fall back to the local trigger config then. */
bool hasWindow = false;
double preSec = 0.0;
double postSec = 0.0;
}; };
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
+54 -1
View File
@@ -9,6 +9,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath"
"syscall"
"marte2/common/wshub" "marte2/common/wshub"
) )
@@ -24,17 +27,53 @@ type multiFlag []string
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) } func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil } func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
// defaultHistoryDir is where samples are archived unless -history-dir says
// otherwise. History is on by default because it is what holds a trigger
// capture at full resolution: the in-memory rings roll past a captured window
// within seconds of it being taken, and a zoom after that has nothing but the
// capture's own decimated copy to draw. Per-signal files are bounded by
// -history-max-mpts, so the default costs a fixed amount of space.
func defaultHistoryDir() string {
return filepath.Join(os.TempDir(), "udpstreamer-history")
}
func main() { func main() {
var sourceArgs multiFlag var sourceArgs multiFlag
flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`) flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`)
sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)") sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)")
listenAddr := flag.String("addr", ":8080", "HTTP listen address") listenAddr := flag.String("addr", ":8080", "HTTP listen address")
histDir := flag.String("history-dir", defaultHistoryDir(), "Directory for disk-backed signal history (empty disables it)")
histWindow := flag.Float64("history-window-sec", 0, "Timespan the history files hold before any client says what it displays (0 keeps the 10 s default); the hub re-sizes them to the live or trigger window afterwards")
histDecim := flag.Int("history-decimation", 1, "Keep every Nth sample in the history files")
histFlush := flag.Int("history-flush-sec", 5, "Seconds between history header flushes")
histMinFree := flag.Int("history-min-free-mb", 500, "Pause history writing below this much free disk (negative disables the check)")
histMaxMPts := flag.Float64("history-max-mpts", 0, "Per-signal history budget in millions of points, also settable in the web UI (0 keeps the 16 MPts / 256 MB default)")
ringMPts := flag.Float64("ring-mpts", 0, "Per-signal in-memory buffer in millions of points (0 keeps the 10 MPts / 160 MB default)")
flag.Parse() flag.Parse()
hub := wshub.NewHub() hub := wshub.NewHub()
// The budget bounds memory, not the window: a window too long to hold at the
// source rate is buffered as min/max pairs rather than truncated to the tail.
hub.SetRingBudget(int(*ringMPts * 1e6))
sm := wshub.NewSourceManager(hub, *sourcesFile) sm := wshub.NewSourceManager(hub, *sourcesFile)
hub.SetSourceManager(sm) hub.SetSourceManager(sm)
if err := hub.EnableHistory(wshub.HistoryConfig{
Directory: *histDir,
WindowSec: *histWindow,
Decimation: *histDecim,
FlushIntervalSec: *histFlush,
MinDiskFreeMB: *histMinFree,
MaxPointsPerSignal: int(*histMaxMPts * 1e6),
}); err != nil {
log.Fatalf("history: %v", err)
}
if *histDir == "" {
log.Print("history disabled: zooming into a trigger capture will fall back " +
"to the capture's own decimated copy once the rings roll past it")
} else {
log.Printf("history: %s", *histDir)
}
go hub.Run() go hub.Run()
// Load sources from file first (if specified), then add any CLI --source flags. // Load sources from file first (if specified), then add any CLI --source flags.
@@ -60,7 +99,21 @@ func main() {
}) })
log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion) log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion)
if err := http.ListenAndServe(*listenAddr, nil); err != nil {
// Serve in the background so Ctrl-C can flush the history files: the
// samples written since the last periodic flush are on disk but are not
// yet accounted for in the file headers, so exiting outright loses them.
srvErr := make(chan error, 1)
go func() { srvErr <- http.ListenAndServe(*listenAddr, nil) }()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
select {
case err := <-srvErr:
hub.CloseHistory()
log.Fatalf("http: %v", err) log.Fatalf("http: %v", err)
case s := <-sig:
log.Printf("received %s, flushing history", s)
hub.CloseHistory()
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
'use strict';
// Min/max (peak-envelope) decimation — O(n). Runs off-main-thread to avoid
// blocking the render loop.
//
// The range is split into threshold/2 equal buckets and each contributes its
// smallest and largest sample, in the order the two occurred — the way an
// oscilloscope draws a trace it cannot show pixel-for-pixel.
//
// This replaced LTTB, which picks the sample forming the largest triangle with
// its neighbours: a plausible-looking shape, but it silently drops a one-sample
// spike whenever a smoother neighbour scores higher — exactly the sample worth
// looking at. The envelope cannot drop it, because a spike is by definition its
// bucket's min or max. Every output point is a real sample at its real
// timestamp; nothing is interpolated or averaged.
//
// Kept identical to minMaxDecimate() in Common/Client/go/wshub/hub.go and to
// decimate() in app.js, so a trace looks the same whichever thinned it.
function decimate(t, v, threshold) {
const len = t.length;
if (len <= threshold || threshold < 4) {
// Copy to new arrays so we can transfer them back without detaching the input.
return { t: new Float64Array(t), v: new Float64Array(v) };
}
const buckets = threshold >> 1;
const outT = new Float64Array(threshold);
const outV = new Float64Array(threshold);
let n = 0;
for (let b = 0; b < buckets; b++) {
const lo = Math.floor(b * len / buckets);
const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets);
if (lo >= hi) continue;
let iMin = lo, iMax = lo;
for (let j = lo + 1; j < hi; j++) {
if (v[j] < v[iMin]) iMin = j;
if (v[j] > v[iMax]) iMax = j;
}
// Emit in time order so the result plots as one ascending trace.
if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; }
outT[n] = t[iMin]; outV[n] = v[iMin]; n++;
// A bucket whose samples are all equal has one extreme, not two.
if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; }
}
// slice() so the transferred buffers are exactly the used length.
return { t: outT.slice(0, n), v: outV.slice(0, n) };
}
self.onmessage = function({ data: { id, t, v, threshold } }) {
const result = decimate(t, v, threshold);
// Transfer the output buffers back to the main thread zero-copy.
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
};
+51 -16
View File
@@ -30,11 +30,14 @@
</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">
<option value="1">1 s</option><option value="5" selected>5 s</option> <option value="1">1 s</option><option value="2">2 s</option>
<option value="10">10 s</option><option value="30">30 s</option> <option value="5" selected>5 s</option><option value="10">10 s</option>
<option value="60">60 s</option> <option value="15">15 s</option><option value="30">30 s</option>
<option value="60">60 s</option><option value="120">2 min</option>
<option value="300">5 min</option><option value="600">10 min</option>
</select> </select>
<button id="btn-cursor" class="ctrl-btn">Cursors</button> <button id="btn-cursor" class="ctrl-btn">Cursors</button>
<button id="btn-cursor-reset" class="ctrl-btn" style="display:none" title="Bring cursors A/B back into the visible window">↔ Reset</button>
<button id="btn-ruler" class="ctrl-btn" title="Horizontal value rulers">Rulers</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>
@@ -43,7 +46,8 @@
<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)"> <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 <input type="checkbox" id="cb-monotonic">
Sync TS
</label> </label>
</div> </div>
<!-- ── Trigger bar ───────────────────────────────────────────── --> <!-- ── Trigger bar ───────────────────────────────────────────── -->
@@ -70,10 +74,19 @@
<div class="trig-group"> <div class="trig-group">
<span class="trig-label">Window</span> <span class="trig-label">Window</span>
<select id="trig-window" class="trig-select"> <select id="trig-window" class="trig-select">
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option> <option value="0.0001">100 μs</option><option value="0.0002">200 μs</option>
<option value="0.01">10 ms</option><option value="0.1">100 ms</option> <option value="0.0005">500 μs</option><option value="0.001">1 ms</option>
<option value="0.5">500 ms</option><option value="1" selected>1 s</option> <option value="0.002">2 ms</option><option value="0.005">5 ms</option>
<option value="0.01">10 ms</option><option value="0.02">20 ms</option>
<option value="0.05">50 ms</option><option value="0.1">100 ms</option>
<option value="0.2">200 ms</option><option value="0.5">500 ms</option>
<option value="1" selected>1 s</option><option value="2">2 s</option>
<option value="5">5 s</option><option value="10">10 s</option> <option value="5">5 s</option><option value="10">10 s</option>
<option value="20">20 s</option><option value="30">30 s</option>
<option value="60">60 s</option>
<option value="120">2 m</option>
<option value="300">5 m</option>
<option value="600">10 m</option>
</select> </select>
</div> </div>
<div class="trig-sep"></div> <div class="trig-sep"></div>
@@ -83,6 +96,11 @@
<span class="trig-range-val" id="trig-pre-val">20%</span> <span class="trig-range-val" id="trig-pre-val">20%</span>
</div> </div>
<div class="trig-sep"></div> <div class="trig-sep"></div>
<div class="trig-group">
<span class="trig-label" title="Re-arm delay after a capture — prevents double triggering">Holdoff</span>
<input id="trig-holdoff" class="trig-input" type="number" min="0" max="60" step="0.01" value="0.2">
<span class="trig-label">s</span>
</div>
<div class="trig-group"> <div class="trig-group">
<span class="trig-label">Mode</span> <span class="trig-label">Mode</span>
<select id="trig-mode" class="trig-select"> <select id="trig-mode" class="trig-select">
@@ -124,10 +142,30 @@
<span id="status-text">Disconnected</span> <span id="status-text">Disconnected</span>
<span id="sb-tsage"></span> <span id="sb-tsage"></span>
<button id="btn-stats" class="ctrl-btn" style="height:16px;padding:0 7px;font-size:10px;line-height:1">📊 Stats</button> <button id="btn-stats" class="ctrl-btn" style="height:16px;padding:0 7px;font-size:10px;line-height:1">📊 Stats</button>
<span id="history-badge" style="display:none;font-size:10px;color:#f9e2af;margin-left:8px"></span> <button id="history-badge" style="display:none" title="Disk history — click to set the per-signal budget"></button>
</div> </div>
<span id="build-version"></span> <span id="build-version"></span>
</div> </div>
<!-- ── History budget popup ──────────────────────────────────── -->
<div id="history-panel" style="display:none">
<div class="ctx-menu-header">Disk history budget</div>
<div class="ctx-row">
<label>Budget</label>
<input type="number" id="hist-budget" class="ctx-num" min="0.001" step="1">
<span class="ctx-range-val">MPts/signal</span>
</div>
<div class="hist-note">
The budget buys resolution, not duration: a signal too fast to store
sample-for-sample is archived as a min/max envelope wide enough to fit,
so the configured window is always covered.
</div>
<div id="hist-signal-res"></div>
<div class="hist-note hist-warn">Applying re-creates the history files — archived data is lost.</div>
<div class="ctx-row" style="margin:0;justify-content:flex-end">
<button class="ctx-btn" id="btn-hist-cancel">Cancel</button>
<button class="ctx-btn" id="btn-hist-apply">Apply</button>
</div>
</div>
<div id="layout-menu"></div> <div id="layout-menu"></div>
<!-- ── Signal style context menu ─────────────────────────────── --> <!-- ── Signal style context menu ─────────────────────────────── -->
<div id="sig-ctx-menu" style="display:none"> <div id="sig-ctx-menu" style="display:none">
@@ -172,7 +210,8 @@
</div> </div>
<!-- ── Array index picker (trigger signal) ──────────────────────── --> <!-- ── Array index picker (trigger signal) ──────────────────────── -->
<div id="array-idx-picker" style="display:none"> <div id="array-idx-picker" style="display:none">
<div class="ctx-menu-header">Element index: <span id="aip-sig" class="ctx-menu-key"></span></div> <div class="ctx-menu-header">Element index:
<span id="aip-sig" class="ctx-menu-key"></span></div>
<div class="ctx-row"> <div class="ctx-row">
<label>Index</label> <label>Index</label>
<input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0"> <input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0">
@@ -186,7 +225,8 @@
<!-- ── VScale toolbar (moved into plot card when active) ─────────── --> <!-- ── VScale toolbar (moved into plot card when active) ─────────── -->
<div id="vscale-menu" style="display:none"> <div id="vscale-menu" style="display:none">
<div class="vstb-header"> <div class="vstb-header">
<span class="vstb-label">V-Scale: <span id="vscale-menu-key" class="ctx-menu-key"></span></span> <span class="vstb-label"><span id="vscale-menu-title">V-Scale</span>:
<span id="vscale-menu-key" class="ctx-menu-key"></span></span>
<div class="ctx-btns" id="vscale-mode-btns"> <div class="ctx-btns" id="vscale-mode-btns">
<button class="ctx-btn active" data-mode="auto">Auto</button> <button class="ctx-btn active" data-mode="auto">Auto</button>
<button class="ctx-btn" data-mode="range">Range</button> <button class="ctx-btn" data-mode="range">Range</button>
@@ -200,10 +240,6 @@
<label class="vstb-lbl" title="Raw value at screen centre — unbounded, may lie outside the plotted range">Offset</label> <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"> <input type="number" id="vscale-offset" class="ctx-num" step="any" value="0">
</div> </div>
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Pos</label>
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
</div>
<div id="vscale-type-row" style="display:none;align-items:center;gap:4px"> <div id="vscale-type-row" style="display:none;align-items:center;gap:4px">
<label class="vstb-lbl">Type</label> <label class="vstb-lbl">Type</label>
<div class="ctx-btns" id="vscale-type-btns"> <div class="ctx-btns" id="vscale-type-btns">
@@ -213,8 +249,7 @@
</div> </div>
<div class="vstb-sep"></div> <div class="vstb-sep"></div>
<div id="vscale-cal-row" style="display:flex;align-items:center;gap:4px"> <div id="vscale-cal-row" style="display:flex;align-items:center;gap:4px">
<label class="vstb-lbl" id="vscale-cal-lbl" <label class="vstb-lbl" id="vscale-cal-lbl" title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
<label class="vstb-lbl">Scale</label> <label class="vstb-lbl">Scale</label>
<input type="number" id="vscale-cal-scale" class="ctx-num ctx-num-sm" step="any" value="1"> <input type="number" id="vscale-cal-scale" class="ctx-num ctx-num-sm" step="any" value="1">
<label class="vstb-lbl">Offset</label> <label class="vstb-lbl">Offset</label>
-39
View File
@@ -1,39 +0,0 @@
'use strict';
// LTTB (Largest Triangle Three Buckets) decimation — O(n).
// Runs off-main-thread to avoid blocking the render loop.
function lttb(t, v, threshold) {
const len = t.length;
if (len <= threshold) {
// Copy to new arrays so we can transfer them back without detaching the input.
return { t: new Float64Array(t), v: new Float64Array(v) };
}
const outT = new Float64Array(threshold);
const outV = new Float64Array(threshold);
outT[0] = t[0]; outV[0] = v[0];
outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1];
const every = (len - 2) / (threshold - 2);
let a = 0;
for (let i = 0; i < threshold - 2; i++) {
const avgS = Math.floor((i + 1) * every) + 1;
const avgE = Math.min(Math.floor((i + 2) * every) + 1, len);
let avgT = 0, avgV = 0, n = 0;
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
if (n) { avgT /= n; avgV /= n; }
const rS = Math.floor(i * every) + 1;
const rE = Math.min(Math.floor((i + 1) * every) + 1, len);
let maxA = -1, next = rS;
const aT = t[a], aV = v[a];
for (let j = rS; j < rE; j++) {
const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV));
if (area > maxA) { maxA = area; next = j; }
}
outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next;
}
return { t: outT, v: outV };
}
self.onmessage = function({ data: { id, t, v, threshold } }) {
const result = lttb(t, v, threshold);
// Transfer the output buffers back to the main thread zero-copy.
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
};
+35 -19
View File
@@ -141,10 +141,17 @@ input[type=range].trig-range::-webkit-slider-thumb {
#trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); } #trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); }
#trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); } #trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); }
#trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); } #trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
#btn-trig-rearm, #btn-trig-stop { #btn-trig-force, #btn-trig-rearm, #btn-trig-stop {
border:none; border-radius:5px; border:none; border-radius:5px;
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer; display:none; padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer;
} }
#btn-trig-rearm, #btn-trig-stop { display:none; }
#btn-trig-force {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1);
transition:background var(--transition),border-color var(--transition),color var(--transition);
}
#btn-trig-force:hover { background:var(--surface1); border-color:var(--mauve); color:var(--mauve); }
#btn-trig-rearm { background:var(--mauve); color:var(--crust); } #btn-trig-rearm { background:var(--mauve); color:var(--crust); }
#btn-trig-stop { background:var(--surface1); color:var(--yellow); border:1px solid var(--yellow); } #btn-trig-stop { background:var(--surface1); color:var(--yellow); border:1px solid var(--yellow); }
#btn-trig-rearm:hover, #btn-trig-stop:hover { opacity:0.85; } #btn-trig-rearm:hover, #btn-trig-stop:hover { opacity:0.85; }
@@ -192,23 +199,6 @@ input[type=range].trig-range::-webkit-slider-thumb {
.sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; } .sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; }
.type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; } .type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; }
.array-group {}
.array-header {
padding:6px 14px 6px 10px; cursor:pointer; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:6px; user-select:none;
}
.array-header:hover { background:var(--surface0); }
.array-arrow { font-size:10px; color:var(--subtext0); transition:transform var(--transition); display:inline-block; }
.array-header.open .array-arrow { transform:rotate(90deg); }
.array-children { display:none; padding-left:16px; }
.array-header.open + .array-children { display:block; }
.array-child {
padding:4px 14px 4px 8px; cursor:grab; border-radius:6px; margin:1px 6px;
transition:background var(--transition); display:flex; align-items:center; gap:8px;
user-select:none; color:var(--subtext1); font-size:12px;
}
.array-child:hover { background:var(--surface0); }
.array-child:active { cursor:grabbing; }
/* ── Main area ────────────────────────────────────────────────── */ /* ── Main area ────────────────────────────────────────────────── */
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; } #main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
@@ -324,6 +314,32 @@ input[type=range].trig-range::-webkit-slider-thumb {
border:1px solid var(--mauve); border:1px solid var(--mauve);
} }
/* ── History budget ───────────────────────────────────────────── */
#history-badge {
font-size:10px; color:var(--yellow); margin-left:8px; cursor:pointer;
background:transparent; border:1px solid transparent; border-radius:4px;
padding:1px 5px; white-space:nowrap;
}
#history-badge:hover { border-color:var(--yellow); background:rgba(249,226,175,0.10); }
#history-panel {
position:fixed; z-index:300;
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; width:290px;
}
.hist-note { font-size:10px; color:var(--overlay0); line-height:1.4; margin:6px 0; }
.hist-warn { color:var(--peach); }
#hist-signal-res {
font-size:10px; font-family:monospace; color:var(--subtext0);
max-height:120px; overflow-y:auto;
border-top:1px solid var(--surface0); border-bottom:1px solid var(--surface0);
padding:5px 0;
}
.hist-res-row { display:flex; justify-content:space-between; gap:8px; }
.hist-res-row .hist-res-key {
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--subtext1);
}
.hist-res-row .hist-res-val { color:var(--mauve); flex-shrink:0; }
/* ── Signal style context menu ────────────────────────────────── */ /* ── Signal style context menu ────────────────────────────────── */
#sig-ctx-menu { #sig-ctx-menu {
position:fixed; z-index:300; position:fixed; z-index:300;
@@ -0,0 +1,168 @@
package wshub
import (
"encoding/binary"
"math"
"testing"
)
// decodeCaptureSpan pulls the time extent of one signal out of a v2 frame.
func decodeCaptureSpan(t *testing.T, buf []byte, key string) (first, last float64, n int) {
t.Helper()
off := 1 + 8 + 8 + 8
nSig := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
for i := 0; i < nSig; i++ {
kl := int(binary.LittleEndian.Uint16(buf[off:]))
off += 2
k := string(buf[off : off+kl])
off += kl
cnt := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
if k == key && cnt > 0 {
first = math.Float64frombits(binary.LittleEndian.Uint64(buf[off:]))
last = math.Float64frombits(binary.LittleEndian.Uint64(buf[off+(cnt-1)*8:]))
n = cnt
}
off += cnt * 16
}
return
}
// The rings only reach back over the window once they have rolled over at the
// current bucket, which takes as long as the window itself — so a window widened
// mid-run leaves the first captures asking for history the rings never stored.
// The archive kept it, and the capture must come back whole.
func TestCaptureBackfillsItsHeadFromTheArchive(t *testing.T) {
h := NewHub()
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 60, Decimation: 1, MinDiskFreeMB: -1,
}, 1000)
h.hist = hw
// 20 s of 1 kSps, archived in full…
ts, vs := ramp(1000, 0.001, 20000)
hw.write(key, ts, vs)
// …but a ring that only ever holds the last 5 s of it.
rb := newSigRing(5000)
rb.write(ts, vs)
h.rings[key] = rb
// A 15 s window, of which the ring has the newest third.
const t0, t1 = 1005.0, 1020.0
buf := h.buildTriggerCapture(1015, 10, 5)
if buf == nil {
t.Fatal("no capture frame built")
}
first, last, n := decodeCaptureSpan(t, buf, key)
if first > t0+0.05 {
t.Errorf("capture starts at %.3f, want the window's start %.3f — the archive holds it",
first, t0)
}
if last < t1-0.05 {
t.Errorf("capture ends at %.3f, want %.3f", last, t1)
}
if n < 100 {
t.Errorf("capture has %d points, too few for a 15 s window at 1 kSps", n)
}
// The join between the two sources must not break time order, or every
// binary search over the capture — client-side and in the hold — misreads it.
ct, _, ok := h.capture.slice(key, t0, t1)
if !ok {
t.Fatal("the hold declined the window it just published")
}
for i := 1; i < len(ct); i++ {
if ct[i] < ct[i-1] {
t.Fatalf("capture time goes backwards at %d: %.6f then %.6f", i, ct[i-1], ct[i])
}
}
}
// A capture that neither source could fill must not answer for the stretch it is
// missing: the client has to fall through to the archive instead of redrawing
// the same hole on every zoom.
func TestHoldDeclinesTheStretchACaptureNeverGot(t *testing.T) {
h := NewHub()
ts, vs := ramp(1000, 0.001, 20000)
rb := newSigRing(5000) // the newest 5 s only, and no archive to fill from
rb.write(ts, vs)
h.rings["src:sig"] = rb
if buf := h.buildTriggerCapture(1015, 10, 5); buf == nil {
t.Fatal("no capture frame built")
}
if _, _, ok := h.capture.slice("src:sig", 1005, 1020); ok {
t.Error("the hold answered for 15 s it only has the last 5 s of")
}
// What it does hold, it still serves.
if _, _, ok := h.capture.slice("src:sig", 1016, 1019); !ok {
t.Error("the hold declined a range well inside its data")
}
}
// TestCaptureCoverageAcrossShots walks a whole acquisition the way Run() does —
// ingest, retune, dueCapture, rearm — and reports how much of each window the
// capture actually came back with.
func TestCaptureCoverageAcrossShots(t *testing.T) {
const (
key = "s1:Ch1"
rate = 100e3 // scaled 10x down from the 1 MSps producer
budget = 400_000
window = 120.0
prePct = 20.0
batchSec = 1.0 / 30.0
simSec = 900.0
)
h := NewHub()
h.SetRingBudget(budget)
h.rings[key] = newSigRing(ringCapInitial)
h.trigger.SetConfig(trigConfig{signalKey: key, edge: "rising", threshold: 0,
windowSec: window, prePercent: prePct, mode: "normal", holdoffSec: 0.2})
rateHz := float64(rate)
nBatch := int(rateHz * batchSec)
ts := make([]float64, nBatch)
vs := make([]float64, nBatch)
armed := false
shots := 0
for now := 0.0; now < simSec; now += batchSec {
for i := range ts {
ts[i] = now + float64(i)/rateHz
// 0.05 Hz sine: one rising zero crossing every 20 s.
vs[i] = math.Sin(2 * math.Pi * 0.05 * ts[i])
}
h.ingest(key, 1, ts, vs)
h.retuneRings(now)
// Arm once the stream is going, as a user would.
if !armed && now > 5 {
h.trigger.Arm()
armed = true
}
if trigTime, pre, post, ok := h.trigger.dueCapture(now + batchSec); ok {
buf := h.buildTriggerCapture(trigTime, pre, post)
if buf == nil {
t.Fatalf("shot at t=%.1f produced no frame", trigTime)
}
first, last, n := decodeCaptureSpan(t, buf, key)
t0, t1 := trigTime-pre, trigTime+post
_, ringSpan := h.rings[key].stats()
shots++
t.Logf("shot %d fired t=%.1f window [%.1f,%.1f] got [%.1f,%.1f] "+
"= %.0f%% (%d pts, bucket %d, ring span %.1f s)",
shots, trigTime, t0, t1, first, last,
100*(last-first)/(t1-t0), n, h.rings[key].bucketSize(), ringSpan)
h.trigger.markTriggered(now + batchSec)
} else if h.trigger.dueRearm(now + batchSec) {
h.trigger.Arm()
}
}
if shots < 3 {
t.Fatalf("only %d shots in %.0f s", shots, simSec)
}
}
+83
View File
@@ -0,0 +1,83 @@
package wshub
import (
"sort"
"sync"
)
// capturedWindow is one delivered trigger capture, held at the resolution the
// rings had when it was taken. Nothing mutates it after publication, so readers
// may sub-slice it without copying.
type capturedWindow struct {
t0, t1 float64
sigs map[string]sigData
}
// captureHold is the read half of the trigger double buffer; the rings are the
// write half.
//
// The rings keep rolling while the trigger re-arms and collects the next shot,
// so within seconds of a capture they no longer hold the window the user is
// looking at — a zoom into it came back with only the newest sliver, or with
// nothing. Publishing the window here at capture time gives the viewer a
// snapshot that the re-arming acquisition cannot overwrite: the swap happens
// only when the *next* capture is complete, which is also the moment the client
// stops displaying this one.
type captureHold struct {
mu sync.RWMutex
cur *capturedWindow
}
// publish swaps in a new capture, retiring the previous one. Readers that
// already hold a pointer to the retired window keep reading it safely.
func (ch *captureHold) publish(t0, t1 float64, sigs map[string]sigData) {
if len(sigs) == 0 {
return
}
w := &capturedWindow{t0: t0, t1: t1, sigs: sigs}
ch.mu.Lock()
ch.cur = w
ch.mu.Unlock()
}
// clear drops the held capture, releasing its memory.
func (ch *captureHold) clear() {
ch.mu.Lock()
ch.cur = nil
ch.mu.Unlock()
}
// slice answers [a, b] for one signal out of the held capture, reporting
// whether it could.
//
// It declines any range reaching outside the captured window: that is a live
// zoom or a pan off the capture, and only the rings still track the stream.
// Inside the window the hold is never worse than the rings — retuning does not
// rewrite stored samples, so a ring that still covers the range holds the very
// same points — which is why no trigger-state gating is needed here.
func (ch *captureHold) slice(key string, a, b float64) ([]float64, []float64, bool) {
ch.mu.RLock()
w := ch.cur
ch.mu.RUnlock()
if w == nil || a < w.t0 || b > w.t1 {
return nil, nil, false
}
sd, ok := w.sigs[key]
if !ok || len(sd.T) == 0 {
return nil, nil, false
}
// The window is what was asked for; this signal's samples are what could be
// found. A capture whose front was never recoverable must not answer for the
// stretch it is missing — the client would redraw the same hole on every
// zoom and every "fit" instead of falling back to the archive.
tol := shortCaptureTol * (w.t1 - w.t0)
if sd.T[0] > a+tol || sd.T[len(sd.T)-1] < b-tol {
return nil, nil, false
}
lo := sort.SearchFloat64s(sd.T, a)
hi := lo + sort.Search(len(sd.T)-lo, func(i int) bool { return sd.T[lo+i] > b })
if hi <= lo {
return nil, nil, false
}
return sd.T[lo:hi], sd.V[lo:hi], true
}
+128
View File
@@ -0,0 +1,128 @@
package wshub
import "testing"
func heldRamp(t0, dt float64, n int) sigData {
sd := sigData{T: make([]float64, n), V: make([]float64, n)}
for i := range sd.T {
sd.T[i] = t0 + float64(i)*dt
sd.V[i] = float64(i)
}
return sd
}
func TestCaptureHoldServesRangesInsideTheWindow(t *testing.T) {
var ch captureHold
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
gt, gv, ok := ch.slice("s1:sig", 2, 3)
if !ok {
t.Fatal("held capture declined a range inside its window")
}
if gt[0] < 2 || gt[len(gt)-1] > 3 {
t.Fatalf("range %v..%v escapes the request 2..3", gt[0], gt[len(gt)-1])
}
if len(gt) != len(gv) {
t.Fatalf("t/v length mismatch: %d vs %d", len(gt), len(gv))
}
if gv[0] != 20 {
t.Fatalf("first value %v, want the sample at t=2", gv[0])
}
}
// A range poking outside the capture is a live zoom: only the rings still track
// the stream, so the hold must stand aside rather than answer a clipped range.
func TestCaptureHoldDeclinesRangesOutsideTheWindow(t *testing.T) {
var ch captureHold
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
for _, r := range [][2]float64{{-1, 5}, {5, 11}, {20, 30}, {-5, -1}} {
if _, _, ok := ch.slice("s1:sig", r[0], r[1]); ok {
t.Fatalf("held capture answered %v..%v, which is not inside 0..10", r[0], r[1])
}
}
if _, _, ok := ch.slice("other:sig", 2, 3); ok {
t.Fatal("held capture answered for a signal it does not hold")
}
}
func TestCaptureHoldZeroValueAndClearDecline(t *testing.T) {
var ch captureHold
if _, _, ok := ch.slice("s1:sig", 0, 1); ok {
t.Fatal("empty hold answered a request")
}
ch.publish(0, 10, map[string]sigData{"s1:sig": heldRamp(0, 0.1, 101)})
ch.clear()
if _, _, ok := ch.slice("s1:sig", 2, 3); ok {
t.Fatal("cleared hold still answered a request")
}
}
// The point of the double buffer: the window a client is exploring survives the
// re-armed acquisition rolling the rings past it, and is replaced only when the
// next shot completes.
func TestZoomIntoACaptureSurvivesTheRingRollingPast(t *testing.T) {
h := NewHub()
rb := newSigRing(4000)
h.rings["s1:sig"] = rb
// 2 s of 1 kSps, then fire a trigger over [0.5, 1.5].
ts, vs := make([]float64, 2000), make([]float64, 2000)
for i := range ts {
ts[i], vs[i] = float64(i)*1e-3, float64(i)
}
rb.write(ts, vs)
if msg := h.buildTriggerCapture(1.0, 0.5, 0.5); msg == nil {
t.Fatal("buildTriggerCapture produced no frame")
}
// The trigger re-arms and the stream runs on until the captured window has
// been overwritten several times over.
for pass := 0; pass < 5; pass++ {
for i := range ts {
ts[i] += 2.0
}
rb.write(ts, vs)
}
if rt, _ := rb.slice(0.5, 1.5); len(rt) != 0 {
t.Fatalf("ring still holds %d points of the captured window; the test is not exercising the hold", len(rt))
}
got := h.zoomSlice(0.8, 0.9, []string{"s1:sig"}, 1<<30)
sd, ok := got["s1:sig"]
if !ok {
t.Fatal("zoom into the held capture returned nothing")
}
if len(sd.T) != 101 {
t.Fatalf("zoom returned %d points, want the 101 samples in 0.8..0.9", len(sd.T))
}
if sd.V[0] != 800 || sd.V[len(sd.V)-1] != 900 {
t.Fatalf("zoom returned values %v..%v, want 800..900", sd.V[0], sd.V[len(sd.V)-1])
}
// A live zoom outside the held window still reaches the rings.
if live := h.zoomSlice(11.0, 11.1, []string{"s1:sig"}, 1<<30); len(live["s1:sig"].T) == 0 {
t.Fatal("live zoom outside the capture was swallowed by the hold")
}
}
// A shot that yields nothing must not blank the window already on screen.
func TestEmptyCaptureKeepsThePreviousHold(t *testing.T) {
h := NewHub()
rb := newSigRing(4000)
h.rings["s1:sig"] = rb
ts, vs := make([]float64, 2000), make([]float64, 2000)
for i := range ts {
ts[i], vs[i] = float64(i)*1e-3, float64(i)
}
rb.write(ts, vs)
h.buildTriggerCapture(1.0, 0.5, 0.5)
// A window the rings have no samples for at all.
if msg := h.buildTriggerCapture(500.0, 0.5, 0.5); msg != nil {
t.Fatal("capture of an empty window produced a frame")
}
if _, _, ok := h.capture.slice("s1:sig", 0.8, 0.9); !ok {
t.Fatal("empty capture dropped the previously held window")
}
}
File diff suppressed because it is too large Load Diff
+949
View File
@@ -0,0 +1,949 @@
package wshub
import (
"encoding/binary"
"math"
"os"
"path/filepath"
"testing"
"marte2/common/udpsprotocol"
)
// newTestHistory opens a writer in a temp dir with one signal file of the given
// declared rate, and returns the writer plus that signal's key.
func newTestHistory(t *testing.T, cfg HistoryConfig, rate float64) (*historyWriter, string) {
t.Helper()
if cfg.Directory == "" {
cfg.Directory = t.TempDir()
}
hw, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: rate},
})
t.Cleanup(hw.close)
return hw, "src:sig"
}
func ramp(t0 float64, dt float64, n int) ([]float64, []float64) {
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = t0 + float64(i)*dt
vs[i] = float64(i)
}
return ts, vs
}
// A budget that cannot hold the window at full rate must buy the window by
// widening the min/max bucket, not by archiving a shorter stretch: a user
// looking at 600 s wants 600 s of it archived, coarser if need be.
func TestHistCapacityKeepsWindowByBucketing(t *testing.T) {
const mega = 1 << 20
cases := []struct {
name string
window float64
rate float64
maxPts int
wantBucket int
}{
// 60 s of 1 kSps is 60 k samples — well inside 1 MPt, so stored verbatim.
{"slow signal keeps full resolution", 60, 1000, mega, 1},
// 600 s of 1 MSps is 600 M samples against 16 Mi points: at 2 points per
// bucket and the headroom, ceil(2 × 1.25 × 600e6 / 16Mi) = 90 per bucket.
{"fast signal is enveloped", 600, 1e6, 16 * mega, 90},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
capacity, bucket := histCapacityFor(c.window, c.rate, 1, c.maxPts)
if bucket != c.wantBucket {
t.Errorf("bucket = %d, want %d", bucket, c.wantBucket)
}
if capacity > uint32(c.maxPts) {
t.Errorf("capacity %d exceeds the %d-point budget", capacity, c.maxPts)
}
// The whole window has to fit, which is the entire point.
if covered := histCoverageSec(capacity, bucket, 1, c.rate); covered < c.window {
t.Errorf("archive covers %.1f s, want the %.1f s window", covered, c.window)
}
})
}
}
// The file exists to serve the window, so it must track it: a client that widens
// what it displays must not be left reading an archive sized for the old span.
func TestHistorySetWindowResizesFiles(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 10}, 1000)
before := hw.files[key]
if before.bucket != 1 || histCoverageSec(before.capacity, 1, 1, 1000) < 10 {
t.Fatalf("initial geometry = cap %d bucket %d, want 10 s verbatim",
before.capacity, before.bucket)
}
if !hw.setWindow(600) {
t.Fatal("setWindow reported no change for a 60× wider window")
}
after := hw.files[key]
if after == before {
t.Fatal("the file was not re-created")
}
if cov := histCoverageSec(after.capacity, after.bucket, 1, 1000); cov < 600 {
t.Fatalf("archive covers %.1f s, want the new 600 s window", cov)
}
// Same window again: nothing to do, and re-creating the file would throw the
// archive away for nothing.
if hw.setWindow(600) {
t.Fatal("setWindow re-sized for an unchanged window")
}
// A nudge inside the hysteresis band must not either.
if hw.setWindow(610) {
t.Fatal("setWindow re-sized for a 2 % window change")
}
if hw.files[key] != after {
t.Fatal("the file was re-created despite the hysteresis")
}
}
// The archive is what a zoom beyond the rings reads, so a spike that only the
// archive still holds must survive being written to it.
func TestHistoryBucketedWriteKeepsPeaks(t *testing.T) {
// 1 kSps for 1 s = 1000 samples, plus headroom, into a 100-point budget →
// buckets of ceil(2 × 1.25 × 1000 / 100) = 25.
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 1, MinDiskFreeMB: -1, MaxPointsPerSignal: 100,
}, 1000)
hf := hw.files[key]
if hf.bucket != 25 {
t.Fatalf("bucket = %d, want 25", hf.bucket)
}
ts := make([]float64, 1000)
vs := make([]float64, 1000)
for i := range ts {
ts[i] = float64(i) * 0.001
}
vs[137] = 7.5 // a one-sample positive spike
vs[500] = -3.5 // and a negative one
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 0, 1, 1000)
if len(rt) == 0 {
t.Fatal("nothing archived")
}
hi, lo := false, false
for i := range rv {
if rv[i] == 7.5 && rt[i] == ts[137] {
hi = true
}
if rv[i] == -3.5 && rt[i] == ts[500] {
lo = true
}
}
if !hi || !lo {
t.Errorf("archive lost a spike (positive kept=%v, negative kept=%v)", hi, lo)
}
// A partial bucket is not written until it completes, so the last few
// samples may be missing; everything before them must be there.
if hf.count == 0 || hf.count > hf.capacity {
t.Errorf("archived %d points into a %d-point file", hf.count, hf.capacity)
}
}
func TestHistoryDisabledWithoutDirectory(t *testing.T) {
hw, err := newHistoryWriter(HistoryConfig{})
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
if hw != nil {
t.Fatal("empty Directory must disable history")
}
// Every method must stay usable on the nil writer, which is how the hub
// avoids guarding each call site.
if hw.enabled() {
t.Fatal("nil writer reports enabled")
}
hw.write("src:sig", []float64{1}, []float64{1})
hw.flushHeaders()
hw.close()
if rt, _ := hw.readRange("src:sig", 0, 1, 10); rt != nil {
t.Fatal("nil writer returned data")
}
if len(hw.info()) != 0 {
t.Fatal("nil writer returned info entries")
}
}
func TestHistoryWriteReadRoundTrip(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
ts, vs := ramp(10, 0.01, 500)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 10.5, 11.0, 10000)
if len(rt) != 51 { // inclusive both ends, 0.01 s spacing
t.Fatalf("read %d points, want 51", len(rt))
}
if rt[0] < 10.5-1e-9 || rt[len(rt)-1] > 11.0+1e-9 {
t.Fatalf("range [%v, %v] escapes the request", rt[0], rt[len(rt)-1])
}
for i := range rt {
wantV := math.Round((rt[i] - 10) / 0.01)
if math.Abs(rv[i]-wantV) > 1e-6 {
t.Fatalf("point %d: value %v, want %v", i, rv[i], wantV)
}
}
}
func TestHistoryReadRangeOutsideDataIsEmpty(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
ts, vs := ramp(10, 0.01, 100)
hw.write(key, ts, vs)
if rt, _ := hw.readRange(key, 100, 200, 1000); len(rt) != 0 {
t.Fatalf("read %d points past the newest sample", len(rt))
}
if rt, _ := hw.readRange(key, 0, 5, 1000); len(rt) != 0 {
t.Fatalf("read %d points before the oldest sample", len(rt))
}
if rt, _ := hw.readRange("src:missing", 10, 11, 1000); rt != nil {
t.Fatal("unknown key returned data")
}
if rt, _ := hw.readRange(key, 11, 10, 1000); rt != nil {
t.Fatal("inverted range returned data")
}
}
// Once the file has wrapped, the oldest samples must be gone and the retained
// window must still read back contiguously across the wrap point.
func TestHistoryWrapAround(t *testing.T) {
// A sub-second window at 1 Sps sizes below the 1000-pair floor, which is a
// cheap capacity to wrap.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
hf := hw.files[key]
if hf.capacity != histMinCapacity {
t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity)
}
// 2.5 fills, in batches that do not align with the capacity so the wrap
// lands mid-batch.
total := 2500
ts, vs := ramp(0, 1, total)
for i := 0; i < total; i += 333 {
end := i + 333
if end > total {
end = total
}
hw.write(key, ts[i:end], vs[i:end])
}
if hf.count != histMinCapacity {
t.Fatalf("count = %d, want a full %d", hf.count, histMinCapacity)
}
wantOldest := float64(total - histMinCapacity)
if hf.tOldest != wantOldest {
t.Fatalf("tOldest = %v, want %v", hf.tOldest, wantOldest)
}
if hf.tNewest != float64(total-1) {
t.Fatalf("tNewest = %v, want %v", hf.tNewest, float64(total-1))
}
rt, rv := hw.readRange(key, wantOldest, float64(total-1), 10000)
if len(rt) != histMinCapacity {
t.Fatalf("read %d points, want the full %d", len(rt), histMinCapacity)
}
for i := range rt {
want := wantOldest + float64(i)
if rt[i] != want || rv[i] != want {
t.Fatalf("point %d = (%v, %v), want (%v, %v)", i, rt[i], rv[i], want, want)
}
}
// The evicted samples must not come back.
if et, _ := hw.readRange(key, 0, wantOldest-1, 10000); len(et) != 0 {
t.Fatalf("read %d evicted points", len(et))
}
}
// A single batch larger than the file keeps its tail, not its head.
func TestHistoryOversizedBatchKeepsTail(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
ts, vs := ramp(0, 1, 3000)
hw.write(key, ts, vs)
hf := hw.files[key]
if hf.count != histMinCapacity {
t.Fatalf("count = %d, want %d", hf.count, histMinCapacity)
}
if hf.tNewest != 2999 {
t.Fatalf("tNewest = %v, want 2999", hf.tNewest)
}
rt, _ := hw.readRange(key, 2000, 2999, 10000)
if len(rt) != histMinCapacity || rt[0] != 2000 {
t.Fatalf("retained window starts at %v with %d points, want 2000 / %d",
rt[0], len(rt), histMinCapacity)
}
}
func TestHistoryDecimation(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{Decimation: 4}, 100)
// Two batches, so the decimation phase must carry across the call boundary
// rather than restarting.
ts, vs := ramp(0, 0.01, 100)
hw.write(key, ts[:37], vs[:37])
hw.write(key, ts[37:], vs[37:])
rt, _ := hw.readRange(key, -1, 1e9, 10000)
if len(rt) != 25 {
t.Fatalf("kept %d of 100 points at decimation 4, want 25", len(rt))
}
for i := 1; i < len(rt); i++ {
if d := rt[i] - rt[i-1]; math.Abs(d-0.04) > 1e-9 {
t.Fatalf("spacing at %d = %v, want 0.04", i, d)
}
}
}
// The input slices are shared with the zoom ring and the trigger, so decimation
// must not touch them.
func TestHistoryWriteDoesNotMutateInput(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{Decimation: 3}, 100)
ts, vs := ramp(0, 0.01, 30)
tCopy := append([]float64(nil), ts...)
vCopy := append([]float64(nil), vs...)
hw.write(key, ts, vs)
for i := range ts {
if ts[i] != tCopy[i] || vs[i] != vCopy[i] {
t.Fatalf("write mutated input at %d", i)
}
}
}
// Reopening the same directory must pick the file back up with its contents,
// which is the whole point of persisting the header.
func TestHistoryReopenPreservesData(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 0.36}
sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}}
hw, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("newHistoryWriter: %v", err)
}
hw.onSourceConfigured("src", sigs)
ts, vs := ramp(0, 1, 400)
hw.write("src:sig", ts, vs)
hw.close()
hw2, err := newHistoryWriter(cfg)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer hw2.close()
hw2.onSourceConfigured("src", sigs)
hf := hw2.files["src:sig"]
if hf.count != 400 || hf.head != 400 {
t.Fatalf("reopened count=%d head=%d, want 400/400", hf.count, hf.head)
}
rt, rv := hw2.readRange("src:sig", 100, 199, 10000)
if len(rt) != 100 || rt[0] != 100 || rv[0] != 100 {
t.Fatalf("reopened read = %d points starting (%v, %v)", len(rt), rt[0], rv[0])
}
// Appending after the reopen must continue where the file left off.
ts2, vs2 := ramp(400, 1, 50)
hw2.write("src:sig", ts2, vs2)
if hf.tNewest != 449 {
t.Fatalf("tNewest after append = %v, want 449", hf.tNewest)
}
}
// A file sized for a different rate cannot be reused, so it must be recreated
// rather than reopened with a mismatched capacity.
func TestHistoryReopenWithDifferentCapacityRecreates(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 3600}
hw, _ := newHistoryWriter(cfg)
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 10},
})
firstCap := hw.files["src:sig"].capacity
hw.write("src:sig", []float64{1, 2}, []float64{1, 2})
hw.close()
hw2, _ := newHistoryWriter(cfg)
defer hw2.close()
hw2.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 100}, // 10× the rate
})
hf := hw2.files["src:sig"]
if hf.capacity == firstCap {
t.Fatalf("capacity unchanged at %d despite a 10x rate change", firstCap)
}
if hf.count != 0 {
t.Fatalf("recreated file kept %d samples", hf.count)
}
}
// A corrupt header must not be trusted: the file gets rebuilt instead.
func TestHistoryCorruptHeaderRecreates(t *testing.T) {
dir := t.TempDir()
cfg := HistoryConfig{Directory: dir, WindowSec: 0.36}
sigs := []udpsprotocol.SignalInfo{{Name: "sig", TypeCode: 8, SamplingRate: 1}}
hw, _ := newHistoryWriter(cfg)
hw.onSourceConfigured("src", sigs)
hw.write("src:sig", []float64{1, 2, 3}, []float64{1, 2, 3})
hw.close()
path := filepath.Join(dir, "src", "sig.shist")
f, err := os.OpenFile(path, os.O_RDWR, 0o644)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := f.WriteAt([]byte("XXXX"), 0); err != nil { // clobber the magic
t.Fatalf("clobber: %v", err)
}
f.Close()
hw2, _ := newHistoryWriter(cfg)
defer hw2.close()
hw2.onSourceConfigured("src", sigs)
if got := hw2.files["src:sig"].count; got != 0 {
t.Fatalf("count = %d, want a recreated empty file", got)
}
}
// Raising the budget from the UI has to buy resolution: same duration, a
// narrower min/max bucket. Lowering it again must not overrun the new budget.
func TestSetBudgetRebucketsAtTheSameDuration(t *testing.T) {
// 100 s of 100 kSps is 10 M samples, well past either budget.
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 100, MaxPointsPerSignal: 100_000,
}, 1e5)
before := hw.files[key]
if before.bucket <= 1 {
t.Fatalf("bucket = %d, want the signal enveloped to fit the budget", before.bucket)
}
if got := hw.setBudget(1_000_000); got != 1_000_000 {
t.Fatalf("setBudget = %d, want 1000000", got)
}
after := hw.files[key]
if after == before {
t.Fatal("the file was not re-created")
}
if after.bucket >= before.bucket {
t.Fatalf("bucket %d → %d, want a finer envelope for a 10× budget",
before.bucket, after.bucket)
}
if after.capacity > 1_000_000 {
t.Fatalf("capacity = %d, over the 1 MPts budget", after.capacity)
}
// The point of the envelope: the duration is covered whatever the budget.
if cov := float64(after.capacity) * float64(after.bucket) / 2 / 1e5; cov < 99 {
t.Fatalf("coverage = %.1f s, want ~100 s", cov)
}
if got := hw.setBudget(100_000); got != 100_000 {
t.Fatalf("setBudget back = %d, want 100000", got)
}
if c := hw.files[key].capacity; c > 100_000 {
t.Fatalf("capacity = %d, over the restored 100 kPts budget", c)
}
}
// A budget that leaves a signal's geometry alone must leave its archive alone
// too — re-creating files nobody asked to resize would throw away history.
func TestSetBudgetKeepsUnaffectedFiles(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{
WindowSec: 1, MaxPointsPerSignal: 16 << 20,
}, 1000)
ts, vs := ramp(0, 0.001, 100)
hw.write(key, ts, vs)
hw.setBudget(8 << 20) // still far more than the 1000 points this signal needs
hf := hw.files[key]
if hf.bucket != 1 {
t.Fatalf("bucket = %d, want the slow signal still archived verbatim", hf.bucket)
}
if hf.count != 100 {
t.Fatalf("count = %d, want the 100 archived samples kept", hf.count)
}
}
// Time-reference signals are the clock for the others, so archiving them would
// just waste disk.
func TestHistorySkipsTimeSignals(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "TimeArray", TypeCode: histTypeCodeUint64, SamplingRate: 1000},
{Name: "data", TypeCode: 8, SamplingRate: 1000},
})
if _, ok := hw.files["src:TimeArray"]; ok {
t.Fatal("uint64 time signal was archived")
}
if _, ok := hw.files["src:data"]; !ok {
t.Fatal("data signal was not archived")
}
}
// A second CONFIG for the same source must not throw away the history already
// collected for signals it re-declares.
func TestHistoryReconfigureKeepsExistingFile(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
before := hw.files[key]
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 1},
{Name: "sig2", TypeCode: 8, SamplingRate: 1},
})
if hw.files[key] != before {
t.Fatal("re-CONFIG replaced the existing signal file")
}
if before.count != 3 {
t.Fatalf("count = %d, want the 3 already written", before.count)
}
if _, ok := hw.files["src:sig2"]; !ok {
t.Fatal("newly declared signal was not opened")
}
}
// The C++ UDPStreamer declares samplingRate=0, so sizing the file on the spot
// would use a guess that is three orders of magnitude out at 1 MSps.
func TestHistoryDefersSignalsWithoutDeclaredRate(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "fast", TypeCode: 8, SamplingRate: 0},
{Name: "known", TypeCode: 8, SamplingRate: 100},
})
if _, ok := hw.files["src:fast"]; ok {
t.Fatal("undeclared-rate signal was sized before its rate was measured")
}
if got := hw.pendingKeys(); len(got) != 1 || got[0] != "src:fast" {
t.Fatalf("pendingKeys = %v, want [src:fast]", got)
}
if _, ok := hw.files["src:known"]; !ok {
t.Fatal("declared-rate signal was deferred")
}
// Data for a deferred signal is dropped, not misfiled.
hw.write("src:fast", []float64{1}, []float64{1})
// A repeated CONFIG must not queue it twice.
hw.onSourceConfigured("src", []udpsprotocol.SignalInfo{
{Name: "fast", TypeCode: 8, SamplingRate: 0},
})
if got := hw.pendingKeys(); len(got) != 1 {
t.Fatalf("pendingKeys = %v after re-CONFIG, want one entry", got)
}
if !hw.openPending("src:fast", 100000) {
t.Fatal("openPending refused a measured rate")
}
hf, ok := hw.files["src:fast"]
if !ok {
t.Fatal("file not opened after the rate was measured")
}
// The default window × 100 kSps, enveloped if it does not fit the budget.
wantCap, wantBucket := histCapacityFor(defaultLiveWindowSec, 100000, 1, histDefaultMaxPoints)
if hf.capacity != wantCap || hf.bucket != wantBucket {
t.Fatalf("capacity/bucket = %d/%d, want %d/%d", hf.capacity, hf.bucket, wantCap, wantBucket)
}
if len(hw.pendingKeys()) != 0 {
t.Fatal("signal still pending after being opened")
}
if hw.openPending("src:fast", 100000) {
t.Fatal("openPending reopened an already-open signal")
}
}
func TestOpenPendingHistoryFilesUsesMeasuredRate(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir(), WindowSec: 3.6}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
defer h.CloseHistory()
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 0},
})
rb := newSigRing(200000)
h.rings["s1:sig"] = rb
// Too little data to measure a rate from: the sweep must wait rather than
// size the file from a burst.
fillRing(rb, 0, 100000, 100) // 1 ms of data
h.openPendingHistoryFiles(100)
if len(h.hist.pendingKeys()) != 1 {
t.Fatal("sweep sized the file from a sub-millisecond sample")
}
fillRing(rb, 0, 100000, 100000) // 1 s at 100 kSps
h.openPendingHistoryFiles(200)
hf, ok := h.hist.files["s1:sig"]
if !ok {
t.Fatal("file not opened once the rate was measurable")
}
// 3.6 s at ~100 kSps, plus headroom, ≈ 450 000 pairs; a fixed 1 kHz guess
// would have produced the 1000-sample floor instead.
if hf.capacity < 400_000 || hf.capacity > 500_000 {
t.Fatalf("capacity = %d, want ~450000 from the measured 100 kSps", hf.capacity)
}
}
func TestOpenPendingHistoryFilesIsThrottled(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{Directory: t.TempDir()}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
defer h.CloseHistory()
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 0},
})
h.openPendingHistoryFiles(100) // no ring yet: nothing to measure
rb := newSigRing(20000)
fillRing(rb, 0, 1000, 20000)
h.rings["s1:sig"] = rb
h.openPendingHistoryFiles(100.5)
if len(h.hist.files) != 0 {
t.Fatal("sweep ran inside the throttle window")
}
h.openPendingHistoryFiles(200)
if len(h.hist.files) != 1 {
t.Fatal("sweep did not run after the throttle window elapsed")
}
}
// A hub without history must tolerate the sweep, since Run() calls it every tick.
func TestOpenPendingHistoryFilesNoopWithoutHistory(t *testing.T) {
h := NewHub()
h.openPendingHistoryFiles(100)
}
func TestHistoryInfoShape(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 0.36}, 1)
// Reported before any data arrives, so clients can enable their history UI.
inf := hw.info()
if e, ok := inf[key]; !ok || e.Count != 0 || e.Capacity != histMinCapacity {
t.Fatalf("pre-data info = %+v (present=%v)", inf[key], ok)
}
ts, vs := ramp(5, 1, 10)
hw.write(key, ts, vs)
e := hw.info()[key]
if e.Count != 10 || e.T0 != 5 || e.T1 != 14 {
t.Fatalf("info = %+v, want count=10 t0=5 t1=14", e)
}
}
func TestHistoryHeaderIsPersistedOnFlush(t *testing.T) {
dir := t.TempDir()
hw, key := newTestHistory(t, HistoryConfig{Directory: dir, WindowSec: 0.36, Decimation: 2}, 1)
ts, vs := ramp(0, 1, 20)
hw.write(key, ts, vs)
hw.flushHeaders()
hdr, err := os.ReadFile(filepath.Join(dir, "src", "sig.shist"))
if err != nil {
t.Fatalf("read: %v", err)
}
if string(hdr[0:4]) != "SHR1" {
t.Fatalf("magic = %q", hdr[0:4])
}
if v := binary.LittleEndian.Uint32(hdr[4:]); v != histVersion {
t.Fatalf("version = %d, want %d", v, histVersion)
}
if c := binary.LittleEndian.Uint32(hdr[8:]); c != histMinCapacity {
t.Fatalf("capacity = %d, want %d", c, histMinCapacity)
}
if h := binary.LittleEndian.Uint32(hdr[12:]); h != 10 {
t.Fatalf("head = %d, want 10 (20 samples, decimation 2)", h)
}
if n := binary.LittleEndian.Uint32(hdr[16:]); n != 10 {
t.Fatalf("count = %d, want 10", n)
}
if d := binary.LittleEndian.Uint32(hdr[20:]); d != 2 {
t.Fatalf("decimation = %d, want 2", d)
}
if got := math.Float64frombits(binary.LittleEndian.Uint64(hdr[32:])); got != 19 {
t.Fatalf("tNewest = %v, want 19", got)
}
// The data region must be pre-allocated in full, not grown as it fills.
if want := int64(histHeaderSize) + histMinCapacity*histPairSize; int64(len(hdr)) != want {
t.Fatalf("file size = %d, want the pre-allocated %d", len(hdr), want)
}
}
func TestSanitizeHistName(t *testing.T) {
cases := map[string]string{
"Signal_1": "Signal_1",
"GAM.Out[0]": "GAM.Out[0]",
"a/b": "a_b",
"../../etc/pass": ".._.._etc_pass",
"": "_",
".": "_",
"..": "_",
"with space": "with_space",
"nul\x00byte": "nul_byte",
}
for in, want := range cases {
if got := sanitizeHistName(in); got != want {
t.Errorf("sanitizeHistName(%q) = %q, want %q", in, got, want)
}
}
}
// A producer-supplied name must never place a file outside the history dir.
func TestHistoryNameCannotEscapeDirectory(t *testing.T) {
dir := t.TempDir()
hw, _ := newHistoryWriter(HistoryConfig{Directory: dir})
defer hw.close()
hw.onSourceConfigured("../evil", []udpsprotocol.SignalInfo{
{Name: "../../pwned", TypeCode: 8, SamplingRate: 1},
})
found := false
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
found = true
}
return err
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if !found {
t.Fatal("no file created inside the history directory")
}
if _, err := os.Stat(filepath.Join(dir, "..", "..", "pwned.shist")); err == nil {
t.Fatal("a file escaped the history directory")
}
}
func TestHistCapacityFor(t *testing.T) {
cases := []struct {
window float64
rate float64
decim int
maxPts int
want uint32
wantBucket int
}{
// window × rate / decimation, plus the 1.25 headroom.
{600, 1000, 1, 0, 750_000, 1},
{600, 1000, 10, 0, 75_000, 1},
{10, 100, 1, 0, 1250, 1},
{600, 0.001, 1, 0, histMinCapacity, 1}, // absurdly slow → the floor
// Absurdly fast: bounded by histMaxCapacity, and the window is bought with
// a correspondingly absurd bucket rather than by storing less of it.
{600, 1e9, 1, 0, 1_073_729_421, 1397},
{math.NaN(), 1000, 1, 0, histMinCapacity, 1},
{600, math.NaN(), 1, 0, histMinCapacity, 1},
// A budget envelopes a fast signal without touching a slow one, and the
// window is kept either way.
{600, 1e6, 1, 16 << 20, 16_666_667, 90},
{600, 1000, 1, 16 << 20, 750_000, 1},
}
for _, c := range cases {
got, bucket := histCapacityFor(c.window, c.rate, c.decim, c.maxPts)
if got != c.want || bucket != c.wantBucket {
t.Errorf("histCapacityFor(%v, %v, %d, %d) = %d/%d, want %d/%d",
c.window, c.rate, c.decim, c.maxPts, got, bucket, c.want, c.wantBucket)
}
}
}
func TestHistoryConfigDefaults(t *testing.T) {
c := HistoryConfig{}.withDefaults()
if c.WindowSec != defaultLiveWindowSec || c.Decimation != 1 || c.FlushIntervalSec != 5 || c.MinDiskFreeMB != 500 {
t.Fatalf("defaults = %+v", c)
}
// A negative value is the explicit "no disk guard", so it must survive
// defaulting rather than being turned back into 500.
if got := (HistoryConfig{MinDiskFreeMB: -1}).withDefaults().MinDiskFreeMB; got != -1 {
t.Fatalf("MinDiskFreeMB = %d, want the -1 that disables the guard", got)
}
}
func TestHistoryWritePausedWhenDiskLow(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 100)
hw.diskLow = true
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
if hw.files[key].count != 0 {
t.Fatalf("count = %d, want 0 while the disk guard is tripped", hw.files[key].count)
}
hw.diskLow = false
hw.write(key, []float64{1, 2, 3}, []float64{1, 2, 3})
if hw.files[key].count != 3 {
t.Fatalf("count = %d, want 3 once writing resumes", hw.files[key].count)
}
}
func TestHistoryReadRangeRespectsMaxOut(t *testing.T) {
// A window wide enough that the whole ramp is still on disk when it is read.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, -1, 1e9, 100)
if len(rt) != 100 || len(rv) != 100 {
t.Fatalf("read %d/%d points, want the 100 cap", len(rt), len(rv))
}
}
func TestHistoryReadRangeSpansWholeRange(t *testing.T) {
// A capped read must thin the range out, not return its first maxOut
// samples: a client asking for 100 points over 50 s and getting the first
// second of it draws a flat line and falls back to its coarse copy.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, _ := hw.readRange(key, 0, 49.99, 100)
if len(rt) == 0 {
t.Fatal("no points read")
}
if got := rt[len(rt)-1] - rt[0]; got < 0.95*49.99 {
t.Fatalf("read spans %.2f s of the 49.99 s asked; a capped read must "+
"cover the whole range", got)
}
}
func TestHistoryReadRangeUncappedIsExact(t *testing.T) {
// Below the cap every sample in the range comes back, so a zoom deep enough
// to fit is served at full resolution.
hw, key := newTestHistory(t, HistoryConfig{WindowSec: 50}, 100)
ts, vs := ramp(0, 0.01, 5000)
hw.write(key, ts, vs)
rt, rv := hw.readRange(key, 1, 1.99, 1000)
if len(rt) != 100 {
t.Fatalf("read %d points, want the 100 samples in [1, 1.99]", len(rt))
}
if rv[0] != 100 || rv[len(rv)-1] != 199 {
t.Fatalf("values %.0f..%.0f, want 100..199", rv[0], rv[len(rv)-1])
}
}
// The capture copy is what makes a trigger window zoomable long after the
// circular archive has wrapped over it.
func TestCaptureRangeOutlivesTheArchive(t *testing.T) {
hw, key := newTestHistory(t, HistoryConfig{}, 0.001) // floor capacity: 1000
hf := hw.files[key]
if hf.capacity != histMinCapacity {
t.Fatalf("capacity = %d, want the %d floor", hf.capacity, histMinCapacity)
}
ts, vs := ramp(0, 1, 1000) // t = 0..999, exactly full
hw.write(key, ts, vs)
hw.captureRange(500, 600)
// Wrap the archive right over the captured window.
ts2, vs2 := ramp(1000, 1, 1000)
hw.write(key, ts2, vs2)
if hf.tOldest != 1000 || hf.tNewest != 1999 {
t.Fatalf("archive holds [%v, %v], want [1000, 1999]: capturing must not "+
"stop or divert the archive", hf.tOldest, hf.tNewest)
}
rt, rv := hw.readRange(key, 500, 600, 1000)
if len(rt) != 101 {
t.Fatalf("read %d captured samples in [500, 600], want 101", len(rt))
}
if rv[0] != 500 || rv[len(rv)-1] != 600 {
t.Fatalf("captured values %.0f..%.0f, want 500..600", rv[0], rv[len(rv)-1])
}
// A range the capture does not hold is still answered by the archive.
if at, _ := hw.readRange(key, 1500, 1600, 1000); len(at) != 101 {
t.Fatalf("read %d archived samples in [1500, 1600], want 101", len(at))
}
// The next capture replaces the last one, and only then.
hw.captureRange(1500, 1600)
if ct, _ := hw.readRange(key, 500, 600, 1000); len(ct) != 0 {
t.Fatalf("read %d samples of a replaced capture, want 0", len(ct))
}
}
// Delivering a capture copies its window out of the archive, and the archive
// keeps rolling so the next capture's pre-trigger window is there when it fires.
func TestTriggerCaptureCopiesWindowToDisk(t *testing.T) {
h := NewHub()
if err := h.EnableHistory(HistoryConfig{
Directory: t.TempDir(), WindowSec: 36, MinDiskFreeMB: -1,
}); err != nil {
t.Fatalf("EnableHistory: %v", err)
}
t.Cleanup(h.CloseHistory)
h.hist.onSourceConfigured("s1", []udpsprotocol.SignalInfo{
{Name: "sig", TypeCode: 8, SamplingRate: 1000},
})
h.rings["s1:sig"] = newSigRing(10000)
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 1, prePercent: 20, mode: "single"})
h.trigger.Arm()
// Cross the threshold, then cover the post-trigger window so the capture
// comes due on the next tick.
h.ingest("s1:sig", 1, []float64{5.0, 5.001}, []float64{-1, 1})
h.ingest("s1:sig", 1, []float64{6.0}, []float64{1})
h.triggerTick()
if h.trigger.State() != trigTriggered {
t.Fatalf("state = %q, want triggered", h.trigger.State())
}
cf := h.hist.captures["s1:sig"]
if cf == nil {
t.Fatal("capture delivered but its window was not copied to disk")
}
// The window is [trigTime-0.2, trigTime+0.8] around the 5.001 crossing, so
// the sample at 6.0 falls outside it.
if cf.count != 2 || cf.tOldest != 5.0 || cf.tNewest != 5.001 {
t.Fatalf("capture holds %d samples in [%v, %v], want 2 in [5, 5.001]",
cf.count, cf.tOldest, cf.tNewest)
}
// Copying the window leaves the archive rolling, so the next capture's
// pre-trigger window — written before its trigger fires — is there for it.
h.ingest("s1:sig", 1, []float64{7.0}, []float64{1})
if got := h.hist.files["s1:sig"].count; got != 4 {
t.Fatalf("archived %d samples, want 4: capturing must not stop writing", got)
}
// Rearming does not discard the capture: it stays on screen until the next
// trigger replaces it.
h.trigger.Arm()
h.triggerTick()
if h.hist.captures["s1:sig"] != cf {
t.Fatal("rearming discarded the capture the client is still showing")
}
}
func TestHistSearch(t *testing.T) {
vals := []float64{0, 1, 2, 3, 4, 5}
at := func(i uint32) float64 { return vals[i] }
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 3 }); got != 3 {
t.Fatalf("lower bound = %d, want 3", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) <= 3 }); got != 4 {
t.Fatalf("upper bound = %d, want 4", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < -1 }); got != 0 {
t.Fatalf("all-false = %d, want 0", got)
}
if got := histSearch(0, 6, func(i uint32) bool { return at(i) < 100 }); got != 6 {
t.Fatalf("all-true = %d, want 6", got)
}
}
+231 -72
View File
@@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"unsafe" "unsafe"
@@ -27,6 +28,29 @@ type wsClient struct {
hub *Hub hub *Hub
conn *websocket.Conn conn *websocket.Conn
send chan wsMessage send chan wsMessage
// window is the timespan this client is displaying, in seconds, held as
// float64 bits. The retune sweep sizes the rings from the widest window in
// use, so it must be readable from the hub goroutine while readPump writes
// it. Zero means the client has not said, and the default applies.
window atomic.Uint64
}
func (c *wsClient) setDisplayWindowSec(s float64) {
c.window.Store(math.Float64bits(s))
}
func (c *wsClient) displayWindowSec() float64 {
return math.Float64frombits(c.window.Load())
}
// sendText enqueues one JSON frame for this client, dropping it if the client
// is not draining its queue.
func (c *wsClient) sendText(msg []byte) {
select {
case c.send <- wsMessage{websocket.TextMessage, msg}:
default:
}
} }
func (c *wsClient) writePump() { func (c *wsClient) writePump() {
@@ -128,6 +152,13 @@ func (c *wsClient) readPump() {
case c.hub.commandCh <- hubCmd{op: "wsReloadConfig"}: case c.hub.commandCh <- hubCmd{op: "wsReloadConfig"}:
default: default:
} }
case "setWindow":
// Sizes the zoom rings: the hub cannot know how far back a
// client is plotting, and a window it has not been told
// about is a window the buffers may not reach.
if sec, ok := env["seconds"].(float64); ok && sec > 0 && !math.IsInf(sec, 0) {
c.setDisplayWindowSec(sec)
}
case "setMonotonic": case "setMonotonic":
enabled, _ := env["enabled"].(bool) enabled, _ := env["enabled"].(bool)
select { select {
@@ -140,6 +171,9 @@ func (c *wsClient) readPump() {
if c.hub.handleTriggerCommand(t, env) { if c.hub.handleTriggerCommand(t, env) {
break break
} }
if c.hub.handleHistoryCommand(c, 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:
@@ -271,11 +305,27 @@ type Hub struct {
ringsMu sync.RWMutex ringsMu sync.RWMutex
rings map[string]*sigRing // "sourceId:signalKey" → ring rings map[string]*sigRing // "sourceId:signalKey" → ring
// hist is the disk-backed archive behind long time windows, which hold far
// more samples than the in-memory rings can. nil when history is disabled.
// histOpenAt throttles the sweep that opens the files of signals whose
// producer declared no sampling rate; both are touched only from Run().
hist *historyWriter
histOpenAt float64
statsMu sync.RWMutex statsMu sync.RWMutex
statsMap map[string]*SourceStat statsMap map[string]*SourceStat
// trigger is the hub-side trigger FSM driving the oscilloscope capture mode. // trigger is the hub-side trigger FSM driving the oscilloscope capture mode.
// ringTuneAt throttles the sweep that keeps each ring's depth and min/max
// bucket matched to the window being displayed; both are touched only from
// Run(). ringBudgetPts is that sweep's per-signal budget; set before Run().
trigger *triggerEngine trigger *triggerEngine
ringTuneAt float64
// capture is the trigger double buffer's read half: the last delivered
// capture window, kept out of the rings' way so the shot being viewed
// survives the re-arm that immediately follows it.
capture captureHold
ringBudgetPts int
onClientConnectMu sync.RWMutex onClientConnectMu sync.RWMutex
onClientConnect func(send func([]byte)) onClientConnect func(send func([]byte))
@@ -301,6 +351,44 @@ func NewHub() *Hub {
} }
} }
// SetRingBudget overrides the per-signal in-memory buffer budget, in points.
// Non-positive values restore the default. It must be called before Run().
// Each point costs 16 bytes, so the budget is the memory bound per temporal
// signal. It does not limit how long a window can be held: a window too long
// to fit at full rate is stored as min/max pairs instead (see retuneRings).
func (h *Hub) SetRingBudget(n int) {
if n <= 0 {
n = defaultRingPts
}
if n < ringCapInitial {
n = ringCapInitial
}
h.ringBudgetPts = n
}
func (h *Hub) ringBudget() int {
if h.ringBudgetPts <= 0 {
return defaultRingPts
}
return h.ringBudgetPts
}
// EnableHistory turns on the disk-backed history archive. It must be called
// before Run(). A HistoryConfig with an empty Directory leaves history off.
func (h *Hub) EnableHistory(cfg HistoryConfig) error {
hw, err := newHistoryWriter(cfg)
if err != nil {
return err
}
h.hist = hw
return nil
}
// CloseHistory flushes and closes the history files. Without it the samples
// written since the last periodic flush are on disk but unaccounted for in the
// file headers, so a restart would not see them.
func (h *Hub) CloseHistory() { h.hist.close() }
// SetOnClientConnect registers a callback invoked synchronously (from Run()) // SetOnClientConnect registers a callback invoked synchronously (from Run())
// each time a new WebSocket client connects. The callback receives a send // each time a new WebSocket client connects. The callback receives a send
// function that enqueues one message to that specific client. // function that enqueues one message to that specific client.
@@ -315,6 +403,22 @@ func (h *Hub) SetSourceManager(sm *SourceManager) {
h.sm = sm h.sm = sm
} }
// ingest routes one batch of full-resolution samples for a signal to every
// consumer that needs them at full rate: the in-memory zoom ring, the disk
// history and the trigger comparator. The live push is decimated separately by
// the caller. The ring and the archive may reduce what they store to fit their
// budget, but they are handed every sample so the reduction sees the extrema.
func (h *Hub) ingest(key string, nElem int, t, v []float64) {
if len(t) == 0 {
return
}
if rb := h.getRing(key); rb != nil {
rb.write(t, v)
}
h.hist.write(key, t, v)
h.trigger.feed(key, nElem, t, v)
}
// getRing returns the ring buffer for a fully-prefixed signal key, or nil. // getRing returns the ring buffer for a fully-prefixed signal key, or nil.
func (h *Hub) getRing(key string) *sigRing { func (h *Hub) getRing(key string) *sigRing {
h.ringsMu.RLock() h.ringsMu.RLock()
@@ -323,8 +427,10 @@ func (h *Hub) getRing(key string) *sigRing {
return rb return rb
} }
// zoomSlice extracts [t0, t1] from the full-resolution rings for the named // zoomSlice extracts [t0, t1] for the named signals, decimating each to at most
// signals, decimating each to at most n points. // n points. A range inside the last trigger capture is served from the held
// copy of it, which the re-arming acquisition cannot overwrite; everything else
// comes from the live rings.
func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData { func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData {
h.ringsMu.RLock() h.ringsMu.RLock()
refs := make(map[string]*sigRing, len(keys)) refs := make(map[string]*sigRing, len(keys))
@@ -341,11 +447,14 @@ func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData
result := make(map[string]sigData, len(refs)) result := make(map[string]sigData, len(refs))
for k, rb := range refs { for k, rb := range refs {
rt, rv := rb.slice(t0, t1) rt, rv, ok := h.capture.slice(k, t0, t1)
if !ok {
rt, rv = rb.slice(t0, t1)
}
if len(rt) == 0 { if len(rt) == 0 {
continue continue
} }
dt, dv := lttbDecimate(rt, rv, n) dt, dv := minMaxDecimate(rt, rv, n)
result[k] = sigData{T: dt, V: dv} result[k] = sigData{T: dt, V: dv}
} }
return result return result
@@ -388,10 +497,7 @@ func (h *Hub) handleWSZoom(c *wsClient, env map[string]interface{}) {
log.Printf("hub: ws zoom encode: %v", err) log.Printf("hub: ws zoom encode: %v", err)
return return
} }
select { c.sendText(reply)
case c.send <- wsMessage{websocket.TextMessage, reply}:
default:
}
} }
// HandleZoom serves GET /api/zoom?... // HandleZoom serves GET /api/zoom?...
@@ -526,6 +632,16 @@ func (h *Hub) Run() {
statsTicker := time.NewTicker(time.Second) statsTicker := time.NewTicker(time.Second)
defer statsTicker.Stop() defer statsTicker.Stop()
// Header flushes are what make the archived samples findable again; the
// data region is written as it arrives. Ticks are ignored when history is
// off, so a disabled writer costs one no-op call per period.
flushPeriod := time.Duration(5) * time.Second
if h.hist.enabled() {
flushPeriod = time.Duration(h.hist.cfg.FlushIntervalSec) * time.Second
}
flushTicker := time.NewTicker(flushPeriod)
defer flushTicker.Stop()
sourcesMap := make(map[string]*sourceHubState) sourcesMap := make(map[string]*sourceHubState)
var sourcesMsg []byte var sourcesMsg []byte
@@ -570,6 +686,11 @@ func (h *Hub) Run() {
case c.send <- wsMessage{websocket.TextMessage, calMsg}: case c.send <- wsMessage{websocket.TextMessage, calMsg}:
default: default:
} }
if h.hist.enabled() {
if msg := h.buildHistoryInfoMsg(); msg != nil {
c.sendText(msg)
}
}
// 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()
@@ -670,16 +791,29 @@ func (h *Hub) Run() {
ne := sig.NumElements() ne := sig.NumElements()
isTemporal := ne > 1 && sig.TimeMode != udpsprotocol.TimeModePacket isTemporal := ne > 1 && sig.TimeMode != udpsprotocol.TimeModePacket
if isTemporal { if isTemporal {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal) h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
} else if ne == 1 { } else if ne == 1 {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar) h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar)
} else { } else {
// n>1, TimeModePacket snapshot-waveform: each packet contributes n // n>1, TimeModePacket snapshot-waveform: each packet contributes n
// elements, so use the temporal capacity to hold enough history. // elements, so this is a fast stream too and gets the same budget.
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal) h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
} }
} }
h.ringsMu.Unlock() h.ringsMu.Unlock()
// The held capture describes rings that no longer exist. A
// restarted producer can even replay the same timestamps, so
// keeping it would answer zooms with the old run's samples.
h.capture.clear()
// Opening the archive files touches the filesystem, so keep it
// off the Run() goroutine; the write path simply drops samples
// for a key whose file is not open yet.
if h.hist.enabled() {
go func(id string, sigs []udpsprotocol.SignalInfo) {
h.hist.onSourceConfigured(id, sigs)
h.broadcast(h.buildHistoryInfoMsg())
}(cmd.sourceID, cmd.sigs)
}
case "wsAddSource": case "wsAddSource":
if h.sm != nil { if h.sm != nil {
@@ -748,10 +882,15 @@ func (h *Hub) Run() {
continue continue
} }
src, ok := sourcesMap[srcID] src, ok := sourcesMap[srcID]
if !ok || len(src.signals) == 0 || len(h.clients) == 0 { if !ok || len(src.signals) == 0 {
pending[srcID] = pending[srcID][:0] pending[srcID] = pending[srcID][:0]
continue continue
} }
// Built even with no clients connected: this is also what feeds
// the rings, the disk history and the trigger, none of which may
// stop just because nobody is watching. It also keeps the push
// cursors advancing, so the first client to connect does not get
// a backlog burst. Matches the C++ StreamHub.
msg := h.buildBinaryDataMessageForSource(src, samples) msg := h.buildBinaryDataMessageForSource(src, samples)
pending[srcID] = pending[srcID][:0] pending[srcID] = pending[srcID][:0]
if msg != nil { if msg != nil {
@@ -765,6 +904,9 @@ func (h *Hub) Run() {
} }
h.triggerTick() h.triggerTick()
case <-flushTicker.C:
h.hist.flushHeaders()
case <-statsTicker.C: case <-statsTicker.C:
h.statsMu.RLock() h.statsMu.RLock()
snap := make(map[string]StatInfo, len(h.statsMap)) snap := make(map[string]StatInfo, len(h.statsMap))
@@ -802,9 +944,21 @@ func writeFloat64s(buf []byte, off int, f []float64) int {
// ever recover, and the browser already decimates for display. // ever recover, and the browser already decimates for display.
const maxPushPoints = 50 const maxPushPoints = 50
// Zoom ring depth, in samples per signal (16 bytes each). ringCapTemporal // Ring geometry, in samples per signal (16 bytes each).
// holds 6 s of a 1 MSps waveform; ringCapScalar holds 100 000 packets. //
const ringCapTemporal = 6_000_000 // defaultRingPts is the per-signal memory budget for temporal (array) signals:
// what the hub may spend keeping one signal available for zoom and for trigger
// captures. 10 M points is 160 MB. The budget buys resolution, not span —
// retuneRings buckets the input so the display window fits whatever the source
// rate is.
//
// ringCapInitial is where a ring starts, so a source that is configured but
// never sends costs nothing; the first retune sweep grows it to the budget.
//
// ringCapScalar sizes scalar signals, which arrive at the packet rate and would
// squander a budget meant for megasample streams.
const defaultRingPts = 10_000_000
const ringCapInitial = 250_000
const ringCapScalar = 100_000 const ringCapScalar = 100_000
// monotonicTolerance is the maximum inter-frame timestamp deviation (seconds) // monotonicTolerance is the maximum inter-frame timestamp deviation (seconds)
@@ -817,52 +971,59 @@ const monotonicTolerance = 0.005 // 5 ms
// track real rate changes, slow enough to average out per-frame jitter. // track real rate changes, slow enough to average out per-frame jitter.
const monotonicEMAAlpha = 0.01 const monotonicEMAAlpha = 0.01
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points // minMaxDecimate reduces (tIn, vIn) to at most threshold points the way an
// using the Largest-Triangle-Three-Buckets algorithm. // oscilloscope draws a trace it cannot show pixel-for-pixel: the range is split
func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) { // into threshold/2 equal buckets and each contributes its smallest and largest
// sample, in the order the two occurred.
//
// This is what replaced LTTB on every path here. LTTB picks the sample that
// makes the largest triangle with its neighbours, which reads as a plausible
// shape but silently drops a one-sample spike whenever a smoother neighbour
// scores higher — precisely the sample the user is looking for. The envelope
// cannot drop it: a spike is by definition its bucket's min or max. The cost is
// that a flat trace is drawn as a band rather than a line, which is how a scope
// behaves too.
//
// Both output arrays hold real samples with their real timestamps; nothing is
// interpolated or averaged.
func minMaxDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
n := len(tIn) n := len(tIn)
if n <= threshold || threshold < 3 { // Below four there is no room for a single min/max pair plus endpoints.
if n <= threshold || threshold < 4 {
return tIn, vIn return tIn, vIn
} }
outT := make([]float64, threshold) buckets := threshold / 2
outV := make([]float64, threshold) outT := make([]float64, 0, threshold)
outT[0], outV[0] = tIn[0], vIn[0] outV := make([]float64, 0, threshold)
outT[threshold-1], outV[threshold-1] = tIn[n-1], vIn[n-1] for b := 0; b < buckets; b++ {
lo := b * n / buckets
every := float64(n-2) / float64(threshold-2) hi := (b + 1) * n / buckets
a := 0 if b == buckets-1 {
for i := 0; i < threshold-2; i++ { hi = n
avgS := int(float64(i+1)*every) + 1
avgE := int(float64(i+2)*every) + 1
if avgE > n {
avgE = n
} }
avgT, avgV, cnt := 0.0, 0.0, 0 if lo >= hi {
for j := avgS; j < avgE; j++ { continue
avgT += tIn[j]
avgV += vIn[j]
cnt++
} }
if cnt > 0 { iMin, iMax := lo, lo
avgT /= float64(cnt) for j := lo + 1; j < hi; j++ {
avgV /= float64(cnt) if vIn[j] < vIn[iMin] {
iMin = j
} }
rS := int(float64(i)*every) + 1 if vIn[j] > vIn[iMax] {
rE := int(float64(i+1)*every) + 1 iMax = j
if rE > n {
rE = n
}
maxArea, next := -1.0, rS
aT, aV := tIn[a], vIn[a]
for j := rS; j < rE; j++ {
area := math.Abs((aT-avgT)*(vIn[j]-aV) - (aT-tIn[j])*(avgV-aV))
if area > maxArea {
maxArea = area
next = j
} }
} }
outT[i+1], outV[i+1] = tIn[next], vIn[next] // Emit in time order so the result plots as one ascending trace.
a = next if iMin > iMax {
iMin, iMax = iMax, iMin
}
outT = append(outT, tIn[iMin])
outV = append(outV, vIn[iMin])
// A bucket whose samples are all equal has one extreme, not two.
if iMax != iMin {
outT = append(outT, tIn[iMax])
outV = append(outV, vIn[iMax])
}
} }
return outT, outV return outT, outV
} }
@@ -974,11 +1135,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
allV = append(allV, vals[k]) allV = append(allV, vals[k])
} }
} }
if rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, n, allT, allV)
rb.write(allT, allV) decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
}
h.trigger.feed(pfx+sig.Name, n, allT, allV)
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
pairs[sig.Name] = pairBuf{t: decimT, v: decimV} pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
case sig.TimeMode == udpsprotocol.TimeModeFullArray: case sig.TimeMode == udpsprotocol.TimeModeFullArray:
@@ -1020,11 +1178,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
allV = append(allV, vals[k]) allV = append(allV, vals[k])
} }
} }
if rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, n, allT, allV)
rb.write(allT, allV) decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
}
h.trigger.feed(pfx+sig.Name, n, allT, allV)
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
pairs[sig.Name] = pairBuf{t: decimT, v: decimV} pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
case n == 1: case n == 1:
@@ -1038,10 +1193,7 @@ 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 rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, 1, 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:
@@ -1107,12 +1259,19 @@ 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 {
if rb := h.getRing(pfx + sig.Name); rb != nil { h.ingest(pfx+sig.Name, n, allT, allV)
rb.write(allT, allV) // Live push: never below one packet's worth of elements, or LTTB
// would flatten the snapshot waveform itself; never above it
// either, since anything more is just packets that piled up
// during the tick. Pushing every point unconditionally does not
// survive a fast producer: a 5 kHz x 1000-element array is 5M
// points/s on the wire and the client queue never drains.
thr := maxPushPoints
if n > thr {
thr = n
} }
h.trigger.feed(pfx+sig.Name, n, allT, allV) decimT, decimV := minMaxDecimate(allT, allV, thr)
// Live push: send all points without LTTB (fix 2). pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
pairs[sig.Name] = pairBuf{t: allT, v: allV}
} }
} }
} }
@@ -0,0 +1,116 @@
//go:build linux
package wshub
import (
"net"
"syscall"
"testing"
"time"
)
// setMulticastIf pins a socket's outgoing multicast interface (IP_MULTICAST_IF),
// which is exactly what UDPStreamer/UDPSServer does with its `Interface` key.
func setMulticastIf(t *testing.T, conn *net.UDPConn, ip [4]byte) {
t.Helper()
rc, err := conn.SyscallConn()
if err != nil {
t.Fatalf("SyscallConn: %v", err)
}
var sockErr error
if err := rc.Control(func(fd uintptr) {
sockErr = syscall.SetsockoptInet4Addr(int(fd), syscall.IPPROTO_IP, syscall.IP_MULTICAST_IF, ip)
}); err != nil {
t.Fatalf("Control: %v", err)
}
if sockErr != nil {
t.Fatalf("IP_MULTICAST_IF: %v", sockErr)
}
}
func TestInterfaceForIPResolvesLoopback(t *testing.T) {
ifi := interfaceForIP(net.ParseIP("127.0.0.1"))
if ifi == nil {
t.Fatal("no interface resolved for 127.0.0.1")
}
if ifi.Flags&net.FlagLoopback == 0 {
t.Fatalf("resolved %q for 127.0.0.1, which is not a loopback interface", ifi.Name)
}
}
func TestInterfaceForIPUnknownAddressIsNil(t *testing.T) {
// Unspecified and unassigned addresses must fall back to "let the kernel
// choose" rather than resolving to an arbitrary interface.
if ifi := interfaceForIP(net.IPv4zero); ifi != nil {
t.Fatalf("0.0.0.0 resolved to %q, want nil", ifi.Name)
}
if ifi := interfaceForIP(nil); ifi != nil {
t.Fatalf("nil IP resolved to %q, want nil", ifi.Name)
}
if ifi := interfaceForIP(net.ParseIP("203.0.113.42")); ifi != nil {
t.Fatalf("unassigned address resolved to %q, want nil", ifi.Name)
}
}
// TestMulticastJoinOnControlInterfaceReceivesData is the regression test for the
// bug that left the web UI permanently blank: the hub joined the group with a
// nil interface, so imr_interface stayed INADDR_ANY and the kernel picked the
// default-route interface. A UDPStreamer configured with Interface = "127.0.0.1"
// sends out the loopback instead, and every datagram was silently dropped.
//
// The sender here mimics that server exactly (IP_MULTICAST_IF = 127.0.0.1); the
// receiver joins the way runMulticastSession now does, via the interface that
// owns the control connection's local address.
func TestMulticastJoinOnControlInterfaceReceivesData(t *testing.T) {
const group = "239.255.13.37"
ifi := interfaceForIP(net.ParseIP("127.0.0.1"))
if ifi == nil {
t.Skip("no loopback interface available")
}
rx, err := net.ListenMulticastUDP("udp4", ifi, &net.UDPAddr{IP: net.ParseIP(group), Port: 0})
if err != nil {
t.Fatalf("join %s on %s: %v", group, ifi.Name, err)
}
defer rx.Close()
port := rx.LocalAddr().(*net.UDPAddr).Port
tx, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
t.Fatalf("sender socket: %v", err)
}
defer tx.Close()
setMulticastIf(t, tx, [4]byte{127, 0, 0, 1})
payload := []byte("UDPS-multicast-probe")
dst := &net.UDPAddr{IP: net.ParseIP(group), Port: port}
// Datagrams are lossy even on loopback if the join has not settled, so send
// a few and accept the first that lands.
done := make(chan struct{})
defer close(done)
go func() {
for {
select {
case <-done:
return
default:
}
tx.WriteToUDP(payload, dst)
time.Sleep(20 * time.Millisecond)
}
}()
buf := make([]byte, 128)
if err := rx.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
t.Fatal(err)
}
n, _, err := rx.ReadFromUDP(buf)
if err != nil {
t.Fatalf("no multicast received on %s within 3s: %v", ifi.Name, err)
}
if got := string(buf[:n]); got != string(payload) {
t.Fatalf("payload = %q, want %q", got, payload)
}
}
+311 -3
View File
@@ -1,6 +1,10 @@
package wshub package wshub
import "sync" import (
"log"
"math"
"sync"
)
// sigRing is a fixed-capacity circular buffer storing (time, value) pairs. // sigRing is a fixed-capacity circular buffer storing (time, value) pairs.
// Writes come from the Hub.Run() goroutine; reads come from HTTP handler goroutines. // Writes come from the Hub.Run() goroutine; reads come from HTTP handler goroutines.
@@ -10,28 +14,332 @@ type sigRing struct {
t, v []float64 t, v []float64
cap int cap int
head, size int // next write position; current fill head, size int // next write position; current fill
// bucket is how many source samples collapse into one min/max pair on the
// way in. 1 stores the stream verbatim. Raising it trades resolution for
// the timespan a fixed capacity covers, which is what lets a long display
// window fit in a fixed per-signal memory budget.
bucket int
// In-progress bucket. accN counts source samples seen since the last pair
// was emitted; the four acc fields are the extrema and when they occurred.
accN int
accTMin, accVMin float64
accTMax, accVMax float64
// Source-sample accounting, kept because size and the stored timespan no
// longer give the source rate once bucket > 1. Reset every
// srcRateWindowSec so a producer restart or a rate change is not averaged
// against the whole run.
srcCount int64
srcT0, srcT1 float64
haveSrc bool
} }
// srcRateWindowSec bounds how long a source-rate measurement accumulates before
// starting over. Long enough to average out per-frame jitter, short enough that
// a rate change is reflected within a few seconds.
const srcRateWindowSec = 10.0
func newSigRing(capacity int) *sigRing { func newSigRing(capacity int) *sigRing {
return &sigRing{ return &sigRing{
t: make([]float64, capacity), t: make([]float64, capacity),
v: make([]float64, capacity), v: make([]float64, capacity),
cap: capacity, cap: capacity,
bucket: 1,
} }
} }
// write appends (tArr[i], vArr[i]) pairs, overwriting oldest entries when full. // write appends (tArr[i], vArr[i]) pairs, overwriting oldest entries when full.
// With bucket > 1 each group of bucket samples contributes only its minimum and
// its maximum, in the order the two occurred.
func (rb *sigRing) write(tArr, vArr []float64) { func (rb *sigRing) write(tArr, vArr []float64) {
rb.mu.Lock() rb.mu.Lock()
defer rb.mu.Unlock() defer rb.mu.Unlock()
if n := len(tArr); n > 0 {
if !rb.haveSrc || tArr[n-1]-rb.srcT0 > srcRateWindowSec || tArr[0] < rb.srcT0 {
rb.srcT0, rb.srcCount, rb.haveSrc = tArr[0], 0, true
}
rb.srcT1 = tArr[n-1]
rb.srcCount += int64(n)
}
if rb.bucket <= 1 {
for i := 0; i < len(tArr); i++ { for i := 0; i < len(tArr); i++ {
rb.t[rb.head] = tArr[i] rb.pushLocked(tArr[i], vArr[i])
rb.v[rb.head] = vArr[i] }
return
}
for i := 0; i < len(tArr); i++ {
t, v := tArr[i], vArr[i]
if rb.accN == 0 {
rb.accTMin, rb.accVMin, rb.accTMax, rb.accVMax = t, v, t, v
} else {
if v < rb.accVMin {
rb.accTMin, rb.accVMin = t, v
}
if v > rb.accVMax {
rb.accTMax, rb.accVMax = t, v
}
}
rb.accN++
if rb.accN >= rb.bucket {
rb.flushBucketLocked()
}
}
}
func (rb *sigRing) pushLocked(t, v float64) {
rb.t[rb.head] = t
rb.v[rb.head] = v
rb.head = (rb.head + 1) % rb.cap rb.head = (rb.head + 1) % rb.cap
if rb.size < rb.cap { if rb.size < rb.cap {
rb.size++ rb.size++
} }
}
// flushBucketLocked emits the accumulated extrema oldest-first. Time order
// matters: every read binary-searches rb.t, so the stored timestamps must stay
// non-decreasing.
func (rb *sigRing) flushBucketLocked() {
if rb.accN == 0 {
return
} }
if rb.accTMin <= rb.accTMax {
rb.pushLocked(rb.accTMin, rb.accVMin)
rb.pushLocked(rb.accTMax, rb.accVMax)
} else {
rb.pushLocked(rb.accTMax, rb.accVMax)
rb.pushLocked(rb.accTMin, rb.accVMin)
}
rb.accN = 0
}
// setBucket changes the min/max reduction applied to incoming samples and
// reports whether it changed. Samples already stored keep the resolution they
// were written at; the ring converges on the new one as it rolls.
func (rb *sigRing) setBucket(n int) bool {
if n < 1 {
n = 1
}
rb.mu.Lock()
defer rb.mu.Unlock()
if n == rb.bucket {
return false
}
// Emit what the old bucket had collected rather than dropping it.
rb.flushBucketLocked()
rb.bucket = n
return true
}
func (rb *sigRing) bucketSize() int {
rb.mu.RLock()
defer rb.mu.RUnlock()
return rb.bucket
}
// sourceRate is the measured rate of the incoming stream in samples per second,
// or 0 while there is too little to extrapolate from. Unlike stats() it counts
// source samples, so it is unaffected by bucketing.
func (rb *sigRing) sourceRate() float64 {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.srcCount < 2 || rb.srcT1 <= rb.srcT0 {
return 0
}
return float64(rb.srcCount-1) / (rb.srcT1 - rb.srcT0)
}
// stats reports the current fill and the timespan it covers, so callers can
// estimate the stream's sample rate without copying the data out.
func (rb *sigRing) stats() (count int, span float64) {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.size < 2 {
return rb.size, 0
}
start := 0
if rb.size == rb.cap {
start = rb.head
}
oldest := rb.t[start]
newest := rb.t[(start+rb.size-1)%rb.cap]
return rb.size, newest - oldest
}
func (rb *sigRing) capacity() int {
rb.mu.RLock()
defer rb.mu.RUnlock()
return rb.cap
}
// ─── Ring tuning ─────────────────────────────────────────────────────────────
// ringHeadroom oversizes a reduced ring's span. It absorbs rate jitter and
// keeps the tail of a trigger window in the buffer long enough for the capture
// to read it. It applies only once the window no longer fits verbatim: at the
// boundary, spending a whole extra bucket step to buy 25 % more span would cost
// half the resolution.
const ringHeadroom = 1.25
// ringTuneIntervalSec throttles the retune sweep. The source rate only settles
// once data flows, so the sweep repeats rather than running once.
const ringTuneIntervalSec = 1.0
// defaultLiveWindowSec is the window assumed when no client has said what it is
// displaying — the native clients never do, and a browser has not yet at the
// moment the first samples land.
const defaultLiveWindowSec = 10.0
// ringBucketFor is how many source samples must collapse into one min/max pair
// for `window` seconds at `rate` samples/s to fit in `capacity` points.
//
// rate*window <= capacity → 1, the buffer stays verbatim and reaches further
// back than the window, which is free zoom headroom
// rate*window > capacity → >1, so the whole window fits at reduced resolution
//
// A bucket costs two points (its minimum and its maximum), hence the factor 2.
func ringBucketFor(rate, window float64, capacity int) int {
if capacity <= 0 || rate <= 0 || window <= 0 {
return 1
}
need := rate * window
if need <= float64(capacity) {
return 1
}
return int(math.Ceil(2 * need * ringHeadroom / float64(capacity)))
}
// ringCoverage is how many source samples a ring of `capacity` points holds at
// the given bucket. A bucket of 2 stores both of its samples, so it covers no
// more ground than a bucket of 1.
func ringCoverage(bucket, capacity int) int {
if bucket <= 2 {
return capacity
}
return capacity / 2 * bucket
}
// activeWindowSec is the timespan the buffers must cover. An armed trigger owns
// it: its pre-window has to already be in the ring when the trigger fires or
// there is nothing to back-fill the capture from. Otherwise it is the widest
// window any connected client is displaying.
func (h *Hub) activeWindowSec() float64 {
if h.trigger != nil && h.trigger.Active() {
if cfg := h.trigger.Config(); cfg.windowSec > 0 {
return cfg.windowSec
}
}
widest := 0.0
for c := range h.clients {
if w := c.displayWindowSec(); w > widest {
widest = w
}
}
if widest <= 0 {
return defaultLiveWindowSec
}
return widest
}
// retuneRings keeps every ring matched to the window being displayed: grown
// towards the per-signal budget, and bucketed so the window fits inside it.
//
// A fixed sample-count ring covers a fraction of a second at a megasample rate,
// which is why long windows used to come back with only their tail populated —
// in live mode as much as under a trigger. Spending the budget on min/max pairs
// rather than on a bigger allocation is what makes an arbitrarily long window
// work within a fixed memory bound.
//
// Called from Hub.Run() only, so reading h.clients here needs no lock.
func (h *Hub) retuneRings(nowSec float64) {
if nowSec < h.ringTuneAt {
return
}
h.ringTuneAt = nowSec + ringTuneIntervalSec
window := h.activeWindowSec()
if window <= 0 {
return
}
// The archive answers for the same window as the rings — it is what a zoom
// or a capture falls back on once they have rolled past it — so it is sized
// from the same number.
if h.hist.setWindow(window) {
if msg := h.buildHistoryInfoMsg(); msg != nil {
h.broadcast(msg)
}
}
budget := h.ringBudget()
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()
for i, rb := range rings {
rate := rb.sourceRate()
if rate <= 0 {
continue
}
// Claim the whole budget before deciding on a bucket: memory is what
// buys resolution, so it is spent first and reduced from only if the
// window still does not fit.
if rb.capacity() < budget && rate*window > float64(rb.capacity()) {
rb.grow(budget)
}
cur := rb.bucketSize()
need := rate * window
covered := float64(ringCoverage(cur, rb.capacity()))
// Hysteresis. Retuning up and retuning down must not share a threshold:
// a bucket step doubles or halves the span, so a rate jittering across
// the boundary would otherwise flip the resolution every second. Hold
// the current bucket while it covers the window without covering more
// than twice it.
if covered >= need && covered <= 2*need {
continue
}
want := ringBucketFor(rate, window, rb.capacity())
if !rb.setBucket(want) {
continue
}
if want > 1 {
log.Printf("hub: ring %s stores min/max over %d samples: %.0f s at %.0f kSps does not fit in %d points",
keys[i], want, window, rate/1e3, rb.capacity())
} else {
log.Printf("hub: ring %s back to full resolution: %.0f s at %.0f kSps fits in %d points",
keys[i], window, rate/1e3, rb.capacity())
}
}
}
// grow enlarges the buffer to newCap, keeping every sample it currently holds.
// Shrinking is refused: it would discard history a pending capture may need.
func (rb *sigRing) grow(newCap int) bool {
rb.mu.Lock()
defer rb.mu.Unlock()
if newCap <= rb.cap {
return false
}
nt := make([]float64, newCap)
nv := make([]float64, newCap)
start := 0
if rb.size == rb.cap {
start = rb.head
}
for i := 0; i < rb.size; i++ {
p := (start + i) % rb.cap
nt[i], nv[i] = rb.t[p], rb.v[p]
}
rb.t, rb.v = nt, nv
rb.cap = newCap
rb.head = rb.size // size < newCap, so no wrap
return true
} }
// slice returns copies of all (t, v) pairs whose timestamp falls in [t0, t1]. // slice returns copies of all (t, v) pairs whose timestamp falls in [t0, t1].
+147
View File
@@ -0,0 +1,147 @@
package wshub
import (
"math"
"testing"
)
// dump returns the ring's contents oldest-first, which is what every reader
// sees through slice() but is easier to assert on directly.
func dump(rb *sigRing) ([]float64, []float64) {
return rb.slice(math.Inf(-1), math.Inf(1))
}
func TestRingBucketStoresMinMaxPairsInTimeOrder(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(4)
// Two buckets. In the first the minimum comes before the maximum, in the
// second the order is reversed, so the emitted pairs must not be sorted by
// value — a ring whose timestamps are not monotonic breaks slice()'s
// binary search.
ts := []float64{0, 1, 2, 3, 4, 5, 6, 7}
vs := []float64{-5, 0, 0, 9, 9, 0, 0, -5}
rb.write(ts, vs)
gotT, gotV := dump(rb)
wantT := []float64{0, 3, 4, 7}
wantV := []float64{-5, 9, 9, -5}
if len(gotT) != len(wantT) {
t.Fatalf("stored %d points, want %d", len(gotT), len(wantT))
}
for i := range wantT {
if gotT[i] != wantT[i] || gotV[i] != wantV[i] {
t.Fatalf("point %d = (%v,%v), want (%v,%v)", i, gotT[i], gotV[i], wantT[i], wantV[i])
}
}
}
func TestRingBucketExtendsTheSpanAFixedCapacityCovers(t *testing.T) {
const cap = 200
// 2000 samples at 1 kHz is 2 s, ten times what the capacity holds verbatim.
ts := make([]float64, 2000)
vs := make([]float64, 2000)
for i := range ts {
ts[i] = float64(i) * 1e-3
vs[i] = math.Sin(float64(i))
}
full := newSigRing(cap)
full.write(ts, vs)
if _, span := full.stats(); span > 0.25 {
t.Fatalf("full-rate ring spans %.3f s, expected ~0.2 s", span)
}
// bucket 20 turns 20 samples into 2 points, so the same capacity reaches
// 10x further: 200/2*20 = 2000 samples = 2 s.
bucketed := newSigRing(cap)
bucketed.setBucket(20)
bucketed.write(ts, vs)
count, span := bucketed.stats()
if count != cap {
t.Fatalf("bucketed ring holds %d points, want the full %d", count, cap)
}
if span < 1.9 {
t.Fatalf("bucketed ring spans %.3f s, want the whole ~2 s", span)
}
}
func TestRingSourceRateIsUnaffectedByBucketing(t *testing.T) {
rb := newSigRing(1000)
rb.setBucket(50)
ts := make([]float64, 5000)
vs := make([]float64, 5000)
for i := range ts {
ts[i] = float64(i) * 1e-4 // 10 kHz
}
rb.write(ts, vs)
got := rb.sourceRate()
if math.Abs(got-10000) > 10 {
t.Fatalf("sourceRate = %.1f, want ~10000", got)
}
}
func TestSetBucketFlushesThePartialBucket(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(10)
// Three samples: not enough to close a bucket of 10, so nothing is stored
// yet and they would be silently dropped by a re-bucket that just reset the
// accumulator.
rb.write([]float64{0, 1, 2}, []float64{7, -7, 0})
if n, _ := rb.stats(); n != 0 {
t.Fatalf("partial bucket already emitted %d points", n)
}
rb.setBucket(2)
gotT, gotV := dump(rb)
if len(gotT) != 2 || gotT[0] != 0 || gotV[0] != 7 || gotT[1] != 1 || gotV[1] != -7 {
t.Fatalf("flushed pair = %v/%v, want t=[0 1] v=[7 -7]", gotT, gotV)
}
}
func TestActiveWindowSecFallsBackToTheDefault(t *testing.T) {
h := NewHub()
if got := h.activeWindowSec(); got != defaultLiveWindowSec {
t.Fatalf("activeWindowSec with no clients = %v, want %v", got, defaultLiveWindowSec)
}
}
// Clients disagree about how far back they are plotting, and a buffer sized for
// the narrowest one leaves the others with nothing to zoom into.
func TestActiveWindowSecTakesTheWidestClientWindow(t *testing.T) {
h := NewHub()
narrow, wide, silent := &wsClient{}, &wsClient{}, &wsClient{}
narrow.setDisplayWindowSec(1)
wide.setDisplayWindowSec(120)
h.clients[narrow], h.clients[wide], h.clients[silent] = true, true, true
if got := h.activeWindowSec(); got != 120 {
t.Fatalf("activeWindowSec = %v, want the widest 120", got)
}
}
// An armed trigger owns the window: its pre-window has to be in the buffer
// before the trigger fires or the capture has nothing to back-fill from.
func TestActiveWindowSecPrefersTheArmedTrigger(t *testing.T) {
h := NewHub()
c := &wsClient{}
c.setDisplayWindowSec(1)
h.clients[c] = true
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 45, mode: "normal"})
if got := h.activeWindowSec(); got != 45 {
t.Fatalf("activeWindowSec = %v, want the trigger's 45", got)
}
}
func TestSetBucketToOneRestoresVerbatimStorage(t *testing.T) {
rb := newSigRing(100)
rb.setBucket(4)
rb.setBucket(1)
ts := []float64{0, 1, 2, 3}
vs := []float64{1, 2, 3, 4}
rb.write(ts, vs)
gotT, _ := dump(rb)
if len(gotT) != 4 {
t.Fatalf("stored %d points, want all 4", len(gotT))
}
}
+61 -2
View File
@@ -447,6 +447,52 @@ func (u *UDPClient) runSession() error {
} }
} }
// interfaceForIP returns the interface that owns the given local address, or
// nil if no interface matches (in which case callers fall back to letting the
// kernel choose).
func interfaceForIP(ip net.IP) *net.Interface {
if ip == nil || ip.IsUnspecified() {
return nil
}
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for i := range ifaces {
addrs, err := ifaces[i].Addrs()
if err != nil {
continue
}
for _, a := range addrs {
var aIP net.IP
switch v := a.(type) {
case *net.IPNet:
aIP = v.IP
case *net.IPAddr:
aIP = v.IP
}
if aIP != nil && aIP.Equal(ip) {
return &ifaces[i]
}
}
}
return nil
}
// interfaceForConn returns the interface a connection's local endpoint sits on.
func interfaceForConn(c net.Conn) *net.Interface {
if c == nil {
return nil
}
switch a := c.LocalAddr().(type) {
case *net.TCPAddr:
return interfaceForIP(a.IP)
case *net.UDPAddr:
return interfaceForIP(a.IP)
}
return nil
}
// runMulticastSession handles the multicast mode session. // runMulticastSession handles the multicast mode session.
func (u *UDPClient) runMulticastSession() error { func (u *UDPClient) runMulticastSession() error {
tcpAddr, err := net.ResolveTCPAddr("tcp4", u.serverAddr) tcpAddr, err := net.ResolveTCPAddr("tcp4", u.serverAddr)
@@ -500,7 +546,15 @@ func (u *UDPClient) runMulticastSession() error {
return &net.AddrError{Err: "invalid multicast group IP", Addr: u.multicastGroup} return &net.AddrError{Err: "invalid multicast group IP", Addr: u.multicastGroup}
} }
mcastAddr := &net.UDPAddr{IP: mcastIP, Port: mcastPort} mcastAddr := &net.UDPAddr{IP: mcastIP, Port: mcastPort}
mcastConn, err := net.ListenMulticastUDP("udp4", nil, mcastAddr) // Join on the interface that reaches the control connection. The UDPStreamer
// pins its multicast sends to its configured Interface (IP_MULTICAST_IF), so
// a join with a nil interface — which leaves imr_interface at INADDR_ANY and
// lets the kernel pick the default-route interface — silently receives
// nothing whenever that is not the sending interface. The local address of
// the control connection is the interface the server is reachable on, which
// is the sending interface in every single-homed and same-host deployment.
ifi := interfaceForConn(tcpConn)
mcastConn, err := net.ListenMulticastUDP("udp4", ifi, mcastAddr)
if err != nil { if err != nil {
return err return err
} }
@@ -508,7 +562,12 @@ func (u *UDPClient) runMulticastSession() error {
if err := mcastConn.SetReadBuffer(udpRcvBufSize); err != nil { if err := mcastConn.SetReadBuffer(udpRcvBufSize); err != nil {
log.Printf("[%s] multicast SetReadBuffer: %v", u.sourceID, err) log.Printf("[%s] multicast SetReadBuffer: %v", u.sourceID, err)
} }
log.Printf("[%s] joined multicast %s:%s", u.sourceID, u.multicastGroup, strconv.Itoa(mcastPort)) ifName := "default"
if ifi != nil {
ifName = ifi.Name
}
log.Printf("[%s] joined multicast %s:%s on interface %s",
u.sourceID, u.multicastGroup, strconv.Itoa(mcastPort), ifName)
tcpDone := make(chan error, 1) tcpDone := make(chan error, 1)
go func() { go func() {
+1
View File
@@ -32,6 +32,7 @@ type SourceStat struct {
} }
// RecordFragment is called for every UDP datagram of a DATA packet. // RecordFragment is called for every UDP datagram of a DATA packet.
//
// complete: this fragment completed the DATA reassembly. // complete: this fragment completed the DATA reassembly.
// nBytes: raw datagram size (header+payload). // nBytes: raw datagram size (header+payload).
func (s *SourceStat) RecordFragment(counter uint32, nBytes int, arrivalNs int64, complete bool) { func (s *SourceStat) RecordFragment(counter uint32, nBytes int, arrivalNs int64, complete bool) {
+354 -8
View File
@@ -3,7 +3,9 @@ package wshub
import ( import (
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"log"
"math" "math"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -25,10 +27,30 @@ const (
// capture is extracted, so the rings have received the last samples. // capture is extracted, so the rings have received the last samples.
const captureMarginSec = 0.15 const captureMarginSec = 0.15
// captureStallSec is how long the stream may be silent before a collecting
// trigger gives up waiting for the rest of its window and delivers what it has.
const captureStallSec = 2.0
// autoRearmDelaySec is the pause between a completed capture and the automatic // autoRearmDelaySec is the pause between a completed capture and the automatic
// rearm in "normal" mode. // rearm in "normal" mode.
const autoRearmDelaySec = 0.2 const autoRearmDelaySec = 0.2
// trigCapturePts caps the points sent per signal in a capture frame. A window
// of 60 s at 1 MSps is 60 M raw samples — ~960 MB per signal on the wire, which
// no client can take and which the send path would simply drop. Matches the C++
// StreamHub's kTrigCapturePts.
const trigCapturePts = 20000
// shortCaptureTol is the fraction of the window a capture may miss at its front
// before it is reported. One min/max bucket of slack, not a quality target.
const shortCaptureTol = 0.01
// maxTriggerWindowSec bounds the capture window, matching the longest option
// the web UI offers. It is not a resolution limit: retuneRings buckets the
// rings so any window fits the per-signal memory budget, at the cost of storing
// min/max pairs rather than every sample.
const maxTriggerWindowSec = 600.0
// trigConfig is the client-settable part of the trigger. // trigConfig is the client-settable part of the trigger.
type trigConfig struct { type trigConfig struct {
signalKey string // "src:sig" or "src:sig[i]" signalKey string // "src:sig" or "src:sig[i]"
@@ -37,6 +59,7 @@ type trigConfig struct {
windowSec float64 windowSec float64
prePercent float64 prePercent float64
mode string // "normal" | "single" mode string // "normal" | "single"
holdoffSec float64 // rearm delay after a capture (double-trigger guard)
} }
// triggerEngine implements the hub-side trigger FSM. Its methods are safe to // triggerEngine implements the hub-side trigger FSM. Its methods are safe to
@@ -51,11 +74,34 @@ type triggerEngine struct {
state string state string
stopped bool stopped bool
// sentState is the state carried by the last stateMsg handed out. The
// armed→collecting transition happens inside feed(), on the ingest path,
// so the hub cannot see it by sampling State() across a tick — by the time
// the tick runs, ingest has already moved the FSM.
sentState string
// sentFill is the pre-fill fraction carried by the last stateMsg, so a
// trigger that is armed but still filling can report progress.
sentFill float64
// How far back the trigger signal's ring reaches and how fast that is
// growing (seconds of span per second of wall clock), refreshed by the hub.
// bufKnown is false when there is no ring to measure, which disables the
// fill gate rather than blocking the trigger on a measurement that will
// never arrive; bufRateOK is false until two measurements exist.
bufSpan float64
bufGrowth float64
bufKnown bool
bufRateOK bool
// Reference point the growth is measured against.
bufRefSpan, bufRefWall float64
prevValue float64 prevValue float64
prevValid bool prevValid bool
lastT float64 lastT float64
lastTOK bool lastTOK bool
// lastFeedWall is the wall clock at the last feed(), used only to notice a
// stalled stream — the window itself is measured on the sample clock.
lastFeedWall float64
trigTime float64 trigTime float64
firedPre float64 firedPre float64
@@ -67,7 +113,7 @@ type triggerEngine struct {
func newTriggerEngine() *triggerEngine { func newTriggerEngine() *triggerEngine {
return &triggerEngine{ return &triggerEngine{
cfg: trigConfig{edge: "rising", windowSec: 1, prePercent: 20, mode: "normal"}, cfg: trigConfig{edge: "rising", windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: autoRearmDelaySec},
elemIdx: -1, elemIdx: -1,
state: trigIdle, state: trigIdle,
} }
@@ -97,8 +143,8 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
if cfg.windowSec < 1e-4 { if cfg.windowSec < 1e-4 {
cfg.windowSec = 1e-4 cfg.windowSec = 1e-4
} }
if cfg.windowSec > 10 { if cfg.windowSec > maxTriggerWindowSec {
cfg.windowSec = 10 cfg.windowSec = maxTriggerWindowSec
} }
if cfg.prePercent < 0 { if cfg.prePercent < 0 {
cfg.prePercent = 0 cfg.prePercent = 0
@@ -106,8 +152,19 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
if cfg.prePercent > 100 { if cfg.prePercent > 100 {
cfg.prePercent = 100 cfg.prePercent = 100
} }
if cfg.holdoffSec < 0 {
cfg.holdoffSec = 0
}
if cfg.holdoffSec > 60 {
cfg.holdoffSec = 60
}
te.cfg = cfg te.cfg = cfg
te.baseKey, te.elemIdx = parseSignalKey(cfg.signalKey) base, idx := parseSignalKey(cfg.signalKey)
if base != te.baseKey {
// The buffer measurement belongs to the old signal's ring.
te.bufKnown, te.bufRateOK = false, false
}
te.baseKey, te.elemIdx = base, idx
te.prevValid = false te.prevValid = false
te.prevValue = 0 te.prevValue = 0
} }
@@ -168,6 +225,105 @@ func (te *triggerEngine) Active() bool {
return te.baseKey != "" return te.baseKey != ""
} }
// baseSignalKey is the configured trigger signal without its "[i]" suffix, or
// "" when no trigger signal is set.
func (te *triggerEngine) baseSignalKey() string {
te.mu.Lock()
defer te.mu.Unlock()
return te.baseKey
}
// bufGrowthIntervalSec is the shortest baseline the span growth is measured
// over. The hub refreshes 30 times a second and the span moves in steps as
// batches land, so a shorter baseline measures the batching, not the trend.
const bufGrowthIntervalSec = 0.5
// bufGrowthSmooth is the weight of a new growth measurement in the running
// estimate.
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) {
te.mu.Lock()
defer te.mu.Unlock()
if !known {
te.bufKnown, te.bufRateOK = false, false
return
}
if !te.bufKnown {
te.bufKnown = true
te.bufRefSpan, te.bufRefWall = span, now
}
te.bufSpan = span
dt := now - te.bufRefWall
if dt < bufGrowthIntervalSec {
return
}
g := (span - te.bufRefSpan) / dt
// A ring that is not full grows one second of span per second; one that is
// full grows by whatever its incoming samples free up. Neither can exceed 1,
// and a shrinking ring is simply not growing.
if g < 0 {
g = 0
} else if g > 1 {
g = 1
}
if te.bufRateOK {
g = te.bufGrowth + bufGrowthSmooth*(g-te.bufGrowth)
}
te.bufGrowth, te.bufRateOK = g, true
te.bufRefSpan, te.bufRefWall = span, now
}
// fillNeedLocked is how far back the buffer must reach before an edge may be
// accepted, so that the capture is still whole when it is harvested a
// post-window later.
//
// What has to hold at harvest time is that the buffer spans the whole window:
// its newest sample is then trigTime+post, so anything less has lost the front
// of the capture. The buffer keeps filling while the post-window is collected,
// though, so the shortfall it may start with is exactly what it will make up in
// that time — measured, not assumed:
//
// need = windowSec growth × postSec, floored at the pre-trigger window
//
// A ring that is still filling grows a second per second, which reduces this to
// the pre-trigger window: everything after the trigger is yet to be recorded
// 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.
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
if te.bufRateOK {
growth = te.bufGrowth
}
need := te.cfg.windowSec - growth*(te.cfg.windowSec-pre)
if need < pre {
need = pre
}
return need
}
// fillLocked is how much of that requirement is met, as a fraction in [0, 1].
// It is 1 whenever the gate does not apply: nothing needed, or no ring to
// measure.
func (te *triggerEngine) fillLocked() float64 {
need := te.fillNeedLocked()
if need <= 0 || !te.bufKnown || te.bufSpan >= need*(1-shortCaptureTol) {
return 1
}
if te.bufSpan <= 0 {
return 0
}
return te.bufSpan / need
}
// latchWindowLocked freezes the pre/post split at fire time so later config // latchWindowLocked freezes the pre/post split at fire time so later config
// edits do not change how the capture is rendered. // edits do not change how the capture is rendered.
func (te *triggerEngine) latchWindowLocked(t float64) { func (te *triggerEngine) latchWindowLocked(t float64) {
@@ -209,6 +365,7 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
} }
te.lastT = t[len(t)-1] te.lastT = t[len(t)-1]
te.lastTOK = true te.lastTOK = true
te.lastFeedWall = float64(time.Now().UnixNano()) / 1e9
if te.state != trigArmed { if te.state != trigArmed {
return return
} }
@@ -219,6 +376,17 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
} }
step, start = nElem, te.elemIdx step, start = nElem, te.elemIdx
} }
// Hold off while the buffer does not reach back far enough. Firing now would
// deliver a capture whose front is simply missing — the ring never held it —
// which is what made the first shot after a window change come back short.
// Track the level meanwhile, so the first edge once the buffer is deep
// enough is still measured against the right previous sample.
if te.fillLocked() < 1 {
for i := start; i < len(v); i += step {
te.prevValue, te.prevValid = v[i], true
}
return
}
thr := te.cfg.threshold thr := te.cfg.threshold
for i := start; i < len(t); i += step { for i := start; i < len(t); i += step {
if !te.prevValid { if !te.prevValid {
@@ -247,13 +415,28 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
// dueCapture reports whether a collecting trigger's post-window has elapsed and // dueCapture reports whether a collecting trigger's post-window has elapsed and
// returns the latched window. // returns the latched window.
//
// The window is measured on the sample clock, not the wall clock: trigTime is a
// sample timestamp, and a stream whose timestamps lag real time (a busy
// producer, a buffered link) would otherwise be cut short by exactly that lag —
// an 8 s lag turned a 60 s window into a 36 s capture. Waiting for the samples
// themselves also means the ring really holds the window by the time it is read.
func (te *triggerEngine) dueCapture(nowSec float64) (trigTime, pre, post float64, ok bool) { func (te *triggerEngine) dueCapture(nowSec float64) (trigTime, pre, post float64, ok bool) {
te.mu.Lock() te.mu.Lock()
defer te.mu.Unlock() defer te.mu.Unlock()
if te.state != trigCollecting || !te.firedValid { if te.state != trigCollecting || !te.firedValid {
return 0, 0, 0, false return 0, 0, 0, false
} }
if nowSec < te.trigTime+te.firedPost+captureMarginSec { deadline := te.trigTime + te.firedPost + captureMarginSec
switch {
case te.lastTOK && te.lastT >= deadline:
// The samples have covered the window.
case !te.lastTOK && nowSec >= deadline:
// No sample ever seen, so trigTime came from the wall clock (Force).
case te.lastFeedWall > 0 && nowSec-te.lastFeedWall >= captureStallSec:
// The stream has dried up; deliver what was collected rather than
// leaving the client stuck in "collecting" forever.
default:
return 0, 0, 0, false return 0, 0, 0, false
} }
return te.trigTime, te.firedPre, te.firedPost, true return te.trigTime, te.firedPre, te.firedPost, true
@@ -266,7 +449,7 @@ func (te *triggerEngine) markTriggered(nowSec float64) {
if te.state == trigCollecting { if te.state == trigCollecting {
te.state = trigTriggered te.state = trigTriggered
if te.cfg.mode != "single" && !te.stopped { if te.cfg.mode != "single" && !te.stopped {
te.rearmAt = nowSec + autoRearmDelaySec te.rearmAt = nowSec + te.cfg.holdoffSec
} }
} }
te.mu.Unlock() te.mu.Unlock()
@@ -283,17 +466,49 @@ func (te *triggerEngine) dueRearm(nowSec float64) bool {
return !te.stopped return !te.stopped
} }
// stateUnsent reports whether the FSM has moved since the last stateMsg was
// built, i.e. whether clients still have to be told.
func (te *triggerEngine) stateUnsent() bool {
te.mu.Lock()
defer te.mu.Unlock()
if te.state != te.sentState {
return true
}
// An armed trigger waiting for its buffer is otherwise indistinguishable
// from one that is ignoring edges, so the filling itself is news. Coarse
// steps only: this is checked 30 times a second.
if te.state == trigArmed {
f := te.fillLocked()
return math.Abs(f-te.sentFill) >= 0.02 || (f >= 1 && te.sentFill < 1)
}
return false
}
// stateMsg builds the JSON "triggerState" broadcast for the current FSM state. // stateMsg builds the JSON "triggerState" broadcast for the current FSM state.
func (te *triggerEngine) stateMsg() []byte { func (te *triggerEngine) stateMsg() []byte {
te.mu.Lock() te.mu.Lock()
te.sentState = te.state
te.sentFill = te.fillLocked()
m := map[string]any{ m := map[string]any{
"type": "triggerState", "type": "triggerState",
"state": te.state, "state": te.state,
"mode": te.cfg.mode, "mode": te.cfg.mode,
"stopped": te.stopped, "stopped": te.stopped,
} }
if te.state == trigArmed && te.sentFill < 1 {
// Armed but holding off: the buffer does not yet reach back far enough
// to deliver the window, so edges are being ignored on purpose.
m["bufferFill"] = te.sentFill
m["bufferNeedSec"] = te.fillNeedLocked()
}
if te.firedValid { if te.firedValid {
// The window latched at fire time. Clients draw the filling capture on
// this axis before the v2 frame arrives, and config edits between arm
// and fire would otherwise leave them inferring the wrong window from
// their own copy of the config.
m["trigTime"] = te.trigTime m["trigTime"] = te.trigTime
m["preSec"] = te.firedPre
m["postSec"] = te.firedPost
} }
te.mu.Unlock() te.mu.Unlock()
msg, _ := json.Marshal(m) msg, _ := json.Marshal(m)
@@ -331,6 +546,9 @@ func (h *Hub) handleTriggerCommand(t string, env map[string]interface{}) bool {
if f, ok := env["prePercent"].(float64); ok { if f, ok := env["prePercent"].(float64); ok {
cfg.prePercent = f cfg.prePercent = f
} }
if f, ok := env["holdoffSec"].(float64); ok {
cfg.holdoffSec = f
}
h.trigger.SetConfig(cfg) h.trigger.SetConfig(cfg)
case "arm", "rearm": case "arm", "rearm":
h.trigger.Arm() h.trigger.Arm()
@@ -347,34 +565,139 @@ func (h *Hub) handleTriggerCommand(t string, env map[string]interface{}) bool {
default: default:
return false return false
} }
// Measure the buffer now rather than waiting for the next tick: ingest runs
// on the source goroutine and a 1 MSps stream crosses the threshold many
// times within one 33 ms tick, so an arm serviced here would otherwise fire
// on a stale (or missing) measurement before the gate ever saw the new
// configuration.
h.refreshTriggerFill()
h.broadcastTriggerState() h.broadcastTriggerState()
return true return true
} }
// refreshTriggerFill tells the FSM how far back the trigger signal's ring
// reaches, which is what lets an armed trigger hold off until a capture taken
// now would come back whole.
//
// The ring is the right yardstick even though a short capture is back-filled
// from the archive: the archive is sized for the same window and starts over
// whenever that window changes, so it holds no more of the stretch being waited
// for than the ring does. It can only add to what the capture finds.
//
// Called both from the push tick and from the client goroutine handling a
// trigger command; all the state it derives lives in the engine, behind the
// engine's lock.
func (h *Hub) refreshTriggerFill() {
if h.trigger == nil {
return
}
now := float64(time.Now().UnixNano()) / 1e9
var rb *sigRing
if key := h.trigger.baseSignalKey(); 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)
return
}
_, span := rb.stats()
h.trigger.setBuffered(span, true, now)
}
// triggerTick services the trigger FSM; called from Hub.Run() on every push tick. // triggerTick services the trigger FSM; called from Hub.Run() on every push tick.
func (h *Hub) triggerTick() { func (h *Hub) triggerTick() {
nowSec := float64(time.Now().UnixNano()) / 1e9 nowSec := float64(time.Now().UnixNano()) / 1e9
prev := h.trigger.State()
h.retuneRings(nowSec)
h.openPendingHistoryFiles(nowSec)
h.refreshTriggerFill()
if trigTime, pre, post, ok := h.trigger.dueCapture(nowSec); ok { if trigTime, pre, post, ok := h.trigger.dueCapture(nowSec); ok {
if msg := h.buildTriggerCapture(trigTime, pre, post); msg != nil { if msg := h.buildTriggerCapture(trigTime, pre, post); msg != nil {
dropped := 0
for c := range h.clients { for c := range h.clients {
select { select {
case c.send <- wsMessage{websocket.BinaryMessage, msg}: case c.send <- wsMessage{websocket.BinaryMessage, msg}:
default: default:
dropped++
} }
} }
// A dropped capture is invisible to the user — the trigger fires,
// the state goes to "triggered" and no waveform ever arrives — so
// say so rather than leaving it to be guessed at.
if dropped > 0 {
log.Printf("wshub: trigger capture (%d B) dropped for %d client(s): send queue full",
len(msg), dropped)
}
} }
h.trigger.markTriggered(nowSec) h.trigger.markTriggered(nowSec)
// A capture is only zoomable for as long as its samples still exist at
// full resolution somewhere, and the rings roll past the window within
// seconds of it being taken. Lift the window out of the archive into a
// file of its own, where nothing overwrites it until the next trigger.
h.hist.captureRange(trigTime-pre, trigTime+post)
} else if h.trigger.dueRearm(nowSec) { } else if h.trigger.dueRearm(nowSec) {
h.trigger.Arm() h.trigger.Arm()
} }
if h.trigger.State() != prev { if h.trigger.stateUnsent() {
h.broadcastTriggerState() h.broadcastTriggerState()
} }
} }
// backfillCaptureHead prepends the front of [t0, t1] that the ring no longer
// holds, read from the disk archive. It returns its input unchanged when the
// ring already reaches t0, when history is off, or when the archive has nothing
// for that range.
//
// The rings are sized for the window, but they only have to *become* that long:
// they are min/max buckets that cover the configured window once they have
// rolled over completely at the current bucket, which takes as long as the
// window itself. Widen the window and arm, and the first captures ask for more
// history than the ring has ever stored — the frame then starts late and the
// user sees a blank front half. The archive is written straight through, at the
// geometry its file was created with, so unless that file was re-sized too it
// has kept the stretch the ring is still converging on.
func (h *Hub) backfillCaptureHead(key string, t0, t1 float64, st, sv []float64) ([]float64, []float64) {
window := t1 - t0
if !h.hist.enabled() || window <= 0 {
return st, sv
}
gapEnd := t1
if len(st) > 0 {
gapEnd = st[0]
}
gap := gapEnd - t0
if gap <= shortCaptureTol*window {
return st, sv
}
// Budget the read by the share of the window being back-filled. The frame is
// decimated to trigCapturePts either way, so a bigger read would buy nothing
// but disk seeks — on the hub's own goroutine, between two push ticks.
maxOut := int(float64(trigCapturePts)*gap/window) + 2
ht, hv := h.hist.readRange(key, t0, gapEnd, maxOut)
if len(ht) == 0 {
return st, sv
}
// Drop anything at or past the ring's first sample: the two sources overlap
// around the join, and the frame's timestamps must stay ascending.
n := len(ht)
if len(st) > 0 {
n = sort.SearchFloat64s(ht, st[0])
}
if n == 0 {
return st, sv
}
outT := make([]float64, 0, n+len(st))
outV := make([]float64, 0, n+len(sv))
outT = append(append(outT, ht[:n]...), st...)
outV = append(append(outV, hv[:n]...), sv...)
return outT, outV
}
// buildTriggerCapture extracts [trigTime-pre, trigTime+post] from every ring // buildTriggerCapture extracts [trigTime-pre, trigTime+post] from every ring
// buffer and encodes the version-2 binary capture frame: // buffer and encodes the version-2 binary capture frame:
// //
@@ -397,18 +720,41 @@ func (h *Hub) buildTriggerCapture(trigTime, pre, post float64) []byte {
h.ringsMu.RUnlock() h.ringsMu.RUnlock()
slices := make([]sigSlice, 0, len(keys)) slices := make([]sigSlice, 0, len(keys))
held := make(map[string]sigData, len(keys))
total := 1 + 8 + 8 + 8 + 4 total := 1 + 8 + 8 + 8 + 4
for i, k := range keys { for i, k := range keys {
st, sv := rings[i].slice(t0, t1) st, sv := rings[i].slice(t0, t1)
st, sv = h.backfillCaptureHead(k, t0, t1, st, sv)
if len(st) == 0 { if len(st) == 0 {
continue continue
} }
// Neither the ring nor the archive reached t0. Nothing can recover that
// data, so name it rather than leaving the user to wonder why the front
// of their window is blank.
if lost := st[0] - t0; lost > shortCaptureTol*(t1-t0) {
cnt, span := rings[i].stats()
log.Printf("wshub: capture %s is short by %.2f s of %.2f s: ring holds %.2f s (%d pts, min/max over %d)",
k, lost, t1-t0, span, cnt, rings[i].bucketSize())
}
// Take the second half of the double buffer here, before the frame is
// decimated: the client gets 20 000 points to draw, but a zoom into
// them has to come back with the underlying samples, and the rings will
// have rolled past them by the time it is asked for.
held[k] = sigData{T: st, V: sv}
// Decimate before framing: a long window at a high sample rate is
// hundreds of megabytes raw, which the send path would silently drop.
// The min/max envelope keeps every peak in the window, so a glitch is
// still on screen at the zoomed-out view that first shows it.
st, sv = minMaxDecimate(st, sv, trigCapturePts)
slices = append(slices, sigSlice{key: k, t: st, v: sv}) slices = append(slices, sigSlice{key: k, t: st, v: sv})
total += 2 + len(k) + 4 + len(st)*16 total += 2 + len(k) + 4 + len(st)*16
} }
if len(slices) == 0 { if len(slices) == 0 {
return nil return nil
} }
// Swap only now that the capture is known good. A shot that yielded nothing
// must leave the previous window on screen rather than blanking it.
h.capture.publish(t0, t1, held)
buf := make([]byte, total) buf := make([]byte, total)
buf[0] = 2 buf[0] = 2
@@ -0,0 +1,278 @@
package wshub
import (
"encoding/binary"
"math"
"testing"
)
// fillRing writes n samples at the given rate starting at t0.
func fillRing(rb *sigRing, t0 float64, rate float64, n int) {
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = t0 + float64(i)/rate
vs[i] = math.Sin(float64(i))
}
rb.write(ts, vs)
}
func TestRingGrowPreservesSamples(t *testing.T) {
rb := newSigRing(100)
// Overflow the ring so the retained window starts mid-buffer.
fillRing(rb, 0, 1000, 250)
beforeT, beforeV := rb.slice(-1e9, 1e9)
if len(beforeT) != 100 {
t.Fatalf("pre-grow fill = %d, want 100", len(beforeT))
}
if !rb.grow(1000) {
t.Fatal("grow(1000) returned false")
}
if rb.capacity() != 1000 {
t.Fatalf("capacity = %d, want 1000", rb.capacity())
}
afterT, afterV := rb.slice(-1e9, 1e9)
if len(afterT) != len(beforeT) {
t.Fatalf("post-grow fill = %d, want %d", len(afterT), len(beforeT))
}
for i := range beforeT {
if afterT[i] != beforeT[i] || afterV[i] != beforeV[i] {
t.Fatalf("sample %d changed across grow", i)
}
}
// Further writes must keep landing in order rather than wrapping early.
fillRing(rb, 1.0, 1000, 500)
if n, _ := rb.stats(); n != 600 {
t.Fatalf("fill after grow = %d, want 600", n)
}
// Shrinking is refused.
if rb.grow(10) {
t.Fatal("grow(10) shrank the ring")
}
}
func TestRingStatsMeasuresRate(t *testing.T) {
rb := newSigRing(10000)
fillRing(rb, 0, 1000, 1000) // 1 kHz
n, span := rb.stats()
if n != 1000 {
t.Fatalf("count = %d, want 1000", n)
}
rate := float64(n) / span
if math.Abs(rate-1001) > 5 { // n samples span (n-1) intervals
t.Fatalf("rate = %v, want ~1000", rate)
}
}
// A long trigger window must grow the rings to hold it: a fixed sample-count
// ring covers a fraction of a second at a high rate, which is what made 60 s
// captures come back with only their tail populated.
func TestRetuneRingsCoversTriggerWindow(t *testing.T) {
h := NewHub()
rb := newSigRing(6000) // 6 s at 1 kHz — far short of a 60 s window
fillRing(rb, 0, 1000, 6000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising",
windowSec: 60, prePercent: 20, mode: "normal"})
h.retuneRings(1000)
// 60 s at 1 kHz is 60 k samples: growing to the budget holds them verbatim.
if got := rb.capacity(); got < 60000 {
t.Fatalf("capacity = %d, want >= 60000 to hold a 60 s window", got)
}
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1: the window fits at full rate", got)
}
}
// Past the budget the window is kept by reducing resolution, not by dropping
// its head — the whole point of the min/max buckets.
func TestRetuneRingsBucketsWhenTheWindowExceedsTheBudget(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 60, mode: "normal"})
h.retuneRings(1000)
if got := rb.capacity(); got != defaultRingPts {
t.Fatalf("capacity = %d, want the budget %d", got, defaultRingPts)
}
// 60 s at 1 MSps is 60 M samples in a 10 M-point buffer, so each stored
// pair must cover at least 12 source samples.
bucket := rb.bucketSize()
if bucket < 12 {
t.Fatalf("bucket = %d, too fine to fit 60 M samples in %d points", bucket, rb.capacity())
}
if covered := float64(rb.capacity()) / 2 * float64(bucket) / 1e6; covered < 60 {
t.Fatalf("buffer covers %.1f s, want the whole 60 s window", covered)
}
}
// A raised budget buys resolution back: the same window is held verbatim.
func TestRetuneRingsHonoursRaisedBudget(t *testing.T) {
h := NewHub()
h.SetRingBudget(80_000_000)
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 60, mode: "normal"})
h.retuneRings(1000)
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1: 60 M samples fit in an 80 M-point buffer", got)
}
}
func TestSetRingBudgetBounds(t *testing.T) {
h := NewHub()
h.SetRingBudget(0)
if got := h.ringBudget(); got != defaultRingPts {
t.Fatalf("ringBudget after 0 = %d, want the default %d", got, defaultRingPts)
}
// Never below the depth a freshly configured ring already has, or the
// budget would ask for a shrink the ring refuses anyway.
h.SetRingBudget(10)
if got := h.ringBudget(); got != ringCapInitial {
t.Fatalf("ringBudget after 10 = %d, want the floor %d", got, ringCapInitial)
}
}
func TestRetuneRingsIsThrottled(t *testing.T) {
h := NewHub()
h.SetRingBudget(250_000)
rb := newSigRing(250_000)
fillRing(rb, 0, 1e6, 100_000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 10, mode: "normal"})
h.retuneRings(100)
first := rb.bucketSize()
if first <= 1 {
t.Fatalf("bucket = %d, expected a reduction for 10 s at 1 MSps in 250 k points", first)
}
// Same second: the sweep must not run again even though a bigger window
// is now configured.
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 600, mode: "normal"})
h.retuneRings(100.5)
if rb.bucketSize() != first {
t.Fatalf("sweep ran inside the throttle window")
}
h.retuneRings(200)
if rb.bucketSize() <= first {
t.Fatalf("sweep did not run after the throttle window elapsed")
}
}
// With no trigger armed and no client saying otherwise, the rings are sized for
// the default live window — live mode needs the buffers just as much as a
// capture does.
func TestRetuneRingsSizesForTheLiveWindow(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
fillRing(rb, 0, 1e6, 100_000) // 1 MSps: 10 s does not fit in 1000 points
h.rings["s1:sig"] = rb
// No signal configured → trigger inactive, so the live window governs.
h.trigger.SetConfig(trigConfig{windowSec: 600, mode: "normal"})
h.retuneRings(100)
if got := rb.capacity(); got != defaultRingPts {
t.Fatalf("capacity = %d, want the budget %d", got, defaultRingPts)
}
// defaultLiveWindowSec at 1 MSps is exactly the budget, so no reduction.
if got := rb.bucketSize(); got != 1 {
t.Fatalf("bucket = %d, want 1 for the default live window", got)
}
}
func TestRingBucketForCoversTheWindow(t *testing.T) {
cases := []struct {
rate, window float64
capacity int
want int
}{
{1000, 10, 1_000_000, 1}, // 10 k samples in 1 M points: verbatim
{1e6, 10, 10_000_000, 1}, // exactly the budget: still verbatim
{1e6, 60, 10_000_000, 15}, // 60 M samples, 1.25x headroom
{1e6, 600, 10_000_000, 150}, // 600 s still fits, at 1/150 resolution
{0, 10, 1_000_000, 1}, // no rate measured yet
{1000, 0, 1_000_000, 1}, // no window
}
for _, c := range cases {
if got := ringBucketFor(c.rate, c.window, c.capacity); got != c.want {
t.Errorf("ringBucketFor(%v, %v, %d) = %d, want %d",
c.rate, c.window, c.capacity, got, c.want)
}
}
}
// decodeCapture pulls the per-signal point counts out of a v2 capture frame.
func decodeCapture(t *testing.T, buf []byte) map[string]int {
t.Helper()
if buf[0] != 2 {
t.Fatalf("frame version = %d, want 2", buf[0])
}
off := 1 + 8 + 8 + 8
nSig := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
out := make(map[string]int, nSig)
for i := 0; i < nSig; i++ {
kl := int(binary.LittleEndian.Uint16(buf[off:]))
off += 2
key := string(buf[off : off+kl])
off += kl
n := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
off += n * 16
out[key] = n
}
if off != len(buf) {
t.Fatalf("decoded %d of %d bytes", off, len(buf))
}
return out
}
// A 60 s window at a high rate is hundreds of megabytes raw; the capture frame
// must be decimated so it can actually reach a client.
func TestBuildTriggerCaptureDecimates(t *testing.T) {
h := NewHub()
rb := newSigRing(200000)
fillRing(rb, 0, 100000, 200000) // 2 s at 100 kSps
h.rings["s1:sig"] = rb
buf := h.buildTriggerCapture(1.0, 1.0, 1.0)
if buf == nil {
t.Fatal("no capture frame built")
}
counts := decodeCapture(t, buf)
n := counts["s1:sig"]
if n != trigCapturePts {
t.Fatalf("captured %d points, want the %d-point cap", n, trigCapturePts)
}
}
// Short captures must stay full resolution — decimation only kicks in above
// the cap.
func TestBuildTriggerCaptureKeepsSmallWindowsIntact(t *testing.T) {
h := NewHub()
rb := newSigRing(10000)
fillRing(rb, 0, 1000, 10000) // 10 s at 1 kHz
h.rings["s1:sig"] = rb
buf := h.buildTriggerCapture(1.0, 0.5, 0.5)
if buf == nil {
t.Fatal("no capture frame built")
}
counts := decodeCapture(t, buf)
if n := counts["s1:sig"]; n < 990 || n > 1010 {
t.Fatalf("captured %d points, want ~1000 undecimated", n)
}
}
+324 -11
View File
@@ -1,6 +1,11 @@
package wshub package wshub
import "testing" import (
"encoding/json"
"math"
"testing"
"time"
)
func TestParseSignalKey(t *testing.T) { func TestParseSignalKey(t *testing.T) {
cases := []struct { cases := []struct {
@@ -26,7 +31,7 @@ func TestParseSignalKey(t *testing.T) {
func armed(key, edge string, thr float64) *triggerEngine { func armed(key, edge string, thr float64) *triggerEngine {
te := newTriggerEngine() te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: key, edge: edge, threshold: thr, te.SetConfig(trigConfig{signalKey: key, edge: edge, threshold: thr,
windowSec: 1, prePercent: 20, mode: "normal"}) windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: autoRearmDelaySec})
te.Arm() te.Arm()
return te return te
} }
@@ -96,6 +101,8 @@ func TestForceUsesLastSampleTime(t *testing.T) {
t.Fatalf("state = %q, want armed (threshold unreachable)", te.State()) t.Fatalf("state = %q, want armed (threshold unreachable)", te.State())
} }
te.Force() te.Force()
// post = 1 s, so the capture waits for samples past t = 12 + 1 + 0.15.
te.feed("src:sig", 1, []float64{13.2}, []float64{0})
trigTime, pre, post, ok := te.dueCapture(1e9) trigTime, pre, post, ok := te.dueCapture(1e9)
if !ok || trigTime != 12 || pre != 1 || post != 1 { if !ok || trigTime != 12 || pre != 1 || post != 1 {
t.Fatalf("dueCapture = (%v,%v,%v,%v), want (12,1,1,true)", t.Fatalf("dueCapture = (%v,%v,%v,%v), want (12,1,1,true)",
@@ -116,15 +123,50 @@ func TestForceFromIdle(t *testing.T) {
func TestCaptureMarginDelaysExtraction(t *testing.T) { func TestCaptureMarginDelaysExtraction(t *testing.T) {
te := armed("src:sig", "rising", 0.5) te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) // fires at t=1 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. // post = 0.8 s; capture is due once the samples reach 1 + 0.8 + 0.15.
if _, _, _, ok := te.dueCapture(1.9); ok { te.feed("src:sig", 1, []float64{1.9}, []float64{0})
if _, _, _, ok := te.dueCapture(1e9); ok {
t.Error("capture extracted before the margin elapsed") t.Error("capture extracted before the margin elapsed")
} }
if _, _, _, ok := te.dueCapture(1.96); !ok { te.feed("src:sig", 1, []float64{1.96}, []float64{0})
if _, _, _, ok := te.dueCapture(1e9); !ok {
t.Error("capture not extracted after the margin elapsed") t.Error("capture not extracted after the margin elapsed")
} }
} }
// A stream whose timestamps run behind real time must still yield the whole
// window: measuring the post-window on the wall clock cut the capture short by
// exactly the lag (an 8 s lag turned a 60 s window into a 36 s one).
func TestCaptureWaitsForLaggingStream(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
wallNow := float64(time.Now().UnixNano()) / 1e9
// Wall clock is far past the post-window, but the samples are not.
te.feed("src:sig", 1, []float64{1.5}, []float64{0})
if _, _, _, ok := te.dueCapture(wallNow); ok {
t.Error("capture extracted while the stream was still short of the window")
}
te.feed("src:sig", 1, []float64{2.0}, []float64{0})
if _, _, _, ok := te.dueCapture(wallNow); !ok {
t.Error("capture not extracted once the samples covered the window")
}
}
// A dead stream must not leave the client stuck in "collecting" forever.
func TestCaptureCompletesWhenStreamStalls(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
wallNow := float64(time.Now().UnixNano()) / 1e9
if _, _, _, ok := te.dueCapture(wallNow + captureStallSec/2); ok {
t.Error("capture extracted before the stall timeout")
}
if _, _, _, ok := te.dueCapture(wallNow + captureStallSec + 0.1); !ok {
t.Error("capture not extracted after the stream stalled")
}
}
func TestAutoRearmNormalMode(t *testing.T) { func TestAutoRearmNormalMode(t *testing.T) {
te := armed("src:sig", "rising", 0.5) te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1}) te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
@@ -167,13 +209,34 @@ func TestStoppedSuppressesRearm(t *testing.T) {
func TestSetConfigClamps(t *testing.T) { func TestSetConfigClamps(t *testing.T) {
te := newTriggerEngine() te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 100, prePercent: 500}) te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 1000, prePercent: 500, holdoffSec: 120})
if cfg := te.Config(); cfg.windowSec != 10 || cfg.prePercent != 100 { if cfg := te.Config(); cfg.windowSec != 600 || cfg.prePercent != 100 || cfg.holdoffSec != 60 {
t.Errorf("upper clamp = %v/%v, want 10/100", cfg.windowSec, cfg.prePercent) t.Errorf("upper clamp = %v/%v/%v, want 600/100/60", cfg.windowSec, cfg.prePercent, cfg.holdoffSec)
} }
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 0, prePercent: -5}) // The web UI's longest option must survive intact — it used to be clamped
if cfg := te.Config(); cfg.windowSec != 1e-4 || cfg.prePercent != 0 { // to 60 s, so a 10 min capture silently came back one minute long.
t.Errorf("lower clamp = %v/%v, want 1e-4/0", cfg.windowSec, cfg.prePercent) te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 600, prePercent: 20, holdoffSec: 1})
if cfg := te.Config(); cfg.windowSec != 600 {
t.Errorf("windowSec = %v, want the requested 600", cfg.windowSec)
}
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 0, prePercent: -5, holdoffSec: -1})
if cfg := te.Config(); cfg.windowSec != 1e-4 || cfg.prePercent != 0 || cfg.holdoffSec != 0 {
t.Errorf("lower clamp = %v/%v/%v, want 1e-4/0/0", cfg.windowSec, cfg.prePercent, cfg.holdoffSec)
}
}
func TestHoldoffControlsRearmDelay(t *testing.T) {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 0.5,
windowSec: 1, prePercent: 20, mode: "normal", holdoffSec: 5})
te.Arm()
te.feed("src:sig", 1, []float64{0, 1}, []float64{0, 1})
te.markTriggered(100)
if te.dueRearm(104.9) {
t.Error("rearmed before the configured holdoff elapsed")
}
if !te.dueRearm(105.1) {
t.Error("did not rearm after the configured holdoff elapsed")
} }
} }
@@ -192,3 +255,253 @@ func TestActiveTracksConfiguredSignal(t *testing.T) {
t.Error("engine must stay active after disarm while a signal is set") t.Error("engine must stay active after disarm while a signal is set")
} }
} }
// The armed→collecting transition happens inside feed(), on the ingest path,
// which the hub runs before triggerTick in the same loop iteration. Clients need
// that state — it carries trigTime and the latched window, without which they
// cannot draw the window filling and sit frozen until the capture arrives.
func TestCollectingIsBroadcast(t *testing.T) {
h := NewHub()
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 10, prePercent: 20, mode: "single"})
h.trigger.Arm()
h.triggerTick()
drainStates(t, h)
// Fire, but stay well inside the post-trigger window: the capture is still
// seconds away and this is exactly when the client has nothing to draw.
h.ingest("s1:sig", 1, []float64{5.0, 5.001}, []float64{-1, 1})
h.triggerTick()
states := drainStates(t, h)
found := false
for _, m := range states {
if m["state"] == trigCollecting {
found = true
if m["trigTime"] != 5.001 {
t.Errorf("collecting broadcast has trigTime %v, want 5.001", m["trigTime"])
}
if m["preSec"] != 2.0 || m["postSec"] != 8.0 {
t.Errorf("collecting broadcast has pre=%v post=%v, want 2 and 8",
m["preSec"], m["postSec"])
}
}
}
if !found {
t.Fatalf("no collecting broadcast after the trigger fired, got %v", states)
}
}
// setFill hands the engine a buffer span and a growth rate, as the hub's
// per-tick measurements would: a reference point and a second one a second
// 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)
}
// What has to hold is that the buffer spans the whole window by the time the
// capture is read, one post-window after the trigger fires — so whatever it
// will fill in on its own during that time need not be there yet.
func TestFillNeed(t *testing.T) {
cases := []struct {
window, prePercent, growth, want float64
}{
{100, 20, 1, 20}, // still filling: only the pre-window has to exist
{100, 20, 0.5, 60}, // half speed: 40 s of the 80 s post-window fills in
{100, 20, 0, 100}, // not growing at all: it must already be all there
{100, 0, 0.9, 10}, // no pre-window, but the buffer still has to keep up
{100, 100, 1, 100}, // all pre-window: nothing fills in after the trigger
}
for _, c := range cases {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", windowSec: c.window, prePercent: c.prePercent})
setFill(te, 1e6, c.growth, 100) // span large enough not to matter
te.mu.Lock()
got := te.fillNeedLocked()
te.mu.Unlock()
if math.Abs(got-c.want) > 1e-6 {
t.Errorf("fillNeed(window %v, pre %v%%, growth %v) = %v, want %v",
c.window, c.prePercent, c.growth, got, c.want)
}
}
}
// A trigger that fires before its pre-window has been buffered can only produce
// a capture whose front half never existed. It must wait instead.
func TestFillGateHoldsFire(t *testing.T) {
te := armed("src:sig", "rising", 0.5) // window 1 s, pre 20 % → 0.2 s needed
setFill(te, 0.05, 1, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: only 0.05 s of the 0.2 s pre-window is buffered", te.State())
}
// The level was still tracked, so the next crossing is a real edge and not a
// re-detection of the one that was held off.
setFill(te, 0.25, 1, 200)
te.feed("src:sig", 1, []float64{3, 4}, []float64{1, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: no crossing, the signal stayed high", te.State())
}
te.feed("src:sig", 1, []float64{5, 6}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the pre-window is buffered", te.State())
}
te.feed("src:sig", 1, []float64{7, 8}, []float64{1, 1}) // carry the sample clock past the window
if trigTime, _, _, ok := te.dueCapture(1e9); !ok || trigTime != 6 {
t.Errorf("dueCapture = (%v,%v), want trigTime 6", trigTime, ok)
}
}
// A ring that is full and re-bucketing for a longer window fills slower than
// real time — it drops dense old samples to take sparse new ones — so more of
// the window has to be there before an edge may be accepted.
func TestFillGateAccountsForSlowGrowth(t *testing.T) {
te := armed("src:sig", "rising", 0.5) // window 1 s, pre 20 % → post 0.8 s
// At half speed only 0.4 s of the post-window fills in, so 0.6 s is needed.
setFill(te, 0.5, 0.5, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigArmed {
t.Fatalf("state = %q, want armed: 0.5 s buffered of the 0.6 s needed", te.State())
}
// The same 0.5 s in a ring still filling at full speed is plenty: everything
// after the trigger is yet to be recorded anyway.
te2 := armed("src:sig", "rising", 0.5)
setFill(te2, 0.5, 1, 100)
te2.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te2.State() != trigCollecting {
t.Fatalf("state = %q, want collecting: the buffer keeps up with the stream", te2.State())
}
setFill(te, 0.65, 0.5, 200)
te.feed("src:sig", 1, []float64{3, 4}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the buffer will span the window", te.State())
}
}
func TestFillGateInactiveWithoutMeasurement(t *testing.T) {
// No ring for the configured signal: gating would leave the trigger armed
// forever, which is worse than a short capture.
te := armed("src:sig", "rising", 0.5)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting: nothing measured, so nothing to gate on", te.State())
}
// Nor is there anything to wait for when the buffer keeps up and the whole
// window is still to come.
te = newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "src:sig", edge: "rising", threshold: 0.5,
windowSec: 1, prePercent: 0, mode: "normal"})
te.Arm()
setFill(te, 0, 1, 100)
te.feed("src:sig", 1, []float64{1, 2}, []float64{0, 1})
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting with a 0 %% pre-window", te.State())
}
}
// Force is the user overriding the trigger, so it overrides the gate too.
func TestForceIgnoresFillGate(t *testing.T) {
te := armed("src:sig", "rising", 0.5)
setFill(te, 0, 0, 100)
te.Force()
if te.State() != trigCollecting {
t.Fatalf("state = %q, want collecting", te.State())
}
}
// seedFillNow is setFill against the real clock, for tests that then let the
// hub take its own measurements: its ticks land inside the growth measurement
// 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)
}
// While it holds off, the trigger looks identical to one that is ignoring
// edges. The state broadcast has to say it is filling, and keep saying so.
func TestFillProgressIsBroadcast(t *testing.T) {
h := NewHub()
rb := newSigRing(1000)
h.rings["s1:sig"] = rb
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", edge: "rising", threshold: 0,
windowSec: 10, prePercent: 50, mode: "single"}) // 5 s of pre-window
h.trigger.Arm()
rb.write([]float64{0, 1}, []float64{-1, -1})
// Filling at the rate of the stream, so only the pre-window is needed.
seedFillNow(h.trigger, 1, 1)
h.triggerTick()
states := drainStates(t, h)
if len(states) == 0 {
t.Fatal("no state broadcast while the trigger was filling")
}
last := states[len(states)-1]
if last["state"] != trigArmed {
t.Fatalf("state = %v, want armed", last["state"])
}
if f, _ := last["bufferFill"].(float64); f < 0.19 || f > 0.21 {
t.Errorf("bufferFill = %v, want ~0.2 (1 s of 5 s)", last["bufferFill"])
}
if last["bufferNeedSec"] != 5.0 {
t.Errorf("bufferNeedSec = %v, want 5", last["bufferNeedSec"])
}
// An edge now is ignored: there is no 5 s of history to capture.
h.ingest("s1:sig", 1, []float64{1.5, 2.0}, []float64{-1, 1})
if h.trigger.State() != trigArmed {
t.Fatalf("state = %q, want armed: the pre-window is only 20 %% buffered", h.trigger.State())
}
// Progress is news even though the state has not moved.
rb.write([]float64{2, 3}, []float64{-1, -1})
h.triggerTick()
if states = drainStates(t, h); len(states) == 0 {
t.Fatal("no state broadcast as the pre-window filled further")
}
if f, _ := states[len(states)-1]["bufferFill"].(float64); f < 0.59 || f > 0.61 {
t.Errorf("bufferFill = %v, want ~0.6 (3 s of 5 s)", states[len(states)-1]["bufferFill"])
}
// Full: the gate opens, the fill disappears from the message and the next
// edge fires.
rb.write([]float64{4, 5.2}, []float64{-1, -1})
h.triggerTick()
states = drainStates(t, h)
if len(states) == 0 {
t.Fatal("no state broadcast when the pre-window filled")
}
if _, ok := states[len(states)-1]["bufferFill"]; ok {
t.Errorf("bufferFill still reported once the pre-window is buffered: %v", states[len(states)-1])
}
h.ingest("s1:sig", 1, []float64{5.3, 5.4}, []float64{-1, 1})
if h.trigger.State() != trigCollecting {
t.Fatalf("state = %q, want collecting once the pre-window is buffered", h.trigger.State())
}
}
// drainStates decodes every triggerState frame the hub has queued for
// broadcast. Hub.Run is what normally drains this queue, and it is not running
// in these tests.
func drainStates(t *testing.T, h *Hub) []map[string]any {
t.Helper()
var out []map[string]any
for {
select {
case msg := <-h.broadcastCh:
var m map[string]any
if err := json.Unmarshal(msg, &m); err != nil {
continue
}
if m["type"] == "triggerState" {
out = append(out, m)
}
default:
return out
}
}
}
+60 -1
View File
@@ -1,6 +1,65 @@
package wshub package wshub
import "testing" import (
"math"
"testing"
)
// A scope's envelope must not lose a spike, however narrow, and must stay in
// time order so it can be plotted as a single trace.
func TestMinMaxDecimateKeepsExtremes(t *testing.T) {
const n = 10000
ts := make([]float64, n)
vs := make([]float64, n)
for i := range ts {
ts[i] = float64(i) * 1e-6
vs[i] = math.Sin(float64(i) * 0.01)
}
// A one-sample spike in each direction: exactly what plain decimation drops.
vs[4321] = 12.5
vs[6789] = -9.75
dt, dv := minMaxDecimate(ts, vs, 200)
if len(dt) > 200 || len(dt) != len(dv) {
t.Fatalf("got %d t / %d v points, want <= 200 of each", len(dt), len(dv))
}
hiSeen, loSeen := false, false
for i := range dv {
switch dv[i] {
case 12.5:
hiSeen = true
if dt[i] != ts[4321] {
t.Errorf("spike kept at t=%v, want %v: timestamps must be the real ones", dt[i], ts[4321])
}
case -9.75:
loSeen = true
}
if i > 0 && dt[i] < dt[i-1] {
t.Fatalf("output is not time-ordered at %d: %v after %v", i, dt[i], dt[i-1])
}
}
if !hiSeen || !loSeen {
t.Errorf("envelope lost a spike (max kept=%v, min kept=%v)", hiSeen, loSeen)
}
}
func TestMinMaxDecimatePassesShortInputThrough(t *testing.T) {
ts := []float64{1, 2, 3}
vs := []float64{4, 5, 6}
dt, dv := minMaxDecimate(ts, vs, 200)
if len(dt) != 3 || dv[2] != 6 {
t.Errorf("input below the budget was altered: %v / %v", dt, dv)
}
// A flat bucket contributes one point, not two: nothing is invented.
flatT := make([]float64, 100)
flatV := make([]float64, 100)
for i := range flatT {
flatT[i] = float64(i)
}
if ft, _ := minMaxDecimate(flatT, flatV, 10); len(ft) != 5 {
t.Errorf("flat input decimated to %d points, want 5 (one per bucket)", len(ft))
}
}
func TestZoomPoints(t *testing.T) { func TestZoomPoints(t *testing.T) {
cases := []struct { cases := []struct {
+58 -6
View File
@@ -120,8 +120,11 @@ Force a broadcast of the corresponding event.
- `signal` — full key `src:sig`, or `src:sig[i]` to trigger on element *i* of a - `signal` — full key `src:sig`, or `src:sig[i]` to trigger on element *i* of a
multi-element PACKET signal. multi-element PACKET signal.
- `edge``"rising"`, `"falling"` or `"both"`. - `edge``"rising"`, `"falling"` or `"both"`.
- `windowSec` — total capture window (clamped to 1e-4 … 10 s). - `windowSec` — total capture window. `preSec = windowSec * prePercent / 100`,
`preSec = windowSec * prePercent / 100`, `postSec = windowSec preSec`. `postSec = windowSec preSec`. Clamped to 1e-4 … 600 s by the Go hub and to
1e-4 … 60 s by the C++ one: the Go rings store min/max pairs once a window
outgrows their memory budget, so a long window costs resolution, while the C++
rings are fixed-capacity and would return the window truncated instead.
- `mode``"normal"` (auto-rearm ~200 ms after capture) or `"single"` - `mode``"normal"` (auto-rearm ~200 ms after capture) or `"single"`
(stays TRIGGERED until `rearm`). (stays TRIGGERED until `rearm`).
@@ -175,6 +178,33 @@ Every transition is broadcast as a `triggerState` event.
Request the hub to send a `historyInfo` event (unicast). Also sent automatically Request the hub to send a `historyInfo` event (unicast). Also sent automatically
on client connect. on client connect.
### `setHistoryBudget` (Go hub only)
```json
{"type":"setHistoryBudget","maxMPtsPerSignal":16.0}
```
Sets the per-signal archive budget in millions of stored points, the runtime
equivalent of `-history-max-mpts`. `0` restores the 16 MPts default; the hub
clamps to its own ceiling. Every archive file is re-created at the new size —
**the archived samples are lost**, because a file's capacity and min/max bucket
width are fixed at creation. Broadcasts `historyInfo` rather than answering the
requester alone: every client's view of what history exists has been invalidated.
### `setWindow` (Go hub only)
```json
{"type":"setWindow","seconds":60}
```
Reports how far back this client is plotting. The hub sizes its in-memory
buffers for the **widest** window any connected client has reported (10 s if
none has), bucketing each ring as min/max pairs when the window is too long to
hold verbatim — see *In-memory buffer policy* in
[StreamHub-Developer.md](StreamHub-Developer.md). While a trigger is armed the
trigger's own window wins. No reply; send it on connect and whenever the
timescale changes. A window the hub is not told about is a window whose start
may already have rolled out of the ring, leaving a `zoom` over it nothing to
answer with.
### `setMaxPoints` ### `setMaxPoints`
```json ```json
@@ -229,6 +259,20 @@ Sent at `StatsRate` Hz (default 1 Hz):
`state``idle | armed | collecting | triggered`; `trigTime` present once a `state``idle | armed | collecting | triggered`; `trigTime` present once a
trigger has fired. trigger has fired.
The Go hub adds `bufferFill` (0…1) and `bufferNeedSec` while `state` is `armed`
**and** its buffers do not yet reach back far enough to deliver a whole window.
Edges are ignored until they do, so that no capture arrives with a front that
was never recorded; the fields are absent once the requirement is met.
`bufferNeedSec` is how far back the hub must reach *now*, which is less than the
window by however much its buffers will fill in on their own while the
post-trigger window is collected: the pre-trigger span while they keep up with
the stream, and up to the whole `windowSec` when they do not (a full ring
re-bucketing for a longer window fills slower than real time, so the front of a
capture recedes while it is being collected).
The event is re-broadcast as the fraction grows, so a client can show the
progress instead of an armed trigger that appears to be ignoring the signal.
`forceTrigger` fires regardless.
### `zoom` (reply) ### `zoom` (reply)
```json ```json
@@ -243,18 +287,26 @@ trigger has fired.
Sent on client connect (if history is enabled) and on `historyInfo` command: Sent on client connect (if history is enabled) and on `historyInfo` command:
```json ```json
{"type":"historyInfo","enabled":true,"durationHours":1.0,"decimation":10, {"type":"historyInfo","enabled":true,"windowSec":600.0,"decimation":10,
"maxMPtsPerSignal":16.777216,
"signals":{ "signals":{
"scalar:Sine1":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000}, "scalar:Sine1":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000,"bucket":1},
"scalar:Sine2":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000}}} "scalar:Sine2":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000,"bucket":1}}}
``` ```
- `enabled``true` if the `+History` config block is present and valid. - `enabled``true` if the `+History` config block is present and valid.
- `durationHours` — configured history duration. - `windowSec` — the timespan the files are sized to hold, i.e. the live or
trigger window the clients are displaying (Go hub). The C++ StreamHub instead
keeps a fixed retention period and reports it as `durationHours`.
- `decimation` — samples-to-disk decimation factor (1 = every sample). - `decimation` — samples-to-disk decimation factor (1 = every sample).
- `maxMPtsPerSignal` — current per-signal budget, in millions of stored points
(Go hub only; see `setHistoryBudget`).
- `signals` — per-signal metadata keyed by `"sourceId:signalName"`: - `signals` — per-signal metadata keyed by `"sourceId:signalName"`:
- `t0`/`t1` — oldest/newest timestamp stored on disk (Unix seconds). - `t0`/`t1` — oldest/newest timestamp stored on disk (Unix seconds).
- `count` — number of valid entries currently in the circular file. - `count` — number of valid entries currently in the circular file.
- `capacity` — total capacity of the circular file. - `capacity` — total capacity of the circular file.
- `bucket` — source samples per stored min/max pair (Go hub only); `1` means
the signal is archived verbatim, higher means it is stored as an envelope
because it is too fast to fit the budget at full resolution.
### `historyZoom` (reply) ### `historyZoom` (reply)
+253 -10
View File
@@ -60,8 +60,20 @@ Each session calibrates per time-source:
`packetT = pktCalibOffset + hrt/hrtFreq`. `packetT = pktCalibOffset + hrt/hrtFreq`.
- Each referenced time signal gets its own offset on first value; - Each referenced time signal gets its own offset on first value;
`timerToSec = 1e-9` for `uint64` time signals, `1e-6` otherwise. `timerToSec = 1e-9` for `uint64` time signals, `1e-6` otherwise.
- Re-anchoring on reconnect, CONFIG change, or if computed time drifts > 2 s - The time-signal offset is **snapped** only on a genuine discontinuity in the
from wall clock (source restart / remote-vs-local HRT frequency drift). source: reconnect, CONFIG change, or the source clock jumping backward (a
looping/rewinding producer such as a rewinding `FileReader`).
- Plain *drift* — a source free-running on its own clock, or remote-vs-local HRT
frequency error — is **slewed**, not snapped. Past a 2 s threshold the offset
is nudged toward wall clock by at most 10 % of the packet's own duration.
Snapping instead would shift the whole published timeline in one step and so
tear a hole of exactly the drift into a stream that is in fact continuous;
a source drifting past the threshold repeatedly used to produce a train of
2 s holes. Drift is the honest reading, and the trade-off `TimeArrayGAM`'s
`Anchor = Continuous` explicitly asks for: a producer that cannot sustain its
nominal sample rate will fall progressively behind wall clock, and the hub
reports that rather than hiding it. The Go hub anchors once and never
re-anchors, so it never had the hole.
Per `timeMode`: Per `timeMode`:
@@ -94,16 +106,50 @@ Hub-side, web-client semantics (`setTrigger` fields in
[StreamHub-API.md](StreamHub-API.md)): [StreamHub-API.md](StreamHub-API.md)):
``` ```
IDLE --arm--> ARMED --edge crossing--> COLLECTING --wallNow ≥ trigTime+postSec+0.15s--> TRIGGERED IDLE --arm--> ARMED --edge crossing--> COLLECTING --every source past trigTime+postSec+0.15s--> TRIGGERED
TRIGGERED --rearm (single) / auto ~200ms (normal, unless stopped)--> ARMED TRIGGERED --rearm (single) / auto ~200ms (normal, unless stopped)--> ARMED
any --disarm--> IDLE any --disarm--> IDLE
``` ```
`UDPSourceSession` calls `TriggerEngine::CheckSample` for every decoded sample `UDPSourceSession` calls `TriggerEngine::CheckSample` for every decoded sample
of the configured signal (signal index cached per config epoch). On of the configured signal (signal index cached per config epoch). Each source is
finalisation the push loop reads `[trigTimepreSec, trigTime+postSec]` from all read `[trigTimepreSec, trigTime+postSec]`, LTTB-capped to 20 000 pts/signal and
rings, LTTB-caps to 20 000 pts/signal and broadcasts a binary **version 2** appended to a binary **version 2** capture frame; every FSM transition
capture frame; every FSM transition broadcasts a `triggerState` event. broadcasts a `triggerState` event.
Once fired, that event carries `trigTime` **and** the window latched at fire
time (`preSec`/`postSec`). Clients draw the still-filling capture from their own
buffers on that axis long before the v2 frame arrives — for a long window at a
high rate the hub stays silent for seconds — and the trigger bar's window and
pre-% are editable, so without the latched values a client would place the
filling trace on whatever window the operator happened to be typing. Older hubs
omit both fields; clients fall back to their local config.
The COLLECTING deadline is on the **data's** clock, via
`UDPSourceSession::ProducerNewestTime()``trigTime` comes from sample
timestamps, and a source free-running on its own clock sits seconds away from
`clock_gettime()`, so a wall-clock deadline chops exactly that offset off every
capture's tail. Only signals actually timestamped from a time signal count
toward that reading: PACKET-timed ones (including the time array itself) are
stamped on arrival and would just report "now".
Sources are harvested independently — `BeginTriggerCapture`,
`HarvestTriggerCapture` per source as *it* becomes ready, `FinishTriggerCapture`
once all are in — with the frame accumulating in `capBuf_` across push ticks.
Waiting for the slowest source before reading any of them lets the leaders'
rings roll past the pre-trigger region first, losing the head of their traces. A
2 s wall-clock watchdog bounds the wait for a source that stopped advancing: it
is harvested short, with a warning naming the source and how far it got.
`setTrigger` also records the requested window, and each stats tick the push
loop runs `GrowRingsForTrigger()`. A ring whose measured rate
(`Count() / TimeSpan()`, since UDPS sources usually advertise
`samplingRate = 0`) cannot hold `window + 0.5 s` is grown in place to
`rate × (window + 0.5) × 1.2` points, clamped to `RingMaxMB` per signal.
`SignalRingBuffer::Grow()` copies oldest→newest and leaves `count` /
`totalWritten` untouched so the per-client push cursors survive the resize.
Rings never shrink; a hub left with a 5 s window on a 5 MSps source will sit at
the ceiling.
## 6. Configuration ## 6. Configuration
@@ -113,9 +159,16 @@ MaxPoints = 20000 // legacy global cap (overridable with -maxPoints)
PushRate = 30 // Hz PushRate = 30 // Hz
MaxPushPoints = 50 // per signal per push MaxPushPoints = 50 // per signal per push
StatsRate = 1 // Hz StatsRate = 1 // Hz
RingTemporal = 1000000 // ring capacity, temporal signals (pts) RingTemporal = 1000000 // initial ring capacity, temporal signals (pts)
RingScalar = 100000 // ring capacity, scalar/PACKET signals (pts) RingScalar = 100000 // ring capacity, scalar/PACKET signals (pts)
RingMaxMB = 128 // per-signal growth ceiling (MiB) for trigger windows
SourcesFile = "streamhub_sources.json" // saveSources persistence SourcesFile = "streamhub_sources.json" // saveSources persistence
AllowedOrigins = "http://127.0.0.1:8099,http://localhost:8099"
// comma/space-separated WebSocket Origin allowlist (max 8 × 128 chars).
// Without it the handshake only accepts an Origin whose host matches
// the request Host, so a browser serving the SPA from another port
// (run_streamhub.sh: SPA 8099, hub 8090) gets 403. Non-browser
// clients send no Origin and are unaffected.
Sources = { Sources = {
Src1 = { Label = "PSU" Addr = "127.0.0.1" Port = 44500 Src1 = { Label = "PSU" Addr = "127.0.0.1" Port = 44500
MulticastGroup = "239.0.0.1" DataPort = 44503 } // multicast optional MulticastGroup = "239.0.0.1" DataPort = 44503 } // multicast optional
@@ -145,6 +198,54 @@ Per-signal file capacity is computed at source CONFIG time:
`capacity = ceil(DurationHours × 3600 × samplingRate / Decimation)`, minimum `capacity = ceil(DurationHours × 3600 × samplingRate / Decimation)`, minimum
1000 pairs. 1000 pairs.
The Go hub (`Client/udpstreamer`) carries the same archive and the same file
format, configured with flags instead of a config node: `-history-dir`
(defaults to `<tmp>/udpstreamer-history`; empty disables),
`-history-window-sec`, `-history-decimation`, `-history-flush-sec`,
`-history-min-free-mb` (negative disables the check; 0 means the 500 MB default,
where the C++ `MinDiskFreeMB = 0` disables it) and `-history-max-mpts`, a
per-signal budget in millions of stored points, defaulting to 16 MPts (256 MB).
The budget exists because the timespan alone cannot bound the file: 600 s of a
1 MSps signal is 9.6 GB.
**The Go hub sizes its files from the window, not from a retention period.** The
archive exists to answer a zoom or a trigger capture after the in-memory rings
have rolled past it, and neither ever asks for more than the live or trigger
window — so a file holds `windowSec × rate` samples (plus 25 % headroom, since a
capture is read back a window after its first sample was written), and never
hours of them. Retaining an hour instead meant a 1 s live window was archived at
a thousandth of the resolution the same budget could have bought.
The budget is therefore spent on resolution, not on span. A signal too fast to
archive sample-for-sample within it is stored as a **min/max envelope**: `bucket`
source samples collapse to their two extremes, with `bucket` the narrowest that
makes the window fit. The `.shist` header's `decimation` field carries
`bucket × Decimation`, so a reader knows the stored resolution, and a file is
only reopened when it matches.
`Hub.retuneRings` re-sizes the files once a second alongside the rings, from the
same `activeWindowSec()`. A file's capacity and bucket are fixed at creation, so
a re-size discards what it held; two rules keep that rare. A file is only grown
when it no longer covers the window, and only shrunk when it is enveloped
(`bucket > 1`), covers more than twice the window, and a narrower bucket is
actually available — a file already at full resolution is left alone however
short the window becomes, so arming a 1 s trigger does not throw away the
seconds the capture is about to ask for. `historyInfo` is re-broadcast whenever a
re-size happens.
The budget is also settable at runtime from the web UI (the history badge in the
status bar) via the `setHistoryBudget` WS command; `historyInfo` reports it as
`maxMPtsPerSignal` and reports each signal's `bucket`. Changing it re-creates the
files, so the archived samples are lost — a file's capacity and bucket width are
fixed at creation and an existing envelope cannot be re-bucketed into a different
one.
History is on by default in the Go hub because it is what holds a trigger capture
at full resolution — see *Trigger captures* below. Signals whose producer
declares `samplingRate = 0` — every UDPS source — are not sized from a guess: the
file is opened only once the hub has measured the rate off the live stream, which
it retries once a second.
### `.shist` binary file format ### `.shist` binary file format
Each signal gets one file: `<Directory>/<sourceId>/<signalName>.shist`. Each signal gets one file: `<Directory>/<sourceId>/<signalName>.shist`.
@@ -178,13 +279,155 @@ reopened — head/count/time bounds are restored from the on-disk header.
`historyZoom` requests (see [StreamHub-API.md](StreamHub-API.md)) call `historyZoom` requests (see [StreamHub-API.md](StreamHub-API.md)) call
`HistoryWriter::ReadRange` which performs binary search over the circular file `HistoryWriter::ReadRange` which performs binary search over the circular file
using `pread` to locate the `[t0, t1]` window, then copies matching pairs. using `pread` to locate the `[t0, t1]` window, then copies matching pairs.
If the result exceeds the requested `n`, LTTB decimation is applied (same If the result exceeds the requested `n`, decimation is applied (same decimator
`LTTBDecimate` as in-memory zoom). as in-memory zoom: `LTTBDecimate` in the C++ hub, `minMaxDecimate` in the Go
one).
Both the web SPA and ImGui client issue `historyZoom` in parallel with regular Both the web SPA and ImGui client issue `historyZoom` in parallel with regular
`zoom` and merge the results: history covers the older part of the visible `zoom` and merge the results: history covers the older part of the visible
window, the in-memory ring covers the recent part. window, the in-memory ring covers the recent part.
A range wider than the read budget is thinned across its whole width with a
stride, not truncated at the front: answering a 10 s query with its first
few milliseconds reads as an empty plot to a client and sends it back to its
own coarse copy of the data.
### In-memory buffer policy (Go hub)
Each temporal signal gets one ring holding a fixed **budget** of `(t, v)` pairs:
10 M points, 160 MB, settable with `-ring-mpts`. Scalar signals keep a flat
100 000-packet ring, where a megasample budget would be waste. Rings start at
250 k points and are grown to the budget on demand, so a source that is
configured but never sends costs nothing.
Like the disk archive, the budget buys **resolution, not span**. Once a second
`retuneRings` compares the measured source rate against the window being
displayed and picks each ring's min/max `bucket`:
| condition | bucket | effect |
|---|---|---|
| `Sps × window ≤ budget` | 1 | stored verbatim; the ring reaches further back than the window, which is free zoom headroom |
| `Sps × window > budget` | `⌈2 × Sps × window × 1.25 ÷ capacity⌉` | `bucket` samples collapse to their two extremes, so the whole window fits |
A bucket costs two points (its minimum and its maximum), hence the factor 2 —
and why a bucket of 2 covers no more ground than a bucket of 1.
The window is the **trigger's** while a trigger is armed: its pre-window has to
already be in the ring when the trigger fires, or the capture has nothing to
back-fill from. Otherwise it is the widest window any connected client has
reported with the `setWindow` command, defaulting to 10 s for clients that never
send one. Sizing for the live window matters as much as for a capture: a fixed
sample-count ring covers ~6 s at 1 MSps, so a zoom on a 60 s timescale used to
come back with only its tail.
Retuning is hysteretic — a bucket is held while it covers the window without
covering more than twice it. Sharing one threshold for up and down makes a rate
jittering across a bucket boundary halve and double the stored resolution every
second.
Live pushes, the disk archive and the trigger comparator all see every sample:
`ingest` hands the raw batch to each, and only the ring's own copy is reduced.
### Trigger captures (Go hub)
A trigger capture is delivered as a decimated snapshot (20 000 points), so a
zoom into it has to come from full-resolution storage. The rings are tuned to
~1.25× the trigger window, so they roll past a captured window shortly after the
capture — and the trigger rearms and starts refilling them immediately.
**In-memory double buffer.** The rings are the write half; `captureHold`
(`capturehold.go`) is the read half. As `buildTriggerCapture` lifts each signal's
window out of its ring it publishes the *undecimated* slice into the hold, and
`zoomSlice` answers from the hold rather than the ring for any range the held
window fully contains. The swap happens only once the next capture is complete —
which is also the moment the client stops displaying the previous one — so the
shot being explored is never overwritten by the acquisition running behind it. A
capture that came back empty does not swap, so it cannot blank the window on
screen.
**Waiting for the buffer.** An armed trigger ignores edges until its buffers
reach back far enough for a capture taken now to come back whole (`fillLocked`
in `trigger.go`, fed by `refreshTriggerFill` from the trigger signal's own ring
— once per tick, and again on every trigger command so that an `arm` cannot
fire on a stale measurement). Firing earlier can only produce a capture whose
front was never recorded, which is what made the first shot after a widened
window come back short.
What must hold is that the buffer spans the whole window *at harvest time* — its
newest sample is then `trigTime + post`, so anything less has lost the front of
the capture. It keeps filling while the post-window is collected, so the
shortfall it may start with is what it will make up in that time, measured
rather than assumed:
```
need = windowSec growth × postSec (floored at the pre-trigger window)
```
`growth` is the ring's span growth in seconds per second, sampled over at least
`bufGrowthIntervalSec` and smoothed. The three regimes fall out of the one
formula:
| ring | growth | needs |
|---|---|---|
| still filling | 1 | the pre-trigger window — everything after the trigger is yet to be recorded anyway |
| full, re-bucketing for a longer window | 0…1 | in between: it drops dense old samples to take sparse new ones, so it fills slower than real time and the front of the capture recedes while the post-window elapses |
| full, settled | 0 | the whole window — which a ring tuned for that window already exceeds, so nothing actually waits |
Measured at 1 MSps, widening 10 s → 30 s with 50 % pre: growth settles at ~0.65,
so `need` converges on ~20.4 s of the 30 s and the trigger fires ~12 s after
arming with a capture that is 100 % complete. Requiring the whole window instead
would have waited 26 s for the same result.
The gate measures the trigger signal's ring, not the narrowest of all of them: a
signal that never reaches back that far would otherwise stop the trigger from
ever firing. It is disabled outright when there is no ring to measure or nothing
is needed, and `forceTrigger` overrides it. While it holds off, `triggerState`
carries `bufferFill`/`bufferNeedSec` and is re-broadcast as the fraction climbs,
so the UI shows `ARMED 42%` rather than a trigger that looks stuck.
**Back-filling a short capture.** A ring only spans the window once it has
rolled over completely at its current min/max bucket, which takes as long as the
window itself; widen the window, or arm right after setting it, and the first
captures start late and the client draws a blank front half.
`backfillCaptureHead` (`trigger.go`) therefore prepends whatever of
`[t0, ring's first sample)` the archive still holds, budgeting the read by the
share of the window being filled and trimming the overlap so the frame's
timestamps stay ascending. It needs history enabled; without it the capture is
simply short, and the hub logs by how much. The hold declines any range its own
samples do not actually cover, so a stretch neither source could supply falls
through to the archive instead of being redrawn as the same hole on every zoom
and every *fit*.
The hold declines ranges reaching outside its window: those are live zooms, and
only the rings still track the stream. Inside the window it needs no
trigger-state gating, because retuning never rewrites stored samples — a ring
that still covers the range holds the very same points. It is cleared when
`updateConfig` rebuilds the rings, since a restarted producer can replay the same
timestamps.
Budget: the hold costs one window per signal on top of the ring budget, up to a
further ~0.8 × `-ring-mpts`. Nothing is held until the first capture fires.
**On disk.** The archive covers what the hold cannot: ranges wider than the
capture window, and sessions where the hub restarted. It is circular and sized
from that same window, so it too wraps over a captured shot within a window of
delivering it. When a capture is delivered, the hub therefore copies
`[trigTime pre, trigTime + post]` out of each `.shist` into a
`<signalName>.cap` file, laid out as a full non-wrapping `.shist`
(`capacity == count`, `head == 0`) so the same `readRange` reads it. A
`historyZoom` whose range the capture file fully contains is answered from it;
anything wider is answered from the archive. The copy is replaced by the next
trigger and by nothing else — rearming keeps it, because the client is still
showing that capture.
Budget: a capture costs one window's worth of disk per signal on top of
`-history-max-mpts`.
Protecting the window in place instead — pinning the region and refusing to
wrap onto it — does not work, and was tried: a capture held for longer than the
archive covers stops the archive dead, and the resulting hole lands exactly
where the *next* capture's pre-trigger window belongs.
## 7. Build & test ## 7. Build & test
```bash ```bash
+16 -1
View File
@@ -69,10 +69,25 @@ See `Docs/SineArrayGAM.md`.
### TimeArrayGAM ### TimeArrayGAM
Generates a time-reference float64 array. Each element holds the timestamp of the Generates a time-reference uint64 array. Each element holds the timestamp of the
corresponding sample in a packed burst, computed from the RT cycle timestamp and the corresponding sample in a packed burst, computed from the RT cycle timestamp and the
configured `SamplingRate`. configured `SamplingRate`.
`Anchor` selects how the burst is placed in time:
| `Anchor` | `out[k]` |
|---|---|
| `FirstSample` | `input + k · period` |
| `LastSample` | `input (N1k) · period` |
| `Continuous` | `input(first cycle) + (n + k) · period` |
`FirstSample`/`LastSample` re-read the timer each cycle, so a lost RT cycle
(`LinuxTimer` re-phases with `counter += nCycles`) punches a whole-period hole
into the time base even though only one array of samples was produced. Use
`Continuous` when the data signal is itself contiguous (`SineArrayGAM` never
skips phase): it latches the timer once and then advances an internal sample
counter by `N` per cycle, like an acquisition card running off its own clock.
### DebugService Interface ### DebugService Interface
Instruments a running MARTe2 application **without modifying its source code**. On Instruments a running MARTe2 application **without modifying its source code**. On
@@ -78,6 +78,32 @@ public:
/** @return Current number of stored points (≤ capacity). */ /** @return Current number of stored points (≤ capacity). */
uint32 Count() const; uint32 Count() const;
/** @return Allocated capacity in points. */
uint32 Capacity() const;
/**
* @brief Enlarge the buffer to @p newCap points, keeping the stored data
* and the TotalWritten() counter (unlike Allocate(), which resets both so
* every reader cursor and every retained sample is lost).
* @return true if the buffer now holds at least @p newCap points.
*/
bool Grow(uint32 newCap);
/**
* @brief Wall-clock span currently retained, i.e. newest minus oldest
* timestamp. 0 when fewer than two points are stored.
*/
float64 TimeSpan() const;
/**
* @brief Timestamp of the most recently stored point, 0 when empty.
*
* This is the source's own time base, which is *not* the hub's wall clock:
* use it, never clock_gettime(), whenever a decision depends on how far
* the data itself has advanced.
*/
float64 NewestTime() const;
/** @brief Discard all stored points. */ /** @brief Discard all stored points. */
void Clear(); void Clear();
@@ -138,6 +164,69 @@ inline bool SignalRingBuffer::Allocate(uint32 maxPts) {
return true; return true;
} }
inline bool SignalRingBuffer::Grow(uint32 newCap) {
if (newCap <= capacity) { return true; }
/* Allocate outside the lock; readers may be active. */
float64 *newT = new float64[newCap];
float64 *newV = new float64[newCap];
if ((newT == static_cast<float64 *>(0)) ||
(newV == static_cast<float64 *>(0))) {
delete[] newT;
delete[] newV;
return false;
}
(void) mutex.FastLock();
if (newCap > capacity) {
/* Copy oldest-to-newest so the new buffer starts unwrapped. */
const uint32 avail = count;
for (uint32 i = 0u; i < avail; i++) {
const uint32 idx = (head + capacity - avail + i) % capacity;
newT[i] = tBuf[idx];
newV[i] = vBuf[idx];
}
float64 *oldT = tBuf;
float64 *oldV = vBuf;
tBuf = newT;
vBuf = newV;
capacity = newCap;
head = avail;
/* count and totalWritten are unchanged: no sample is gained or lost,
* so push cursors stay valid across the resize. */
mutex.FastUnLock();
delete[] oldT;
delete[] oldV;
return true;
}
mutex.FastUnLock();
delete[] newT;
delete[] newV;
return true;
}
inline float64 SignalRingBuffer::TimeSpan() const {
(void) mutex.FastLock();
float64 span = 0.0;
if ((capacity > 0u) && (count > 1u)) {
const uint32 oldest = (head + capacity - count) % capacity;
const uint32 newest = (head + capacity - 1u) % capacity;
span = tBuf[newest] - tBuf[oldest];
}
mutex.FastUnLock();
return (span > 0.0) ? span : 0.0;
}
inline float64 SignalRingBuffer::NewestTime() const {
(void) mutex.FastLock();
float64 t = 0.0;
if ((capacity > 0u) && (count > 0u)) {
t = tBuf[(head + capacity - 1u) % capacity];
}
mutex.FastUnLock();
return t;
}
inline void SignalRingBuffer::Write(float64 t, float64 v) { inline void SignalRingBuffer::Write(float64 t, float64 v) {
(void) mutex.FastLock(); (void) mutex.FastLock();
if (capacity > 0u) { if (capacity > 0u) {
@@ -288,6 +377,13 @@ inline MARTe::uint64 SignalRingBuffer::TotalWritten() const {
return tw; return tw;
} }
inline uint32 SignalRingBuffer::Capacity() const {
(void) mutex.FastLock();
const uint32 c = capacity;
mutex.FastUnLock();
return c;
}
inline uint32 SignalRingBuffer::Count() const { inline uint32 SignalRingBuffer::Count() const {
(void) mutex.FastLock(); (void) mutex.FastLock();
uint32 c = count; uint32 c = count;
+250 -79
View File
@@ -65,6 +65,8 @@ StreamHub::StreamHub()
statsRateHz_(1u), statsRateHz_(1u),
ringTemporal_(1000000u), ringTemporal_(1000000u),
ringScalar_(100000u), ringScalar_(100000u),
ringMaxPts_(8388608u),
trigRetentionSec_(0.0),
nextSourceId_(1u), nextSourceId_(1u),
calibration_(static_cast<CalibrationEntry *>(0)), calibration_(static_cast<CalibrationEntry *>(0)),
numCalibration_(0u), numCalibration_(0u),
@@ -79,13 +81,19 @@ StreamHub::StreamHub()
pushV_(static_cast<float64 *>(0)), pushV_(static_cast<float64 *>(0)),
lastTrigState_(kTrigIdle), lastTrigState_(kTrigIdle),
rearmPending_(false), rearmPending_(false),
rearmAtWallS_(0.0) { rearmAtWallS_(0.0),
collectStartWallS_(0.0),
capBuf_(static_cast<uint8 *>(0)),
capCap_(0u),
capOff_(0u),
capNSig_(0u) {
memset(&recorderCfg_, 0, sizeof(recorderCfg_)); memset(&recorderCfg_, 0, sizeof(recorderCfg_));
calibration_ = new CalibrationEntry[kMaxCalibration]; calibration_ = new CalibrationEntry[kMaxCalibration];
memset(calibration_, 0, sizeof(CalibrationEntry) * kMaxCalibration); memset(calibration_, 0, sizeof(CalibrationEntry) * kMaxCalibration);
for (uint32 i = 0u; i < kMaxSessions; i++) { for (uint32 i = 0u; i < kMaxSessions; i++) {
sessionActive_[i] = false; sessionActive_[i] = false;
configBroadcast_[i] = false; configBroadcast_[i] = false;
capHarvested_[i] = false;
for (uint32 s = 0u; s < UDPSS_MAX_SIGNALS; s++) { for (uint32 s = 0u; s < UDPSS_MAX_SIGNALS; s++) {
pushCursor_[i][s] = 0u; pushCursor_[i][s] = 0u;
} }
@@ -104,6 +112,10 @@ StreamHub::~StreamHub() {
delete[] pushBuf_; delete[] pushBuf_;
pushBuf_ = static_cast<uint8 *>(0); pushBuf_ = static_cast<uint8 *>(0);
} }
if (capBuf_ != static_cast<uint8 *>(0)) {
delete[] capBuf_;
capBuf_ = static_cast<uint8 *>(0);
}
if (lttbT_ != static_cast<float64 *>(0)) { if (lttbT_ != static_cast<float64 *>(0)) {
delete[] lttbT_; delete[] lttbT_;
lttbT_ = static_cast<float64 *>(0); lttbT_ = static_cast<float64 *>(0);
@@ -138,11 +150,38 @@ bool StreamHub::Initialise(StructuredDataI &cfg) {
if (cfg.Read("StatsRate", tmp)) { statsRateHz_ = (tmp > 0u) ? tmp : 1u; } if (cfg.Read("StatsRate", tmp)) { statsRateHz_ = (tmp > 0u) ? tmp : 1u; }
if (cfg.Read("RingTemporal", tmp)) { ringTemporal_ = (tmp > 0u) ? tmp : 1000000u; } if (cfg.Read("RingTemporal", tmp)) { ringTemporal_ = (tmp > 0u) ? tmp : 1000000u; }
if (cfg.Read("RingScalar", tmp)) { ringScalar_ = (tmp > 0u) ? tmp : 100000u; } if (cfg.Read("RingScalar", tmp)) { ringScalar_ = (tmp > 0u) ? tmp : 100000u; }
/* Per-signal ceiling when a trigger window forces a ring to grow.
* 128 MiB / (2 × float64) = 8388608 points ~8 s at 1 Msps, ~1.7 s at
* 5 Msps. Raise it if you need longer windows on very fast sources. */
if (cfg.Read("RingMaxMB", tmp) && (tmp > 0u)) {
ringMaxPts_ = tmp * (1048576u / 16u);
}
if (ringMaxPts_ < ringTemporal_) { ringMaxPts_ = ringTemporal_; }
sourcesFile_ = "streamhub_sources.json"; sourcesFile_ = "streamhub_sources.json";
StreamString sf; StreamString sf;
if (cfg.Read("SourcesFile", sf)) { sourcesFile_ = sf; } if (cfg.Read("SourcesFile", sf)) { sourcesFile_ = sf; }
/* Origins allowed to open the WebSocket, comma-separated
* ("http://localhost:8080,http://box.lan:8080"). Without this only
* same-origin upgrades pass, which rejects every browser that loaded the
* SPA from a separate web server (the usual deployment). */
StreamString origins;
if (cfg.Read("AllowedOrigins", origins)) {
char list[1024];
strncpy(list, origins.Buffer(), sizeof(list) - 1u);
list[sizeof(list) - 1u] = '\0';
char *tok = strtok(list, ", \t");
while (tok != static_cast<char *>(0)) {
if (!wsServer_.AddAllowedOrigin(tok)) {
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
"StreamHub: rejected allowed-origin '%s' (list full or too long).",
tok);
}
tok = strtok(static_cast<char *>(0), ", \t");
}
}
/* Parse +History block (optional). /* Parse +History block (optional).
* StandardParser stores the '+' prefix in the node name, so we try both. */ * StandardParser stores the '+' prefix in the node name, so we try both. */
if (cfg.MoveRelative("+History") || cfg.MoveRelative("History")) { if (cfg.MoveRelative("+History") || cfg.MoveRelative("History")) {
@@ -194,8 +233,8 @@ bool StreamHub::Initialise(StructuredDataI &cfg) {
/* Allocate scratch buffers */ /* Allocate scratch buffers */
pushBuf_ = new uint8[kPushBufSize]; pushBuf_ = new uint8[kPushBufSize];
lttbT_ = new float64[maxPushPoints_]; lttbT_ = new float64[kPushScratchPts];
lttbV_ = new float64[maxPushPoints_]; lttbV_ = new float64[kPushScratchPts];
pushT_ = new float64[kPushScratchPts]; pushT_ = new float64[kPushScratchPts];
pushV_ = new float64[kPushScratchPts]; pushV_ = new float64[kPushScratchPts];
@@ -309,6 +348,7 @@ bool StreamHub::Run() {
if (statsDivisor == 0u) { statsDivisor = 1u; } if (statsDivisor == 0u) { statsDivisor = 1u; }
if ((tickCount_ % statsDivisor) == 0u) { if ((tickCount_ % statsDivisor) == 0u) {
PushStats(); PushStats();
GrowRingsForTrigger();
} }
/* History: flush headers at the configured interval, then re-broadcast /* History: flush headers at the configured interval, then re-broadcast
@@ -337,11 +377,16 @@ bool StreamHub::Run() {
tickCount_++; tickCount_++;
/* Sleep for remainder of period */ /* Sleep for remainder of period. Both operands are unsigned, so the
* comparison must be done additively: a tick that overruns the period
* (easy at multi-Msps ingest, and guaranteed on the first tick, which
* drains the whole ring) would otherwise wrap periodUs - elapsedUs to
* ~2^64 and park the push thread for weeks no data frames, no stats
* and no trigger captures for the rest of the run. */
uint64 t1 = MARTe::HighResolutionTimer::Counter(); uint64 t1 = MARTe::HighResolutionTimer::Counter();
uint64 freq = MARTe::HighResolutionTimer::Frequency(); uint64 freq = MARTe::HighResolutionTimer::Frequency();
uint64 elapsedUs = ((t1 - t0) * 1000000u) / freq; uint64 elapsedUs = ((t1 - t0) * 1000000u) / freq;
if (periodUs - elapsedUs > 1000) { if ((elapsedUs + 1000u) < periodUs) {
Sleep::MSec(static_cast<uint32>((periodUs - elapsedUs) / 1000u)); Sleep::MSec(static_cast<uint32>((periodUs - elapsedUs) / 1000u));
} }
} }
@@ -468,19 +513,35 @@ uint32 StreamHub::SerializeBinaryFrame(uint32 sessionIdx,
pushT_, pushV_, kPushScratchPts); pushT_, pushV_, kPushScratchPts);
if (nRaw == 0u) { continue; } if (nRaw == 0u) { continue; }
/* LTTB decimation only for temporal (multi-element, sample-timed) /* LTTB decimation for the live push, bounded for every signal.
* signals Go hub policy. Scalars and PACKET-timed arrays are *
* pushed verbatim (their per-tick batches are small). */ * A PACKET-timed array is a snapshot waveform, so its floor is one
* packet's worth of elements: below that LTTB would flatten the very
* waveform the operator is looking at, above it each extra point is
* just backlog from packets that piled up during the tick. Exempting
* those arrays altogether (the old rule, on the assumption that their
* per-tick batches are small) does not survive a fast producer: the
* 5 kHz x 1000-element time array in the demo pushes ~65k points per
* tick, 31 MB/s 30x the channel it timestamps and the per-client
* push queue never drains.
*
* Decimating here costs no fidelity downstream: LTTB picks real
* samples (it never interpolates), the rings keep every sample, and
* zoom/history/trigger all re-read the rings at full resolution. */
const uint32 nElems = desc.numRows * ((desc.numCols > 0u) ? desc.numCols : 1u); const uint32 nElems = desc.numRows * ((desc.numCols > 0u) ? desc.numCols : 1u);
const bool temporal = (nElems > 1u) && uint32 threshold = maxPushPoints_;
(desc.timeMode != MARTe::UDPS_TIMEMODE_PACKET); if ((desc.timeMode == MARTe::UDPS_TIMEMODE_PACKET) &&
(nElems > threshold)) {
threshold = nElems;
}
if (threshold > kPushScratchPts) { threshold = kPushScratchPts; }
const float64 *tOut; const float64 *tOut;
const float64 *vOut; const float64 *vOut;
uint32 nOut; uint32 nOut;
if (temporal && (nRaw > maxPushPoints_)) { if (nRaw > threshold) {
nOut = LTTBDecimate(pushT_, pushV_, nRaw, nOut = LTTBDecimate(pushT_, pushV_, nRaw,
lttbT_, lttbV_, maxPushPoints_); lttbT_, lttbV_, threshold);
tOut = lttbT_; tOut = lttbT_;
vOut = lttbV_; vOut = lttbV_;
} else { } else {
@@ -1556,7 +1617,8 @@ void StreamHub::HandleTrigStop(const char *json) {
void StreamHub::HandleSetTrigger(const char *json) { void StreamHub::HandleSetTrigger(const char *json) {
/* Web client shape: /* Web client shape:
* {"type":"setTrigger","signal":"src:sig[i]","edge":"rising|falling|both", * {"type":"setTrigger","signal":"src:sig[i]","edge":"rising|falling|both",
* "threshold":F,"windowSec":F,"prePercent":F,"mode":"normal|single"} */ * "threshold":F,"windowSec":F,"prePercent":F,"mode":"normal|single",
* "holdoffSec":F} */
TriggerConfig cfg = trigger_.GetConfig(); TriggerConfig cfg = trigger_.GetConfig();
char key[160] = ""; char key[160] = "";
@@ -1565,6 +1627,7 @@ void StreamHub::HandleSetTrigger(const char *json) {
float64 thr = cfg.threshold; float64 thr = cfg.threshold;
float64 winSec = cfg.windowSec; float64 winSec = cfg.windowSec;
float64 prePct = cfg.prePercent; float64 prePct = cfg.prePercent;
float64 holdoff = cfg.holdoffSec;
if (JsonGetString(json, "signal", key, sizeof(key))) { if (JsonGetString(json, "signal", key, sizeof(key))) {
cfg.signalKey = key; cfg.signalKey = key;
@@ -1580,8 +1643,20 @@ void StreamHub::HandleSetTrigger(const char *json) {
if (JsonGetFloat(json, "threshold", thr)) { cfg.threshold = thr; } if (JsonGetFloat(json, "threshold", thr)) { cfg.threshold = thr; }
if (JsonGetFloat(json, "windowSec", winSec)) { cfg.windowSec = winSec; } if (JsonGetFloat(json, "windowSec", winSec)) { cfg.windowSec = winSec; }
if (JsonGetFloat(json, "prePercent", prePct)) { cfg.prePercent = prePct; } if (JsonGetFloat(json, "prePercent", prePct)) { cfg.prePercent = prePct; }
if (JsonGetFloat(json, "holdoffSec", holdoff)) { cfg.holdoffSec = holdoff; }
trigger_.SetConfig(cfg); trigger_.SetConfig(cfg);
/* A capture can only contain what the rings still hold: the default
* capacity is a point count, so at 1 Msps it covers ~1 s and every longer
* window came back with only its tail populated. Publish the requested
* retention so the push thread can size the rings to the actual measured
* sample rate. */
trigRetentionSec_ = cfg.windowSec;
/* Grow now, not on the next stats tick: clients send setTrigger and arm
* back to back, and a trigger that fires before the rings are resized
* still loses its pre-trigger data. The periodic call stays as the catch-up
* path for sources that connect later. */
GrowRingsForTrigger();
BroadcastTriggerState(); BroadcastTriggerState();
} }
@@ -1593,22 +1668,67 @@ void StreamHub::TriggerTick(float64 wallNowS) {
/* Capture-margin: wait a little past the post window so the rings have /* Capture-margin: wait a little past the post window so the rings have
* received the last post-trigger samples (web client used 120 ms). */ * received the last post-trigger samples (web client used 120 ms). */
static const float64 kCaptureMarginS = 0.15; static const float64 kCaptureMarginS = 0.15;
static const float64 kAutoRearmDelayS = 0.2;
const TrigState st = trigger_.GetState(); const TrigState st = trigger_.GetState();
/* Wall-clock grace on top of the post window before giving up on a source
* that stopped advancing; the capture is then broadcast with whatever the
* rings hold. */
static const float64 kCaptureWatchdogS = 2.0;
if (st == kTrigCollecting) { if (st == kTrigCollecting) {
const bool justEntered = (lastTrigState_ != kTrigCollecting);
if (justEntered) { collectStartWallS_ = wallNowS; }
float64 trigTime = 0.0; float64 trigTime = 0.0;
float64 preSec = 0.0; float64 preSec = 0.0;
float64 postSec = 0.0; float64 postSec = 0.0;
if (trigger_.GetFiredWindow(trigTime, preSec, postSec) && if (trigger_.GetFiredWindow(trigTime, preSec, postSec)) {
(wallNowS >= (trigTime + postSec + kCaptureMarginS))) { /* Always restart the frame on entry: a capture abandoned by a
BroadcastTriggerCapture(trigTime, preSec, postSec); * disarm would otherwise be resumed with the previous trigTime. */
if (justEntered || (capBuf_ == static_cast<MARTe::uint8 *>(0))) {
BeginTriggerCapture(trigTime, preSec, postSec);
}
/* Harvest on the *data's* clock. trigTime comes from the sample
* timestamps, and a source's time base is offset from and drifts
* against CLOCK_REALTIME, so a wall-clock deadline chops the tail
* off every capture by exactly that offset. The wall clock is only
* a watchdog for a source that went quiet. */
const bool timedOut =
(wallNowS >= (collectStartWallS_ + postSec + kCaptureWatchdogS));
const float64 deadline = trigTime + postSec + kCaptureMarginS;
bool allDone = true;
for (uint32 i = 0u; i < kMaxSessions; i++) {
if (!sessionActive_[i] || capHarvested_[i]) { continue; }
const float64 frontier = SourceFrontierTime(i, wallNowS);
if (frontier >= deadline) {
HarvestTriggerCapture(i, trigTime - preSec,
trigTime + postSec);
}
else if (timedOut) {
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
"StreamHub: source %s timed out at %.3f s of the %.3f s "
"trigger window; its traces will be short.",
sessions_[i].GetId().Buffer(), frontier - trigTime,
postSec);
HarvestTriggerCapture(i, trigTime - preSec,
trigTime + postSec);
}
else {
allDone = false;
}
}
if (allDone || timedOut) {
FinishTriggerCapture();
trigger_.MarkTriggered(); trigger_.MarkTriggered();
TriggerConfig cfg = trigger_.GetConfig(); TriggerConfig cfg = trigger_.GetConfig();
if ((cfg.mode == kTrigNormal) && !trigger_.GetStopped()) { if ((cfg.mode == kTrigNormal) && !trigger_.GetStopped()) {
rearmPending_ = true; rearmPending_ = true;
rearmAtWallS_ = wallNowS + kAutoRearmDelayS; rearmAtWallS_ = wallNowS + cfg.holdoffSec;
}
} }
} }
} }
@@ -1627,6 +1747,45 @@ void StreamHub::TriggerTick(float64 wallNowS) {
} }
} }
float64 StreamHub::SourceFrontierTime(uint32 i, float64 wallNowS) const {
const float64 t = sessions_[i].ProducerNewestTime();
/* No producer clock means every sample was stamped on arrival, so this
* source and the trigger both live in the hub's wall-clock domain. */
return (t > 0.0) ? t : wallNowS;
}
uint32 StreamHub::CurrentMaxRingCapacity() const {
/* Rings grow at runtime for long trigger windows, so read the live
* capacities rather than the configured starting size. */
uint32 maxCap = (ringTemporal_ > ringScalar_) ? ringTemporal_ : ringScalar_;
for (uint32 i = 0u; i < kMaxSessions; i++) {
if (!sessionActive_[i]) { continue; }
const uint32 c = sessions_[i].GetMaxRingCapacity();
if (c > maxCap) { maxCap = c; }
}
return maxCap;
}
void StreamHub::GrowRingsForTrigger() {
const float64 want = trigRetentionSec_;
if (want <= 0.0) { return; }
/* Retain the whole window plus the capture margin and one push period, so
* the tail of the window is still in the ring when TriggerTick reads it. */
const float64 target = want + 0.5;
for (uint32 i = 0u; i < kMaxSessions; i++) {
if (!sessionActive_[i]) { continue; }
if (!sessions_[i].IsConfigured()) { continue; }
if (sessions_[i].GrowRingsForSeconds(target, ringMaxPts_)) {
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
"StreamHub: grew rings of source %s to hold %.2f s "
"(trigger window %.2f s, cap %u pts/signal).",
sessions_[i].GetId().Buffer(), target, want, ringMaxPts_);
}
}
}
void StreamHub::BroadcastTriggerState() { void StreamHub::BroadcastTriggerState() {
const TrigState st = trigger_.GetState(); const TrigState st = trigger_.GetState();
TriggerConfig cfg = trigger_.GetConfig(); TriggerConfig cfg = trigger_.GetConfig();
@@ -1639,17 +1798,23 @@ void StreamHub::BroadcastTriggerState() {
(st == kTrigTriggered) ? "triggered" : "idle"; (st == kTrigTriggered) ? "triggered" : "idle";
const char *modeStr = (cfg.mode == kTrigSingle) ? "single" : "normal"; const char *modeStr = (cfg.mode == kTrigSingle) ? "single" : "normal";
char buf[256]; char buf[512];
int n; int n;
float64 trigTime = 0.0; float64 trigTime = 0.0;
float64 preSec = 0.0; float64 preSec = 0.0;
float64 postSec = 0.0; float64 postSec = 0.0;
if (((st == kTrigCollecting) || (st == kTrigTriggered)) && if (((st == kTrigCollecting) || (st == kTrigTriggered)) &&
trigger_.GetFiredWindow(trigTime, preSec, postSec)) { trigger_.GetFiredWindow(trigTime, preSec, postSec)) {
/* The window latched at fire time. Clients draw the filling capture on
* this axis before the v2 frame arrives, and config edits between arm
* and fire would otherwise leave them inferring the wrong window from
* their own copy of the config. */
n = snprintf(buf, sizeof(buf), n = snprintf(buf, sizeof(buf),
"{\"type\":\"triggerState\",\"state\":\"%s\",\"mode\":\"%s\"," "{\"type\":\"triggerState\",\"state\":\"%s\",\"mode\":\"%s\","
"\"stopped\":%s,\"trigTime\":%.17g}", "\"stopped\":%s,\"trigTime\":%.17g,\"preSec\":%.17g,"
stateStr, modeStr, (stopped ? "true" : "false"), trigTime); "\"postSec\":%.17g}",
stateStr, modeStr, (stopped ? "true" : "false"), trigTime,
preSec, postSec);
} else { } else {
n = snprintf(buf, sizeof(buf), n = snprintf(buf, sizeof(buf),
"{\"type\":\"triggerState\",\"state\":\"%s\",\"mode\":\"%s\"," "{\"type\":\"triggerState\",\"state\":\"%s\",\"mode\":\"%s\","
@@ -1661,37 +1826,36 @@ void StreamHub::BroadcastTriggerState() {
} }
} }
void StreamHub::BroadcastTriggerCapture(float64 trigTime, float64 preSec, void StreamHub::BeginTriggerCapture(float64 trigTime, float64 preSec,
float64 postSec) { float64 postSec) {
const float64 t0 = trigTime - preSec; delete[] capBuf_;
const float64 t1 = trigTime + postSec; capCap_ = 1u << 20;
capBuf_ = new uint8[capCap_];
capOff_ = 0u;
capNSig_ = 0u;
for (uint32 i = 0u; i < kMaxSessions; i++) { capHarvested_[i] = false; }
/* Header: [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig] */
capBuf_[capOff_++] = 2u;
memcpy(capBuf_ + capOff_, &trigTime, 8u); capOff_ += 8u;
memcpy(capBuf_ + capOff_, &preSec, 8u); capOff_ += 8u;
memcpy(capBuf_ + capOff_, &postSec, 8u); capOff_ += 8u;
capOff_ += 4u; /* nSig, patched in FinishTriggerCapture */
}
void StreamHub::HarvestTriggerCapture(uint32 i, float64 t0, float64 t1) {
capHarvested_[i] = true;
UDPSourceSession &sess = sessions_[i];
if ((capBuf_ == static_cast<uint8 *>(0)) || !sess.IsConfigured()) { return; }
/* Read scratch sized for the largest ring; LTTB scratch for the cap. */ /* Read scratch sized for the largest ring; LTTB scratch for the cap. */
const uint32 scratchCap = (ringTemporal_ > ringScalar_) ? ringTemporal_ const uint32 scratchCap = CurrentMaxRingCapacity();
: ringScalar_;
float64 *tRaw = new float64[scratchCap]; float64 *tRaw = new float64[scratchCap];
float64 *vRaw = new float64[scratchCap]; float64 *vRaw = new float64[scratchCap];
float64 *tDec = new float64[kTrigCapturePts]; float64 *tDec = new float64[kTrigCapturePts];
float64 *vDec = new float64[kTrigCapturePts]; float64 *vDec = new float64[kTrigCapturePts];
uint32 cap = 1u << 20;
uint8 *buf = new uint8[cap];
uint32 off = 0u;
/* Header: [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig] */
buf[off++] = 2u;
memcpy(buf + off, &trigTime, 8u); off += 8u;
memcpy(buf + off, &preSec, 8u); off += 8u;
memcpy(buf + off, &postSec, 8u); off += 8u;
const uint32 nSigOff = off;
uint32 nSigWritten = 0u;
off += 4u;
for (uint32 i = 0u; i < kMaxSessions; i++) {
if (!sessionActive_[i]) { continue; }
UDPSourceSession &sess = sessions_[i];
if (!sess.IsConfigured()) { continue; }
StreamString sid = sess.GetId(); StreamString sid = sess.GetId();
const uint32 numSigs = sess.GetNumSignals(); const uint32 numSigs = sess.GetNumSignals();
@@ -1707,8 +1871,7 @@ void StreamHub::BroadcastTriggerCapture(float64 trigTime, float64 preSec,
const float64 *vOut = vRaw; const float64 *vOut = vRaw;
uint32 nOut = nRaw; uint32 nOut = nRaw;
if (nRaw > kTrigCapturePts) { if (nRaw > kTrigCapturePts) {
nOut = LTTBDecimate(tRaw, vRaw, nRaw, tDec, vDec, nOut = LTTBDecimate(tRaw, vRaw, nRaw, tDec, vDec, kTrigCapturePts);
kTrigCapturePts);
tOut = tDec; tOut = tDec;
vOut = vDec; vOut = vDec;
} }
@@ -1720,50 +1883,59 @@ void StreamHub::BroadcastTriggerCapture(float64 trigTime, float64 preSec,
const uint32 keyLen = static_cast<uint32>(kn); const uint32 keyLen = static_cast<uint32>(kn);
const uint32 need = 2u + keyLen + 4u + nOut * 16u; const uint32 need = 2u + keyLen + 4u + nOut * 16u;
if ((off + need) > cap) { if ((capOff_ + need) > capCap_) {
uint32 newCap = cap * 2u; uint32 newCap = capCap_ * 2u;
while ((off + need) > newCap) { newCap *= 2u; } while ((capOff_ + need) > newCap) { newCap *= 2u; }
uint8 *nb = new uint8[newCap]; uint8 *nb = new uint8[newCap];
memcpy(nb, buf, off); memcpy(nb, capBuf_, capOff_);
delete[] buf; delete[] capBuf_;
buf = nb; capBuf_ = nb;
cap = newCap; capCap_ = newCap;
} }
buf[off++] = static_cast<uint8>( keyLen & 0xFFu); capBuf_[capOff_++] = static_cast<uint8>( keyLen & 0xFFu);
buf[off++] = static_cast<uint8>((keyLen >> 8) & 0xFFu); capBuf_[capOff_++] = static_cast<uint8>((keyLen >> 8) & 0xFFu);
memcpy(buf + off, fullKey, keyLen); memcpy(capBuf_ + capOff_, fullKey, keyLen);
off += keyLen; capOff_ += keyLen;
buf[off++] = static_cast<uint8>( nOut & 0xFFu); capBuf_[capOff_++] = static_cast<uint8>( nOut & 0xFFu);
buf[off++] = static_cast<uint8>((nOut >> 8) & 0xFFu); capBuf_[capOff_++] = static_cast<uint8>((nOut >> 8) & 0xFFu);
buf[off++] = static_cast<uint8>((nOut >> 16) & 0xFFu); capBuf_[capOff_++] = static_cast<uint8>((nOut >> 16) & 0xFFu);
buf[off++] = static_cast<uint8>((nOut >> 24) & 0xFFu); capBuf_[capOff_++] = static_cast<uint8>((nOut >> 24) & 0xFFu);
memcpy(buf + off, tOut, nOut * sizeof(float64)); memcpy(capBuf_ + capOff_, tOut, nOut * sizeof(float64));
off += nOut * 8u; capOff_ += nOut * 8u;
memcpy(buf + off, vOut, nOut * sizeof(float64)); memcpy(capBuf_ + capOff_, vOut, nOut * sizeof(float64));
off += nOut * 8u; capOff_ += nOut * 8u;
nSigWritten++; capNSig_++;
}
} }
/* Patch nSig */ delete[] tRaw; delete[] vRaw;
buf[nSigOff] = static_cast<uint8>( nSigWritten & 0xFFu); delete[] tDec; delete[] vDec;
buf[nSigOff + 1u] = static_cast<uint8>((nSigWritten >> 8) & 0xFFu); }
buf[nSigOff + 2u] = static_cast<uint8>((nSigWritten >> 16) & 0xFFu);
buf[nSigOff + 3u] = static_cast<uint8>((nSigWritten >> 24) & 0xFFu);
wsServer_.BroadcastBinary(buf, off); void StreamHub::FinishTriggerCapture() {
if (capBuf_ == static_cast<uint8 *>(0)) { return; }
/* Patch nSig (immediately after the [u8 2] + 3×f64 header). */
const uint32 nSigOff = 25u;
capBuf_[nSigOff] = static_cast<uint8>( capNSig_ & 0xFFu);
capBuf_[nSigOff + 1u] = static_cast<uint8>((capNSig_ >> 8) & 0xFFu);
capBuf_[nSigOff + 2u] = static_cast<uint8>((capNSig_ >> 16) & 0xFFu);
capBuf_[nSigOff + 3u] = static_cast<uint8>((capNSig_ >> 24) & 0xFFu);
wsServer_.BroadcastBinary(capBuf_, capOff_);
REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information, REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
"StreamHub: trigger capture broadcast (%u signal(s), %u bytes).", "StreamHub: trigger capture broadcast (%u signal(s), %u bytes).",
nSigWritten, off); capNSig_, capOff_);
delete[] buf; delete[] capBuf_;
delete[] tRaw; delete[] vRaw; capBuf_ = static_cast<uint8 *>(0);
delete[] tDec; delete[] vDec; capCap_ = 0u;
capOff_ = 0u;
capNSig_ = 0u;
} }
void StreamHub::HandleZoom(const char *json, uint32 slotIdx) { void StreamHub::HandleZoom(const char *json, uint32 slotIdx) {
@@ -1797,8 +1969,7 @@ void StreamHub::HandleZoom(const char *json, uint32 slotIdx) {
/* Read scratch sized for the largest possible ring (no double decimation: /* Read scratch sized for the largest possible ring (no double decimation:
* the whole [t0,t1] slice is read, then LTTB'd once to maxOut). */ * the whole [t0,t1] slice is read, then LTTB'd once to maxOut). */
const uint32 scratchCap = (ringTemporal_ > ringScalar_) ? ringTemporal_ const uint32 scratchCap = CurrentMaxRingCapacity();
: ringScalar_;
float64 *tRaw = new float64[scratchCap]; float64 *tRaw = new float64[scratchCap];
float64 *vRaw = new float64[scratchCap]; float64 *vRaw = new float64[scratchCap];
float64 *tDec = (maxOut > 0u) ? new float64[maxOut] : static_cast<float64 *>(0); float64 *tDec = (maxOut > 0u) ? new float64[maxOut] : static_cast<float64 *>(0);
+48 -8
View File
@@ -91,7 +91,7 @@ public:
* WSPort (uint32, default 8090) * WSPort (uint32, default 8090)
* MaxPoints (uint32, default 20000) ring buffer capacity per signal * MaxPoints (uint32, default 20000) ring buffer capacity per signal
* PushRate (uint32, default 30) push loop rate in Hz * PushRate (uint32, default 30) push loop rate in Hz
* MaxPushPoints (uint32, default 500) LTTB threshold for live push * MaxPushPoints (uint32, default 50) LTTB threshold for live push
* StatsRate (uint32, default 1) stats broadcast rate in Hz * StatsRate (uint32, default 1) stats broadcast rate in Hz
* +Sources { +<id> { Label=...; Addr=...; Port=... } } * +Sources { +<id> { Label=...; Addr=...; Port=... } }
* *
@@ -152,12 +152,40 @@ private:
void BroadcastTriggerState(); void BroadcastTriggerState();
/** /**
* @brief Build and broadcast the version=2 binary capture frame: * @brief Size every ring so it retains the current trigger window.
* [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig] * Called from the push loop once per stats tick; a no-op once the rings
* {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]} * are large enough. Rates are measured from the rings themselves because
* most sources advertise samplingRate = 0.
*/ */
void BroadcastTriggerCapture(float64 trigTime, float64 preSec, void GrowRingsForTrigger();
float64 postSec);
/** @return Largest ring capacity currently allocated across all sessions. */
uint32 CurrentMaxRingCapacity() const;
/**
* @brief How far source @p i has produced, in the trigger's time base;
* @p wallNowS when it publishes no producer clock (its samples are then
* stamped on arrival, so they share the hub's wall clock).
*/
float64 SourceFrontierTime(uint32 i, float64 wallNowS) const;
/* ---- Trigger capture assembly ---------------------------------------
* Sources are harvested one at a time, each as soon as *it* has produced
* past the end of the window, rather than all together once the slowest
* has. Sources free-run on their own clocks and can lag each other by
* seconds; making every source wait for the slowest lets the leaders' ring
* buffers roll past the pre-trigger region before it is ever read. */
/** @brief Start a version=2 capture frame:
* [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig]. */
void BeginTriggerCapture(float64 trigTime, float64 preSec, float64 postSec);
/** @brief Append session @p i's signals to the pending frame, each as
* {[u16 keyLen][fullKey][u32 N][t f64×N][v f64×N]}. */
void HarvestTriggerCapture(uint32 i, float64 t0, float64 t1);
/** @brief Patch nSig, broadcast the pending frame and release it. */
void FinishTriggerCapture();
/* ---- Command handlers (called from OnWSCommand) ---------------------- */ /* ---- Command handlers (called from OnWSCommand) ---------------------- */
@@ -271,8 +299,10 @@ private:
uint32 pushRateHz_; uint32 pushRateHz_;
uint32 maxPushPoints_; uint32 maxPushPoints_;
uint32 statsRateHz_; uint32 statsRateHz_;
uint32 ringTemporal_; ///< Ring capacity for multi-element (waveform) signals uint32 ringTemporal_; ///< Initial ring capacity for multi-element (waveform) signals
uint32 ringScalar_; ///< Ring capacity for scalar signals uint32 ringScalar_; ///< Ring capacity for scalar signals
uint32 ringMaxPts_; ///< Ceiling a ring may be grown to for a trigger window
volatile float64 trigRetentionSec_; ///< Retention the current trigger window needs
StreamString sourcesFile_; ///< Persistent dynamic source list (JSON) StreamString sourcesFile_; ///< Persistent dynamic source list (JSON)
uint32 nextSourceId_; ///< Counter for generated session ids ("sN") uint32 nextSourceId_; ///< Counter for generated session ids ("sN")
@@ -292,7 +322,9 @@ private:
static const uint32 kPushBufSize = 8u * 1024u * 1024u; static const uint32 kPushBufSize = 8u * 1024u * 1024u;
uint8 *pushBuf_; uint8 *pushBuf_;
/* Decimated output scratch (LTTB): maxPushPoints × 2 arrays per signal */ /* Decimated output scratch (LTTB). Sized like the read scratch rather
* than maxPushPoints_: a PACKET-timed array raises its own threshold to
* one packet's worth of elements, which can exceed maxPushPoints_. */
float64 *lttbT_; float64 *lttbT_;
float64 *lttbV_; float64 *lttbV_;
@@ -311,6 +343,14 @@ private:
TrigState lastTrigState_; ///< Last broadcast FSM state TrigState lastTrigState_; ///< Last broadcast FSM state
bool rearmPending_; ///< Normal-mode auto-rearm scheduled bool rearmPending_; ///< Normal-mode auto-rearm scheduled
float64 rearmAtWallS_; ///< Wall time of the scheduled auto-rearm float64 rearmAtWallS_; ///< Wall time of the scheduled auto-rearm
float64 collectStartWallS_; ///< Wall time COLLECTING began (watchdog only)
/* Capture frame under assembly across ticks (push thread only) */
MARTe::uint8 *capBuf_; ///< Pending frame, NULL when idle
uint32 capCap_; ///< Allocated size of capBuf_
uint32 capOff_; ///< Bytes written so far
uint32 capNSig_; ///< Signals appended so far
bool capHarvested_[kMaxSessions]; ///< Session already appended
}; };
} /* namespace StreamHub */ } /* namespace StreamHub */
@@ -27,9 +27,16 @@ void TriggerEngine::SetConfig(const TriggerConfig &cfg) {
config_ = cfg; config_ = cfg;
/* Clamp to web UI bounds */ /* Clamp to web UI bounds */
if (config_.windowSec < 1.0e-4) { config_.windowSec = 1.0e-4; } if (config_.windowSec < 1.0e-4) { config_.windowSec = 1.0e-4; }
if (config_.windowSec > 10.0) { config_.windowSec = 10.0; } /* 60 s where the Go hub allows 600. Deliberate: these rings are
* fixed-capacity and store every sample, so a window they cannot hold is
* harvested truncated and silently decimated to kTrigCapturePts. The Go
* hub stores min/max pairs instead once a window outgrows its budget, so
* there a long window costs resolution rather than coverage. */
if (config_.windowSec > 60.0) { config_.windowSec = 60.0; }
if (config_.prePercent < 0.0) { config_.prePercent = 0.0; } if (config_.prePercent < 0.0) { config_.prePercent = 0.0; }
if (config_.prePercent > 100.0) { config_.prePercent = 100.0; } if (config_.prePercent > 100.0) { config_.prePercent = 100.0; }
if (config_.holdoffSec < 0.0) { config_.holdoffSec = 0.0; }
if (config_.holdoffSec > 60.0) { config_.holdoffSec = 60.0; }
epoch_++; epoch_++;
prevValid_ = false; prevValid_ = false;
prevValue_ = 0.0; prevValue_ = 0.0;
@@ -62,9 +62,10 @@ struct TriggerConfig {
StreamString signalKey; ///< Full key: "src:sig" or "src:sig[i]" StreamString signalKey; ///< Full key: "src:sig" or "src:sig[i]"
TrigEdge edge; ///< Rising / falling / both TrigEdge edge; ///< Rising / falling / both
float64 threshold; ///< Trigger threshold (physical units) float64 threshold; ///< Trigger threshold (physical units)
float64 windowSec; ///< Capture window length [1e-4 .. 10] s float64 windowSec; ///< Capture window length [1e-4 .. 60] s
float64 prePercent; ///< Pre-trigger part of the window [0 .. 100] % float64 prePercent; ///< Pre-trigger part of the window [0 .. 100] %
TrigAcqMode mode; ///< Normal (auto-rearm) or single TrigAcqMode mode; ///< Normal (auto-rearm) or single
float64 holdoffSec; ///< Rearm delay after a capture [0 .. 60] s
}; };
/** /**
@@ -149,7 +150,8 @@ inline TriggerConfig::TriggerConfig()
threshold(0.0), threshold(0.0),
windowSec(1.0), windowSec(1.0),
prePercent(20.0), prePercent(20.0),
mode(kTrigNormal) { mode(kTrigNormal),
holdoffSec(0.2) {
} }
} /* namespace StreamHub */ } /* namespace StreamHub */
@@ -295,6 +295,83 @@ void UDPSourceSession::AllocateRingBuffers() {
} }
} }
bool UDPSourceSession::GrowRingsForSeconds(float64 seconds, uint32 maxPts) {
if ((seconds <= 0.0) || (maxPts == 0u)) { return false; }
(void) metaMutex_.FastLock();
const uint32 nSigs = numSignals_;
metaMutex_.FastUnLock();
bool grew = false;
for (uint32 i = 0u; i < nSigs; i++) {
const uint32 count = rings_[i].Count();
const float64 span = rings_[i].TimeSpan();
/* Need a decent sample of the stream before extrapolating a rate;
* a couple of packets' worth of span is enough at any rate. */
if ((count < 2u) || (span <= 0.0)) { continue; }
const float64 rate = static_cast<float64>(count) / span;
/* 20 % headroom absorbs rate jitter and the capture margin. */
float64 need = rate * seconds * 1.2;
if (need > static_cast<float64>(maxPts)) {
need = static_cast<float64>(maxPts);
}
const uint32 needPts = static_cast<uint32>(need);
if (needPts > rings_[i].Capacity()) {
if (rings_[i].Grow(needPts)) { grew = true; }
}
}
return grew;
}
uint32 UDPSourceSession::GetMaxRingCapacity() const {
(void) metaMutex_.FastLock();
const uint32 nSigs = numSignals_;
metaMutex_.FastUnLock();
uint32 maxCap = 0u;
for (uint32 i = 0u; i < nSigs; i++) {
const uint32 c = rings_[i].Capacity();
if (c > maxCap) { maxCap = c; }
}
return maxCap;
}
float64 UDPSourceSession::ProducerNewestTime() const {
/* Mirror exactly the ParseDataPayload branches that timestamp from the
* referenced time signal; every other branch stamps on arrival and so
* would report "now" in the hub's clock, not the producer's. The time
* signal itself is one of those it is PACKET-timed. */
(void) metaMutex_.FastLock();
const uint32 nSigs = numSignals_;
bool producerTimed[UDPSS_MAX_SIGNALS];
for (uint32 i = 0u; i < nSigs; i++) {
const UDPSSignalDescriptor &d = sigDescs_[i];
uint64 ne = static_cast<uint64>(d.numRows) *
static_cast<uint64>(d.numCols);
if (ne == 0u) { ne = 1u; }
const bool hasTimeSig = (d.timeSignalIdx != UDPS_NO_TIME_SIGNAL) &&
(d.timeSignalIdx < nSigs);
const bool isFirstLast = (ne > 1u) &&
((d.timeMode == UDPS_TIMEMODE_FIRST_SAMPLE) ||
(d.timeMode == UDPS_TIMEMODE_LAST_SAMPLE));
const bool isFullArray = (d.timeMode == UDPS_TIMEMODE_FULL_ARRAY);
producerTimed[i] = hasTimeSig && (isFullArray || isFirstLast);
}
metaMutex_.FastUnLock();
/* Signals of one source share a packet, so they advance together; the max
* is "how far this source has produced" without stalling on a signal that
* simply is not being sent. */
float64 newest = 0.0;
for (uint32 i = 0u; i < nSigs; i++) {
if (!producerTimed[i]) { continue; }
const float64 t = rings_[i].NewestTime();
if (t > newest) { newest = t; }
}
return newest;
}
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
/* DATA parsing */ /* DATA parsing */
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
@@ -154,6 +154,37 @@ public:
*/ */
void SetRingCapacities(uint32 temporal, uint32 scalar); void SetRingCapacities(uint32 temporal, uint32 scalar);
/**
* @brief Grow every ring so it can retain at least @p seconds of history.
*
* The required capacity is seconds × the rate measured from the ring
* itself (count / time span), because most sources advertise
* samplingRate = 0. Signals whose ring has not filled enough to measure a
* rate are left alone; the caller is expected to retry.
*
* @param seconds Retention target.
* @param maxPts Per-signal ceiling, so a multi-Msps source cannot be
* asked to allocate an unbounded amount of memory.
* @return true if at least one ring was enlarged.
*/
bool GrowRingsForSeconds(float64 seconds, uint32 maxPts);
/** @return Largest ring capacity currently allocated in this session. */
uint32 GetMaxRingCapacity() const;
/**
* @brief Newest timestamp this source has produced on its *own* clock, or
* 0 when it publishes no producer-timed signal (or has no data yet).
*
* Only signals that reference a time signal count: PACKET-timed signals
* are stamped on arrival and so live in the hub's wall-clock domain, not
* the producer's, even when they come from the very same source. A source
* free-running on its own clock sits seconds away from wall time and drifts,
* so anything waiting for a capture window to fill must compare against
* this, never clock_gettime().
*/
float64 ProducerNewestTime() const;
/** /**
* @brief Attach the (shared) hub trigger engine. * @brief Attach the (shared) hub trigger engine.
* Every decoded sample of the trigger's configured signal resolved * Every decoded sample of the trigger's configured signal resolved
@@ -241,24 +272,42 @@ private:
* signal @p tIdx given the first decoded timer value @p timer0S of the * signal @p tIdx given the first decoded timer value @p timer0S of the
* current packet and the arrival wall time @p wallNowS. * current packet and the arrival wall time @p wallNowS.
* *
* Re-anchors the offset (offset = wallNowS timer0S) when (a) it is the * Snaps the offset to wallNowS timer0S only when there is a genuine
* first packet, (b) the source clock jumped backward versus the previous * discontinuity in the source: the first packet, or a backward jump of the
* packet (a looping/rewinding producer), or (c) the computed wall time has * source clock (a looping/rewinding producer).
* drifted past kRecalibThresholdS from the true arrival wall time. *
* A source that free-runs on its own clock also *drifts* against wall time,
* without any discontinuity. Snapping that away would shift the whole
* published timeline in one step and so tear a hole of exactly the drift
* into a stream that is in fact continuous, which is worse than the drift
* itself. Past kRecalibThresholdS the offset is therefore slewed instead:
* nudged towards wall time by at most kMaxSlewFraction of the packet's own
* duration, so the seam can never exceed a fraction of one packet.
*
* @return the calibration offset to add to timer-seconds for this signal. * @return the calibration offset to add to timer-seconds for this signal.
*/ */
inline float64 CalibrateTimeSignal(uint32 tIdx, float64 timer0S, inline float64 CalibrateTimeSignal(uint32 tIdx, float64 timer0S,
float64 wallNowS) { float64 wallNowS) {
static const float64 kRecalibThresholdS = 2.0; static const float64 kRecalibThresholdS = 2.0;
static const float64 kMaxSlewFraction = 0.1;
const bool reset = timeSigLastValid_[tIdx] && const bool reset = timeSigLastValid_[tIdx] &&
(timer0S < timeSigLastTimerS_[tIdx]); (timer0S < timeSigLastTimerS_[tIdx]);
const float64 drift = (timeSigCalib_[tIdx] + timer0S) - wallNowS; if ((!timeSigCalibValid_[tIdx]) || reset) {
const float64 absDrift = (drift < 0.0) ? -drift : drift;
if ((!timeSigCalibValid_[tIdx]) || reset ||
(absDrift > kRecalibThresholdS)) {
timeSigCalib_[tIdx] = wallNowS - timer0S; timeSigCalib_[tIdx] = wallNowS - timer0S;
timeSigCalibValid_[tIdx] = true; timeSigCalibValid_[tIdx] = true;
} }
else {
const float64 drift = (timeSigCalib_[tIdx] + timer0S) - wallNowS;
const float64 absDrift = (drift < 0.0) ? -drift : drift;
if (absDrift > kRecalibThresholdS) {
const float64 pktSpan = timer0S - timeSigLastTimerS_[tIdx];
const float64 maxStep = pktSpan * kMaxSlewFraction;
float64 step = -drift;
if (step > maxStep) { step = maxStep; }
if (step < -maxStep) { step = -maxStep; }
timeSigCalib_[tIdx] += step;
}
}
timeSigLastTimerS_[tIdx] = timer0S; timeSigLastTimerS_[tIdx] = timer0S;
timeSigLastValid_[tIdx] = true; timeSigLastValid_[tIdx] = true;
return timeSigCalib_[tIdx]; return timeSigCalib_[tIdx];
+78 -17
View File
@@ -8,6 +8,7 @@
#include "SHA1.h" #include "SHA1.h"
#include "Base64.h" #include "Base64.h"
#include "AdvancedErrorManagement.h" #include "AdvancedErrorManagement.h"
#include "Select.h"
#include "Sleep.h" #include "Sleep.h"
#include "Threads.h" #include "Threads.h"
#include "TimeoutType.h" #include "TimeoutType.h"
@@ -57,8 +58,10 @@ static const char *FindSubstr(const char *s, const char *pattern) {
WSServer::WSServer() WSServer::WSServer()
: numClients(0u), : numClients(0u),
liveReadThreads(0u),
callback(static_cast<WSCommandCallback *>(0)), callback(static_cast<WSCommandCallback *>(0)),
running(false), running(false),
numAllowedOrigins(0u),
acceptTid(MARTe::InvalidThreadIdentifier) { acceptTid(MARTe::InvalidThreadIdentifier) {
for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) { for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) {
@@ -66,6 +69,20 @@ WSServer::WSServer()
clients[i].active = false; clients[i].active = false;
clients[i].readTid = MARTe::InvalidThreadIdentifier; clients[i].readTid = MARTe::InvalidThreadIdentifier;
} }
for (uint32 i = 0u; i < WS_MAX_ORIGINS; i++) {
allowedOrigins[i][0] = '\0';
}
}
bool WSServer::AddAllowedOrigin(const char *origin) {
if ((origin == static_cast<const char *>(0)) || (origin[0] == '\0')) {
return false;
}
if (numAllowedOrigins >= WS_MAX_ORIGINS) { return false; }
if (strlen(origin) >= WS_MAX_ORIGIN_LEN) { return false; }
strcpy(allowedOrigins[numAllowedOrigins], origin);
numAllowedOrigins++;
return true;
} }
WSServer::~WSServer() { WSServer::~WSServer() {
@@ -104,9 +121,9 @@ bool WSServer::Start(uint16 port, WSCommandCallback *cb) {
bool WSServer::Stop() { bool WSServer::Stop() {
if (!running) { return true; } if (!running) { return true; }
running = false; running = false;
Sleep::MSec(200u);
/* Close all client connections — their read threads will exit on error */ /* Close all client connections — their read threads wake out of select()
* and unwind through FreeSlot. */
(void) clientsMutex.FastLock(); (void) clientsMutex.FastLock();
for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) { for (uint32 i = 0u; i < WS_MAX_CLIENTS; i++) {
if (clients[i].active && (clients[i].sock != static_cast<BasicTCPSocket *>(0))) { if (clients[i].active && (clients[i].sock != static_cast<BasicTCPSocket *>(0))) {
@@ -114,10 +131,23 @@ bool WSServer::Stop() {
} }
} }
clientsMutex.FastUnLock(); clientsMutex.FastUnLock();
Sleep::MSec(200u);
/* The accept loop polls WaitConnection with a 500 ms timeout, so it is out
* of the listener by now. */
Sleep::MSec(600u);
tcpListener.Close(); tcpListener.Close();
Sleep::MSec(100u);
/* Wait for the read threads: they hold pointers to the sockets freed
* below. Bounded leaking a socket at exit beats deleting one that a
* wedged thread is still reading from. */
static const uint32 kReadJoinMs = 3000u;
for (uint32 waited = 0u; waited < kReadJoinMs; waited += 20u) {
(void) clientsMutex.FastLock();
const uint32 live = liveReadThreads;
clientsMutex.FastUnLock();
if (live == 0u) { break; }
Sleep::MSec(20u);
}
/* Free any remaining slots */ /* Free any remaining slots */
(void) clientsMutex.FastLock(); (void) clientsMutex.FastLock();
@@ -170,6 +200,10 @@ void WSServer::AcceptLoop() {
} }
/* Start per-client read thread */ /* Start per-client read thread */
(void) clientsMutex.FastLock();
liveReadThreads++;
clientsMutex.FastUnLock();
ClientThreadArg *arg = new ClientThreadArg(); ClientThreadArg *arg = new ClientThreadArg();
arg->srv = this; arg->srv = this;
arg->slot = slot; arg->slot = slot;
@@ -200,12 +234,28 @@ bool WSServer::UpgradeHTTP(BasicTCPSocket *sock) {
} }
/* Origin validation (CSWSH / CSRF defence, RFC 6455 §10.2). /* Origin validation (CSWSH / CSRF defence, RFC 6455 §10.2).
* If an Origin header is present, its host must match the Host header * If an Origin header is present it must either be on the configured
* (same-origin). Non-browser clients (no Origin) are allowed. */ * allowlist or its host must match the Host header (same-origin).
* Non-browser clients (no Origin) are allowed. */
const char *originHdr = FindSubstr(hdrBuf, "Origin:"); const char *originHdr = FindSubstr(hdrBuf, "Origin:");
if (originHdr != static_cast<const char *>(0)) { if (originHdr != static_cast<const char *>(0)) {
originHdr += 7; /* skip "Origin:" */ originHdr += 7; /* skip "Origin:" */
while (*originHdr == ' ') { originHdr++; } while (*originHdr == ' ') { originHdr++; }
/* Full origin value "scheme://host[:port]", for the allowlist. */
char originFull[WS_MAX_ORIGIN_LEN];
uint32 ofLen = 0u;
while ((originHdr[ofLen] != '\r') && (originHdr[ofLen] != '\n') &&
(originHdr[ofLen] != '\0') && (ofLen < (WS_MAX_ORIGIN_LEN - 1u))) {
originFull[ofLen] = originHdr[ofLen];
ofLen++;
}
originFull[ofLen] = '\0';
bool allowed = false;
for (uint32 i = 0u; (i < numAllowedOrigins) && !allowed; i++) {
if (strcmp(originFull, allowedOrigins[i]) == 0) { allowed = true; }
}
/* Extract the host part of Origin: "scheme://host[:port]" */ /* Extract the host part of Origin: "scheme://host[:port]" */
char originHost[256]; char originHost[256];
uint32 ohLen = 0u; uint32 ohLen = 0u;
@@ -221,7 +271,7 @@ bool WSServer::UpgradeHTTP(BasicTCPSocket *sock) {
/* Extract Host header value */ /* Extract Host header value */
const char *hostHdr = FindSubstr(hdrBuf, "Host:"); const char *hostHdr = FindSubstr(hdrBuf, "Host:");
if (hostHdr != static_cast<const char *>(0)) { if (!allowed && (hostHdr != static_cast<const char *>(0))) {
hostHdr += 5; /* skip "Host:" */ hostHdr += 5; /* skip "Host:" */
while (*hostHdr == ' ') { hostHdr++; } while (*hostHdr == ' ') { hostHdr++; }
char hostVal[256]; char hostVal[256];
@@ -299,24 +349,31 @@ void WSServer::ClientReadLoop(uint32 slotIdx) {
uint32 filled = 0u; uint32 filled = 0u;
while (running && slot.active) { while (running && slot.active) {
/* Read more bytes (with short timeout so we can check running) */
uint32 want = kRecvBuf - filled; uint32 want = kRecvBuf - filled;
if (want == 0u) { if (want == 0u) {
/* Buffer full — discard old frame (shouldn't happen with reasonable clients) */ /* Buffer full — discard old frame (shouldn't happen with reasonable clients) */
filled = 0u; filled = 0u;
continue; continue;
} }
bool ok = sock->Read(reinterpret_cast<char *>(buf + filled), want,
TimeoutType(500u)); /* Wait for readability before reading. BasicTCPSocket::Read reports a
if (!ok) { * timeout and a closed peer identically (false, zero bytes), so polling
/* Timeout or error — check running and retry */ * it on its own cannot end the loop: once the client goes away recv
if (!running) { break; } * returns immediately and forever, and the thread spins at 100% CPU
if (want == kRecvBuf) { * until it starves the rest of the hub. select() tells the two apart
/* Zero bytes read — connection likely closed */ * readable followed by no data is end of stream. A wait consumes the
* handle set, hence a fresh Select each pass. */
MARTe::Select sel;
if (!sel.AddReadHandle(*sock)) { break; }
const MARTe::int32 ready = sel.WaitUntil(TimeoutType(500u));
if (ready == 0) { continue; } /* idle client — recheck running */
if (ready < 0) { break; } /* socket closed or errored */
/* Readable: this returns at once, and only fails at end of stream. */
if (!sock->Read(reinterpret_cast<char *>(buf + filled), want,
TimeoutType(500u))) {
break; break;
} }
continue;
}
filled += want; filled += want;
/* Parse as many complete frames as possible */ /* Parse as many complete frames as possible */
@@ -383,6 +440,10 @@ client_done:
callback->OnWSClientDisconnected(); callback->OnWSClientDisconnected();
} }
FreeSlot(slotIdx); FreeSlot(slotIdx);
(void) clientsMutex.FastLock();
if (liveReadThreads > 0u) { liveReadThreads--; }
clientsMutex.FastUnLock();
} }
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
+25 -1
View File
@@ -34,6 +34,12 @@ static const uint32 WS_MAX_RECV_PAYLOAD = 65536u;
/** Maximum WebSocket frame payload we will send (data frames can be large). */ /** Maximum WebSocket frame payload we will send (data frames can be large). */
static const uint32 WS_MAX_SEND_PAYLOAD = 4u * 1024u * 1024u; /* 4 MiB */ static const uint32 WS_MAX_SEND_PAYLOAD = 4u * 1024u * 1024u; /* 4 MiB */
/** Maximum entries in the Origin allowlist. */
static const uint32 WS_MAX_ORIGINS = 8u;
/** Maximum length of one allowlisted Origin ("scheme://host[:port]"). */
static const uint32 WS_MAX_ORIGIN_LEN = 128u;
/** /**
* @brief Callback interface implemented by StreamHub. * @brief Callback interface implemented by StreamHub.
*/ */
@@ -77,6 +83,20 @@ public:
*/ */
bool Start(uint16 port, WSCommandCallback *cb); bool Start(uint16 port, WSCommandCallback *cb);
/**
* @brief Add an Origin that is accepted for the WebSocket upgrade.
*
* With an empty allowlist (the default) only same-origin requests pass:
* the Origin's host must equal the Host header, which excludes the usual
* deployment where the SPA is served by a separate web server on another
* port. Add that server's origin (e.g. "http://localhost:8080") to allow
* it. Requests without an Origin header (non-browser clients) always pass.
*
* @param origin "scheme://host[:port]", compared verbatim.
* @return false if the allowlist is full or the string is too long.
*/
bool AddAllowedOrigin(const char *origin);
/** /**
* @brief Stop accept thread; close all client connections; close listener. * @brief Stop accept thread; close all client connections; close listener.
*/ */
@@ -119,11 +139,15 @@ private:
BasicTCPSocket tcpListener; BasicTCPSocket tcpListener;
WSClientSlot clients[WS_MAX_CLIENTS]; WSClientSlot clients[WS_MAX_CLIENTS];
uint32 numClients; uint32 numClients;
mutable FastPollingMutexSem clientsMutex; ///< Protects numClients and clients[] array uint32 liveReadThreads; ///< Read threads not yet unwound; Stop() waits on it
mutable FastPollingMutexSem clientsMutex; ///< Protects numClients, liveReadThreads and clients[] array
WSCommandCallback *callback; WSCommandCallback *callback;
volatile bool running; volatile bool running;
char allowedOrigins[WS_MAX_ORIGINS][WS_MAX_ORIGIN_LEN];
uint32 numAllowedOrigins;
MARTe::ThreadIdentifier acceptTid; MARTe::ThreadIdentifier acceptTid;
}; };
@@ -16,6 +16,10 @@ TimeArrayGAM::TimeArrayGAM() :
GAM(), GAM(),
samplingRate(1000000.0), samplingRate(1000000.0),
anchorIsFirst(true), anchorIsFirst(true),
anchorIsCont(false),
contStarted(false),
contOriginNs(0u),
contSamples(0u),
nElements(0u), nElements(0u),
inputTime(NULL_PTR(uint32 *)), inputTime(NULL_PTR(uint32 *)),
outputBuf(NULL_PTR(uint64 *)) { outputBuf(NULL_PTR(uint64 *)) {
@@ -42,9 +46,12 @@ bool TimeArrayGAM::Initialise(StructuredDataI &data) {
else if (anchor == "LastSample") { else if (anchor == "LastSample") {
anchorIsFirst = false; anchorIsFirst = false;
} }
else if (anchor == "Continuous") {
anchorIsCont = true;
}
else { else {
REPORT_ERROR(ErrorManagement::InitialisationError, REPORT_ERROR(ErrorManagement::InitialisationError,
"TimeArrayGAM: Anchor must be 'FirstSample' or 'LastSample'."); "TimeArrayGAM: Anchor must be 'FirstSample', 'LastSample' or 'Continuous'.");
ok = false; ok = false;
} }
} }
@@ -88,7 +95,21 @@ bool TimeArrayGAM::Execute() {
/* Input is uint32 microseconds (LinuxTimer); convert to nanoseconds. */ /* Input is uint32 microseconds (LinuxTimer); convert to nanoseconds. */
uint64 anchorNs = static_cast<uint64>(*inputTime) * 1000u; uint64 anchorNs = static_cast<uint64>(*inputTime) * 1000u;
if (anchorIsFirst) { if (anchorIsCont) {
/* Latch the timer once, then run off an internal sample counter so a
* lost RT cycle (LinuxTimer re-phases with counter += nCycles) cannot
* punch a hole into an otherwise contiguous sample stream. */
if (!contStarted) {
contOriginNs = anchorNs;
contStarted = true;
}
for (uint32 k = 0u; k < nElements; k++) {
outputBuf[k] = contOriginNs +
(contSamples + static_cast<uint64>(k)) * periodNs;
}
contSamples += static_cast<uint64>(nElements);
}
else if (anchorIsFirst) {
/* out[k] = anchorNs + k * periodNs */ /* out[k] = anchorNs + k * periodNs */
for (uint32 k = 0u; k < nElements; k++) { for (uint32 k = 0u; k < nElements; k++) {
outputBuf[k] = anchorNs + static_cast<uint64>(k) * periodNs; outputBuf[k] = anchorNs + static_cast<uint64>(k) * periodNs;
@@ -10,6 +10,15 @@
* *
* Anchor = FirstSample: out[k] = input + k * period_us * Anchor = FirstSample: out[k] = input + k * period_us
* Anchor = LastSample: out[k] = input - (N-1-k) * period_us * Anchor = LastSample: out[k] = input - (N-1-k) * period_us
* Anchor = Continuous: out[k] = input(first cycle) + (n + k) * period_us
*
* FirstSample/LastSample re-read the timer every cycle, so they propagate any
* cycle the RT thread loses: LinuxTimer re-phases (counter += nCycles) and the
* emitted time base jumps by a whole period while only one array of samples is
* produced, leaving a hole. Continuous anchors once and then advances an
* internal sample counter by N per cycle, which is what an acquisition card
* with its own clock does use it when the data signal is itself contiguous
* (SineArrayGAM, for instance, never skips phase on a lost cycle).
* *
* The resulting time array is suitable as the TimeSignal for a UDPStreamer signal * The resulting time array is suitable as the TimeSignal for a UDPStreamer signal
* configured with TimeMode = FullArray, providing exact per-sample timestamps. * configured with TimeMode = FullArray, providing exact per-sample timestamps.
@@ -19,7 +28,7 @@
* +TimeArrayGAM1 = { * +TimeArrayGAM1 = {
* Class = TimeArrayGAM * Class = TimeArrayGAM
* SamplingRate = 1000000.0 // Sample rate in Hz (must match data signal) * SamplingRate = 1000000.0 // Sample rate in Hz (must match data signal)
* Anchor = FirstSample // FirstSample (default) or LastSample * Anchor = FirstSample // FirstSample (default), LastSample or Continuous
* InputSignals = { * InputSignals = {
* Time = { DataSource = DDB; Type = uint32 } * Time = { DataSource = DDB; Type = uint32 }
* } * }
@@ -54,6 +63,10 @@ public:
private: private:
float64 samplingRate; /**< Sample rate [Hz] */ float64 samplingRate; /**< Sample rate [Hz] */
bool anchorIsFirst; /**< true = FirstSample anchor, false = LastSample */ bool anchorIsFirst; /**< true = FirstSample anchor, false = LastSample */
bool anchorIsCont; /**< true = Continuous anchor (internal sample counter) */
bool contStarted; /**< Continuous: origin has been latched */
uint64 contOriginNs; /**< Continuous: timer value latched on the first cycle */
uint64 contSamples; /**< Continuous: samples emitted so far */
uint32 nElements; /**< Number of output elements */ uint32 nElements; /**< Number of output elements */
uint32 *inputTime; /**< Pointer to scalar input (microseconds, uint32 from LinuxTimer) */ uint32 *inputTime; /**< Pointer to scalar input (microseconds, uint32 from LinuxTimer) */
uint64 *outputBuf; /**< Pointer to output array (nanoseconds, uint64) */ uint64 *outputBuf; /**< Pointer to output array (nanoseconds, uint64) */
@@ -121,7 +121,7 @@ TEST(TriggerEngineGTest, TestConfigClamping) {
TriggerEngine eng; TriggerEngine eng;
eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 100.0, 150.0)); eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 100.0, 150.0));
TriggerConfig cfg = eng.GetConfig(); TriggerConfig cfg = eng.GetConfig();
EXPECT_DOUBLE_EQ(10.0, cfg.windowSec); EXPECT_DOUBLE_EQ(60.0, cfg.windowSec);
EXPECT_DOUBLE_EQ(100.0, cfg.prePercent); EXPECT_DOUBLE_EQ(100.0, cfg.prePercent);
eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 1.0e-6, -5.0)); eng.SetConfig(MakeConfig(kEdgeRising, 0.0, 1.0e-6, -5.0));
+2 -2
View File
@@ -175,7 +175,7 @@ $App = {
+TimeArrayGAM1 = { +TimeArrayGAM1 = {
Class = TimeArrayGAM Class = TimeArrayGAM
SamplingRate = 1000000.0 SamplingRate = 1000000.0
Anchor = "FirstSample" Anchor = "Continuous"
InputSignals = { InputSignals = {
Time = { Time = {
DataSource = DDB2 DataSource = DDB2
@@ -291,7 +291,7 @@ $App = {
+TimeArrayGAM2 = { +TimeArrayGAM2 = {
Class = TimeArrayGAM Class = TimeArrayGAM
SamplingRate = 5000000.0 SamplingRate = 5000000.0
Anchor = "FirstSample" Anchor = "Continuous"
InputSignals = { InputSignals = {
Time = { Time = {
DataSource = DDB3 DataSource = DDB3
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# run_streamhub.sh — Launch a MARTe2 app with UDPStreamer + StreamHub
#
# Usage:
# ./run_streamhub.sh [OPTIONS]
#
# Options:
# -m <MARTe2_DIR> Override MARTe2 installation dir (default: $MARTe2_DIR)
# -c <MARTe2_Components_DIR> Override MARTe2-components dir (default: $MARTe2_Components_DIR)
# -b <BUILD_TARGET> Build target (default: x86-linux)
# -p <WS_PORT> StreamHub WebSocket port (default: 8090)
# -n <MAX_POINTS> StreamHub ring-buffer size per signal (default: 10000)
# -s Skip building — run with whatever is already built
# -g Launch the ImGui desktop client after start
# -w Build and launch the web UI server (Client/webui)
# -h Show this help
#
# Ports used:
# 44500/udp UDPStreamer scalar signals (unicast control)
# 44503/udp UDPStreamer scalar signals (multicast data, group 239.0.0.1)
# 44501/udp UDPStreamer array signals (FirstSample / LastSample)
# 44502/udp UDPStreamer array signals (FullArray)
# 8080/tcp DebugService control
# 8081/udp DebugService stream
# 9090/tcp TCPLogger
# 8090/tcp StreamHub WebSocket (default, override with -p)
#
# Environment:
# MARTe2_DIR must be set (or passed via -m)
# MARTe2_Components_DIR must be set (or passed via -c)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MARTe_CFG="${SCRIPT_DIR}/Test/Configurations/streamhub_demo.cfg"
BUILD_TARGET="${TARGET:-x86-linux}"
WS_PORT=8090
MAX_POINTS=1000000
SKIP_BUILD=0
START_GUI=0
START_WEBUI=0
WEBUI_PORT=8080
# ── Parse arguments ───────────────────────────────────────────────────────────
while getopts "m:c:b:p:n:sgwh" opt; do
case "$opt" in
m) MARTe2_DIR="$OPTARG" ;;
c) MARTe2_Components_DIR="$OPTARG" ;;
b) BUILD_TARGET="$OPTARG" ;;
p) WS_PORT="$OPTARG" ;;
n) MAX_POINTS="$OPTARG" ;;
s) SKIP_BUILD=1 ;;
g) START_GUI=1 ;;
w) START_WEBUI=1 ;;
h)
sed -n '2,30p' "$0" | grep '^#' | sed 's/^# \?//'
exit 0
;;
*) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
esac
done
# ── Validate environment ──────────────────────────────────────────────────────
if [[ -z "${MARTe2_DIR:-}" ]]; then
echo "ERROR: MARTe2_DIR is not set. Source env.sh first or pass -m <dir>."
echo " source ${SCRIPT_DIR}/env.sh"
exit 1
fi
if [[ -z "${MARTe2_Components_DIR:-}" ]]; then
echo "ERROR: MARTe2_Components_DIR is not set. Source env.sh first or pass -c <dir>."
exit 1
fi
BUILD_DIR="${SCRIPT_DIR}/Build/${BUILD_TARGET}"
STREAMHUB_EX="${BUILD_DIR}/StreamHub/StreamHub.ex"
IMGUI_CLIENT="${SCRIPT_DIR}/Client/streamhub/build/StreamHubClient"
WEBUI_DIR="${SCRIPT_DIR}/Client/webui"
WEBUI_BIN="${WEBUI_DIR}/streamhub-webui"
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTeApp.ex"
if [[ ! -x "$MARTE2_BIN" ]]; then
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTe2.sh"
fi
if [[ ! -x "$MARTE2_BIN" ]]; then
echo "ERROR: MARTe2 executable not found at ${MARTe2_DIR}/Build/${BUILD_TARGET}/App/"
exit 1
fi
# ── Build ─────────────────────────────────────────────────────────────────────
if [[ "$SKIP_BUILD" -eq 0 ]]; then
echo "==> Building MARTe2 components (TARGET=${BUILD_TARGET})..."
make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" 2>&1 | tail -10
echo "==> Building StreamHub (TARGET=${BUILD_TARGET})..."
make -C "${SCRIPT_DIR}/Source/Applications/StreamHub" \
-f Makefile.gcc TARGET="${BUILD_TARGET}" \
MARTe2_DIR="${MARTe2_DIR}" 2>&1 | tail -10
if [[ "$START_GUI" -eq 1 ]]; then
if [[ -d "${SCRIPT_DIR}/Client/streamhub/build" ]]; then
echo "==> Building ImGui client (a full rebuild can take ~2 min;"
echo " ImPlot's implot_items.cpp is one slow -O3 translation unit)..."
cmake --build "${SCRIPT_DIR}/Client/streamhub/build" -j"$(nproc)"
fi
fi
if [[ "$START_WEBUI" -eq 1 ]]; then
echo "==> Building web UI server..."
(cd "${WEBUI_DIR}" && go build -o streamhub-webui .)
fi
echo "==> Build done."
fi
# ── Sanity-check binaries ─────────────────────────────────────────────────────
if [[ ! -x "$STREAMHUB_EX" ]]; then
echo "ERROR: StreamHub binary not found: ${STREAMHUB_EX}"
echo " Build it with: make -C Source/Applications/StreamHub -f Makefile.gcc"
exit 1
fi
# ── Write StreamHub config ────────────────────────────────────────────────────
HUB_CFG="$(mktemp /tmp/streamhub_XXXXXX.cfg)"
cat > "$HUB_CFG" <<EOF
/**
* StreamHub configuration — auto-generated by run_streamhub.sh
*
* Three sources matching the streamhub_demo MARTe2 configuration:
* scalar : 1 kHz scalar sines @ 1 ksps (Sine1, Sine2)
* med : 1 kHz arrays @ 1 Msps (Ch1, Ch2)
* fast : 5 kHz arrays @ 5 Msps (Ch3, Ch4)
*/
Hub = {
WSPort = ${WS_PORT}
MaxPoints = ${MAX_POINTS}
PushRate = 30
MaxPushPoints = 2000
RingTemporal = 1000000
RingScalar = 100000
+History = {
Directory = "/tmp/streamhub_history"
DurationHours = 1
Decimation = 10
FlushIntervalSec = 5
MinDiskFreeMB = 200
}
+Recorder = {
Enabled = 0
AutoStart = 1
Directory = "/tmp/streamhub_rec"
MaxFileMB = 256
KeepFiles = 8
StagingMB = 8
FlushIntervalSec = 5
MinDiskFreeMB = 500
Signals = "all"
}
Sources = {
scalar = {
Label = "Scalar Sines (1 ksps)"
Addr = "127.0.0.1"
Port = 44500
MulticastGroup = "239.0.0.1"
DataPort = 44503
}
med = {
Label = "1 Msps Sines (Ch1 1kHz, Ch2 5kHz)"
Addr = "127.0.0.1"
Port = 44501
}
fast = {
Label = "5 Msps Sines (Ch3 10kHz, Ch4 50kHz)"
Addr = "127.0.0.1"
Port = 44502
}
}
}
EOF
# ── Library path — covers both MARTe2 app and StreamHub ──────────────────────
export LD_LIBRARY_PATH="\
${MARTe2_DIR}/Build/${BUILD_TARGET}/Core:\
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/DataSources/LinuxTimer:\
${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/Interfaces/UDPStream:\
${LD_LIBRARY_PATH:-}"
# ── Cleanup handler ───────────────────────────────────────────────────────────
MARTE_PID=""
HUB_PID=""
GUI_PID=""
WEBUI_PID=""
cleanup() {
echo ""
echo "==> Shutting down..."
[[ -n "$WEBUI_PID" ]] && kill "$WEBUI_PID" 2>/dev/null || true
[[ -n "$GUI_PID" ]] && kill "$GUI_PID" 2>/dev/null || true
[[ -n "$HUB_PID" ]] && kill "$HUB_PID" 2>/dev/null || true
[[ -n "$MARTE_PID" ]] && kill "$MARTE_PID" 2>/dev/null || true
wait "$WEBUI_PID" 2>/dev/null || true
wait "$GUI_PID" 2>/dev/null || true
wait "$HUB_PID" 2>/dev/null || true
wait "$MARTE_PID" 2>/dev/null || true
rm -f "$HUB_CFG"
echo "==> Done."
}
trap cleanup EXIT INT TERM
# ── Launch MARTe2 ─────────────────────────────────────────────────────────────
echo ""
echo "==> Launching MARTe2..."
echo " Binary : ${MARTE2_BIN}"
echo " Config : ${MARTe_CFG}"
echo " Signals: Sine1 (1 Hz), Sine2 (0.3 Hz), Ch1-Ch4 (arrays)"
echo ""
"${MARTE2_BIN}" \
-l RealTimeLoader \
-f "${MARTe_CFG}" \
-s Running \
-m StateMachine:START &
MARTE_PID="$!"
# Give MARTe2 a moment to bind its UDP ports before StreamHub connects
sleep 1
# ── Launch StreamHub ──────────────────────────────────────────────────────────
echo "==> Launching StreamHub..."
echo " Binary : ${STREAMHUB_EX}"
echo " Config : ${HUB_CFG}"
echo " WS port : ${WS_PORT}"
echo " MaxPoints: ${MAX_POINTS}"
echo ""
"${STREAMHUB_EX}" -cfg "${HUB_CFG}" &
HUB_PID="$!"
# ── Optionally launch the ImGui client ───────────────────────────────────────
if [[ "$START_GUI" -eq 1 ]]; then
if [[ ! -x "$IMGUI_CLIENT" ]]; then
echo "WARNING: ImGui client not found at ${IMGUI_CLIENT}"
echo " Build it with: cd Client/streamhub && cmake -B build && cmake --build build"
else
sleep 0.5
echo "==> Launching ImGui client (127.0.0.1:${WS_PORT})..."
"${IMGUI_CLIENT}" -host 127.0.0.1 -port "${WS_PORT}" &
GUI_PID="$!"
fi
fi
# ── Optionally launch the web UI server ──────────────────────────────────────
if [[ "$START_WEBUI" -eq 1 ]]; then
if [[ ! -x "$WEBUI_BIN" ]]; then
echo "WARNING: webui binary not found at ${WEBUI_BIN}"
echo " Build it with: cd Client/webui && go build -o streamhub-webui ."
else
echo "==> Launching web UI server (:${WEBUI_PORT})..."
"${WEBUI_BIN}" -addr ":${WEBUI_PORT}" \
-hub "localhost:${WS_PORT}" \
-static "${SCRIPT_DIR}/Client/udpstreamer/static" &
WEBUI_PID="$!"
fi
fi
# ── Status ────────────────────────────────────────────────────────────────────
echo " MARTe2 PID : ${MARTE_PID}"
echo " StreamHub PID: ${HUB_PID}"
[[ -n "$GUI_PID" ]] && echo " ImGui PID : ${GUI_PID}"
[[ -n "$WEBUI_PID" ]] && echo " WebUI PID : ${WEBUI_PID}"
echo ""
echo " StreamHub WebSocket: ws://127.0.0.1:${WS_PORT}"
[[ -n "$WEBUI_PID" ]] && echo " Browser client : http://localhost:${WEBUI_PORT}/"
[[ -x "$IMGUI_CLIENT" ]] && echo " ImGui client : ${IMGUI_CLIENT} -host 127.0.0.1 -port ${WS_PORT}"
echo ""
echo " Press Ctrl-C to stop all processes."
echo ""
# ── Wait until any child exits ────────────────────────────────────────────────
wait -n "${MARTE_PID}" "${HUB_PID}" ${GUI_PID:-} ${WEBUI_PID:-} 2>/dev/null || true
echo "==> A process exited — stopping remaining processes."
+3
View File
@@ -138,6 +138,9 @@ Hub = {
MaxPushPoints = 2000 MaxPushPoints = 2000
RingTemporal = 1000000 RingTemporal = 1000000
RingScalar = 100000 RingScalar = 100000
// The SPA is served on WEBUI_PORT, not WSPort, so its Origin does not match
// the hub's Host and the default same-origin check would 403 the handshake.
AllowedOrigins = "http://localhost:${WEBUI_PORT},http://127.0.0.1:${WEBUI_PORT}"
+History = { +History = {
Directory = "/tmp/streamhub_history" Directory = "/tmp/streamhub_history"
DurationHours = 1 DurationHours = 1
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env bash
# run_udp_producer.sh — Run a MARTe2 app that streams N sine channels at 1 Msps.
#
# 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.
#
# Usage:
# ./run_udp_producer.sh [OPTIONS]
#
# Options:
# -n <CHANNELS> Number of 1 Msps channels (default 4, max 13 — see below)
# -p <PORT> UDP port to stream on (default 44501)
# -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.
#
# Environment:
# MARTe2_DIR must be set (or source env.sh first)
# MARTe2_Components_DIR must be set (or source env.sh first)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_TARGET="${TARGET:-x86-linux}"
CHANNELS=4
PORT=44501
SKIP_BUILD=0
KEEP_CFG=0
MAX_CHANNELS=13
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
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
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
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
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
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)
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
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]}"
sine_gams+="
+SineGAM${i} = {
Class = SineArrayGAM
Frequency = ${frq}.0
Amplitude = ${amp}
Phase = ${pha}
Offset = 0.0
SamplingRate = 1000000.0
OutputSignals = {
Ch${i} = {
DataSource = DDB1
Type = float32
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}
}
}
"
iogam_in+="
Ch${i} = {
DataSource = DDB1
Type = float32
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}"
iogam_out+="
Ch${i} = {
DataSource = Streamer
Type = float32
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}"
stream_sigs+="
Ch${i} = {
Type = float32
Unit = \"V\"
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
RangeMin = -${amp}
RangeMax = ${amp}
TimeMode = \"FullArray\"
TimeSignal = TimeArray
}"
func_list+=", SineGAM${i}"
done
func_list+=", TimeArrayGAM1, StreamerGAM"
CFG="$(mktemp /tmp/udp_producer_XXXXXX.cfg)"
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}.
*/
\$App = {
Class = RealTimeApplication
+Functions = {
Class = ReferenceContainer
+TimerGAM = {
Class = IOGAM
InputSignals = {
Time = {
DataSource = Timer
Type = uint32
Frequency = ${RATE}
}
}
OutputSignals = {
Time = {
DataSource = DDB1
Type = uint32
}
}
}
${sine_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 = {
Class = TimeArrayGAM
SamplingRate = 1000000.0
Anchor = "Continuous"
InputSignals = {
Time = {
DataSource = DDB1
Type = uint32
}
}
OutputSignals = {
TimeArray = {
DataSource = DDB1
Type = uint64
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}
}
}
+StreamerGAM = {
Class = IOGAM
InputSignals = {
TimeArray = {
DataSource = DDB1
Type = uint64
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}${iogam_in}
}
OutputSignals = {
TimeArray = {
DataSource = Streamer
Type = uint64
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}${iogam_out}
}
}
}
+Data = {
Class = ReferenceContainer
DefaultDataSource = DDB1
+DDB1 = {
Class = GAMDataSource
}
+Timer = {
Class = LinuxTimer
SleepNature = "Default"
Signals = {
Counter = {
Type = uint32
}
Time = {
Type = uint32
}
}
}
+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.
MaxPayloadSize = ${PAYLOAD}
PublishingMode = "Strict"
Signals = {
TimeArray = {
Type = uint64
Unit = "ns"
NumberOfDimensions = 1
NumberOfElements = ${ELEMS}
}${stream_sigs}
}
}
+Timings = {
Class = TimingDataSource
}
}
+States = {
Class = ReferenceContainer
+Running = {
Class = RealTimeState
+Threads = {
Class = ReferenceContainer
+Thread1 = {
Class = RealTimeThread
CPUs = 0x2
Functions = { ${func_list} }
}
}
}
}
+Scheduler = {
Class = GAMScheduler
TimingDataSource = Timings
}
}
EOF
# ── Run ───────────────────────────────────────────────────────────────────────
BUILD_DIR="${SCRIPT_DIR}/Build/${BUILD_TARGET}"
# UDPStream is not used directly here, but UDPStreamer.so carries a NEEDED entry
# on it, so dlopen of the DataSource fails without it on the path.
export LD_LIBRARY_PATH="\
${MARTe2_DIR}/Build/${BUILD_TARGET}/Core:\
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/DataSources/LinuxTimer:\
${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/Interfaces/UDPStream:\
${LD_LIBRARY_PATH:-}"
cleanup() {
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]}"
done
echo " Cycle : ${CYCLE_BYTES} B (MaxPayloadSize ${PAYLOAD})"
echo " Config : ${CFG}"
echo ""
echo " Consume with e.g.:"
echo " Addr = \"127.0.0.1\" Port = ${PORT} (StreamHub source)"
echo ""
echo " Press Ctrl-C to stop."
echo ""
exec "${MARTE2_BIN}" \
-l RealTimeLoader \
-f "${CFG}" \
-s Running \
-m StateMachine:START