Implemented and fixed many issues
This commit is contained in:
@@ -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
@@ -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` |
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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,9 +852,13 @@ 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
|
||||||
.arg(QString::fromStdString(sig.meta.name))
|
* the header's Y-Scale button already shows — so show just the name. */
|
||||||
.arg(fmtVal(a.vs.resolvedDiv)));
|
b->setText(vMode_ == 3
|
||||||
|
? QString::fromStdString(sig.meta.name)
|
||||||
|
: QString("%1 %2/div")
|
||||||
|
.arg(QString::fromStdString(sig.meta.name))
|
||||||
|
.arg(fmtVal(a.vs.resolvedDiv)));
|
||||||
QColor c = sig.color;
|
QColor c = sig.color;
|
||||||
QString fg = (activeSlot_ == i) ? "#11111b" : "#11111b";
|
QString fg = (activeSlot_ == i) ? "#11111b" : "#11111b";
|
||||||
QColor bg = (activeSlot_ == i) ? col::blue() : c;
|
QColor bg = (activeSlot_ == i) ? col::blue() : c;
|
||||||
@@ -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(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
m.addSeparator();
|
/* In unified mode the plot has one scale for every trace, so it is edited
|
||||||
QMenu* vs = m.addMenu("V-scale");
|
* from the header's Y-Scale button instead of from any one signal. */
|
||||||
const char* modes[] = {"Auto", "Range", "Manual"};
|
if (vMode_ != 3) {
|
||||||
for (int mm = 0; mm < 3; mm++) {
|
m.addSeparator();
|
||||||
QAction* act = vs->addAction(modes[mm]);
|
buildVScaleMenu(m.addMenu("V-scale"), a.vs);
|
||||||
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", [&]() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"marte2/common/wshub"
|
"marte2/common/wshub"
|
||||||
)
|
)
|
||||||
@@ -21,20 +24,56 @@ var staticFiles embed.FS
|
|||||||
// multiFlag allows a flag to be repeated: --source a --source b
|
// multiFlag allows a flag to be repeated: --source a --source b
|
||||||
type multiFlag []string
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+794
-262
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]);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
};
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+233
-74
@@ -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.
|
||||||
trigger *triggerEngine
|
// 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
|
||||||
|
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
|
}
|
||||||
rE := int(float64(i+1)*every) + 1
|
if vIn[j] > vIn[iMax] {
|
||||||
if rE > n {
|
iMax = j
|
||||||
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}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func TestHubSetCalibrationCommand(t *testing.T) {
|
|||||||
sendCh := make(chan wsMessage, 64)
|
sendCh := make(chan wsMessage, 64)
|
||||||
c := &wsClient{hub: h, send: sendCh}
|
c := &wsClient{hub: h, send: sendCh}
|
||||||
h.register <- c
|
h.register <- c
|
||||||
sleepMillis(20) // let Run() process the register and flush initial state msgs
|
sleepMillis(20) // let Run() process the register and flush initial state msgs
|
||||||
drainSendCh(sendCh) // discard state-sync messages (sources, trigger, cal, ...)
|
drainSendCh(sendCh) // discard state-sync messages (sources, trigger, cal, ...)
|
||||||
|
|
||||||
h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
|
h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,30 +14,334 @@ 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++ {
|
||||||
|
rb.pushLocked(tArr[i], vArr[i])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
for i := 0; i < len(tArr); i++ {
|
for i := 0; i < len(tArr); i++ {
|
||||||
rb.t[rb.head] = tArr[i]
|
t, v := tArr[i], vArr[i]
|
||||||
rb.v[rb.head] = vArr[i]
|
if rb.accN == 0 {
|
||||||
rb.head = (rb.head + 1) % rb.cap
|
rb.accTMin, rb.accVMin, rb.accTMax, rb.accVMax = t, v, t, v
|
||||||
if rb.size < rb.cap {
|
} else {
|
||||||
rb.size++
|
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
|
||||||
|
if rb.size < rb.cap {
|
||||||
|
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].
|
||||||
// The returned slices are safe to use after the call without holding any lock.
|
// The returned slices are safe to use after the call without holding any lock.
|
||||||
func (rb *sigRing) slice(t0, t1 float64) ([]float64, []float64) {
|
func (rb *sigRing) slice(t0, t1 float64) ([]float64, []float64) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ 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.
|
//
|
||||||
// nBytes: raw datagram size (header+payload).
|
// complete: this fragment completed the DATA reassembly.
|
||||||
|
// 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) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -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]"
|
||||||
@@ -36,7 +58,8 @@ type trigConfig struct {
|
|||||||
threshold float64
|
threshold float64
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 `[trigTime−preSec, trigTime+postSec]` from all
|
read `[trigTime−preSec, 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
|
||||||
|
|||||||
@@ -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 − (N−1−k) · 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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
trigger_.MarkTriggered();
|
if (justEntered || (capBuf_ == static_cast<MARTe::uint8 *>(0))) {
|
||||||
TriggerConfig cfg = trigger_.GetConfig();
|
BeginTriggerCapture(trigTime, preSec, postSec);
|
||||||
if ((cfg.mode == kTrigNormal) && !trigger_.GetStopped()) {
|
}
|
||||||
rearmPending_ = true;
|
/* Harvest on the *data's* clock. trigTime comes from the sample
|
||||||
rearmAtWallS_ = wallNowS + kAutoRearmDelayS;
|
* 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();
|
||||||
|
TriggerConfig cfg = trigger_.GetConfig();
|
||||||
|
if ((cfg.mode == kTrigNormal) && !trigger_.GetStopped()) {
|
||||||
|
rearmPending_ = true;
|
||||||
|
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,109 +1826,116 @@ 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;
|
StreamString sid = sess.GetId();
|
||||||
uint8 *buf = new uint8[cap];
|
const uint32 numSigs = sess.GetNumSignals();
|
||||||
uint32 off = 0u;
|
|
||||||
|
|
||||||
/* Header: [u8 2][f64 trigTime][f64 preSec][f64 postSec][u32 nSig] */
|
for (uint32 s = 0u; s < numSigs; s++) {
|
||||||
buf[off++] = 2u;
|
MARTe::UDPSSignalDescriptor desc;
|
||||||
memcpy(buf + off, &trigTime, 8u); off += 8u;
|
if (!sess.GetSignalDescriptor(s, desc)) { continue; }
|
||||||
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++) {
|
const uint32 nRaw = sess.ReadSignalRange(s, t0, t1,
|
||||||
if (!sessionActive_[i]) { continue; }
|
tRaw, vRaw, scratchCap);
|
||||||
UDPSourceSession &sess = sessions_[i];
|
if (nRaw == 0u) { continue; }
|
||||||
if (!sess.IsConfigured()) { continue; }
|
|
||||||
|
|
||||||
StreamString sid = sess.GetId();
|
const float64 *tOut = tRaw;
|
||||||
const uint32 numSigs = sess.GetNumSignals();
|
const float64 *vOut = vRaw;
|
||||||
|
uint32 nOut = nRaw;
|
||||||
for (uint32 s = 0u; s < numSigs; s++) {
|
if (nRaw > kTrigCapturePts) {
|
||||||
MARTe::UDPSSignalDescriptor desc;
|
nOut = LTTBDecimate(tRaw, vRaw, nRaw, tDec, vDec, kTrigCapturePts);
|
||||||
if (!sess.GetSignalDescriptor(s, desc)) { continue; }
|
tOut = tDec;
|
||||||
|
vOut = vDec;
|
||||||
const uint32 nRaw = sess.ReadSignalRange(s, t0, t1,
|
|
||||||
tRaw, vRaw, scratchCap);
|
|
||||||
if (nRaw == 0u) { continue; }
|
|
||||||
|
|
||||||
const float64 *tOut = tRaw;
|
|
||||||
const float64 *vOut = vRaw;
|
|
||||||
uint32 nOut = nRaw;
|
|
||||||
if (nRaw > kTrigCapturePts) {
|
|
||||||
nOut = LTTBDecimate(tRaw, vRaw, nRaw, tDec, vDec,
|
|
||||||
kTrigCapturePts);
|
|
||||||
tOut = tDec;
|
|
||||||
vOut = vDec;
|
|
||||||
}
|
|
||||||
|
|
||||||
char fullKey[192];
|
|
||||||
const int kn = snprintf(fullKey, sizeof(fullKey), "%s:%s",
|
|
||||||
sid.Buffer(), desc.name);
|
|
||||||
if (kn <= 0) { continue; }
|
|
||||||
const uint32 keyLen = static_cast<uint32>(kn);
|
|
||||||
|
|
||||||
const uint32 need = 2u + keyLen + 4u + nOut * 16u;
|
|
||||||
if ((off + need) > cap) {
|
|
||||||
uint32 newCap = cap * 2u;
|
|
||||||
while ((off + need) > newCap) { newCap *= 2u; }
|
|
||||||
uint8 *nb = new uint8[newCap];
|
|
||||||
memcpy(nb, buf, off);
|
|
||||||
delete[] buf;
|
|
||||||
buf = nb;
|
|
||||||
cap = newCap;
|
|
||||||
}
|
|
||||||
|
|
||||||
buf[off++] = static_cast<uint8>( keyLen & 0xFFu);
|
|
||||||
buf[off++] = static_cast<uint8>((keyLen >> 8) & 0xFFu);
|
|
||||||
memcpy(buf + off, fullKey, keyLen);
|
|
||||||
off += keyLen;
|
|
||||||
|
|
||||||
buf[off++] = static_cast<uint8>( nOut & 0xFFu);
|
|
||||||
buf[off++] = static_cast<uint8>((nOut >> 8) & 0xFFu);
|
|
||||||
buf[off++] = static_cast<uint8>((nOut >> 16) & 0xFFu);
|
|
||||||
buf[off++] = static_cast<uint8>((nOut >> 24) & 0xFFu);
|
|
||||||
|
|
||||||
memcpy(buf + off, tOut, nOut * sizeof(float64));
|
|
||||||
off += nOut * 8u;
|
|
||||||
memcpy(buf + off, vOut, nOut * sizeof(float64));
|
|
||||||
off += nOut * 8u;
|
|
||||||
|
|
||||||
nSigWritten++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
char fullKey[192];
|
||||||
|
const int kn = snprintf(fullKey, sizeof(fullKey), "%s:%s",
|
||||||
|
sid.Buffer(), desc.name);
|
||||||
|
if (kn <= 0) { continue; }
|
||||||
|
const uint32 keyLen = static_cast<uint32>(kn);
|
||||||
|
|
||||||
|
const uint32 need = 2u + keyLen + 4u + nOut * 16u;
|
||||||
|
if ((capOff_ + need) > capCap_) {
|
||||||
|
uint32 newCap = capCap_ * 2u;
|
||||||
|
while ((capOff_ + need) > newCap) { newCap *= 2u; }
|
||||||
|
uint8 *nb = new uint8[newCap];
|
||||||
|
memcpy(nb, capBuf_, capOff_);
|
||||||
|
delete[] capBuf_;
|
||||||
|
capBuf_ = nb;
|
||||||
|
capCap_ = newCap;
|
||||||
|
}
|
||||||
|
|
||||||
|
capBuf_[capOff_++] = static_cast<uint8>( keyLen & 0xFFu);
|
||||||
|
capBuf_[capOff_++] = static_cast<uint8>((keyLen >> 8) & 0xFFu);
|
||||||
|
memcpy(capBuf_ + capOff_, fullKey, keyLen);
|
||||||
|
capOff_ += keyLen;
|
||||||
|
|
||||||
|
capBuf_[capOff_++] = static_cast<uint8>( nOut & 0xFFu);
|
||||||
|
capBuf_[capOff_++] = static_cast<uint8>((nOut >> 8) & 0xFFu);
|
||||||
|
capBuf_[capOff_++] = static_cast<uint8>((nOut >> 16) & 0xFFu);
|
||||||
|
capBuf_[capOff_++] = static_cast<uint8>((nOut >> 24) & 0xFFu);
|
||||||
|
|
||||||
|
memcpy(capBuf_ + capOff_, tOut, nOut * sizeof(float64));
|
||||||
|
capOff_ += nOut * 8u;
|
||||||
|
memcpy(capBuf_ + capOff_, vOut, nOut * sizeof(float64));
|
||||||
|
capOff_ += nOut * 8u;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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,23 +349,30 @@ 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
|
||||||
break;
|
* handle set, hence a fresh Select each pass. */
|
||||||
}
|
MARTe::Select sel;
|
||||||
continue;
|
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;
|
||||||
}
|
}
|
||||||
filled += want;
|
filled += want;
|
||||||
|
|
||||||
@@ -383,6 +440,10 @@ client_done:
|
|||||||
callback->OnWSClientDisconnected();
|
callback->OnWSClientDisconnected();
|
||||||
}
|
}
|
||||||
FreeSlot(slotIdx);
|
FreeSlot(slotIdx);
|
||||||
|
|
||||||
|
(void) clientsMutex.FastLock();
|
||||||
|
if (liveReadThreads > 0u) { liveReadThreads--; }
|
||||||
|
clientsMutex.FastUnLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+285
@@ -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."
|
||||||
@@ -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
|
||||||
|
|||||||
Executable
+328
@@ -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
|
||||||
Reference in New Issue
Block a user