chore(e2e): retire streamhub/datasources/recorder standalone scripts superseded by run_e2e.sh
This commit is contained in:
@@ -127,8 +127,6 @@ cd Test/E2E/suite && python3 -m unittest tests_py
|
|||||||
| Script | What it does |
|
| Script | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `./run_streamhub.sh` | Launch MARTe2 app (UDPStreamer) + StreamHub, optionally web UI (`-w`) and ImGui client (`-g`). Ports documented in its header. |
|
| `./run_streamhub.sh` | Launch MARTe2 app (UDPStreamer) + StreamHub, optionally web UI (`-w`) and ImGui client (`-g`). Ports documented in its header. |
|
||||||
| `./run_combined_test.sh` | Combined streaming + debug integration test. `-d` auto-starts the debugger web UI. |
|
|
||||||
| `./run_e2e_test.sh` | Older standalone StreamHub E2E (`-s` skips build). |
|
|
||||||
| `./Test/E2E/suite/run_e2e.sh` | **Streaming-chain E2E suite**: per-scenario generates data + cfgs, runs MARTe2+StreamHub, drives the Go `chain-client` (live/zoom/window/trigger), validates the recorded waveform against an analytic/fed oracle, runs unit suites + coverage, builds a Typst PDF report. Flags: `--skip-build`, `--only <id>`, `--cpp-coverage`, `--pdf-only`, `--stress`. |
|
| `./Test/E2E/suite/run_e2e.sh` | **Streaming-chain E2E suite**: per-scenario generates data + cfgs, runs MARTe2+StreamHub, drives the Go `chain-client` (live/zoom/window/trigger), validates the recorded waveform against an analytic/fed oracle, runs unit suites + coverage, builds a Typst PDF report. Flags: `--skip-build`, `--only <id>`, `--cpp-coverage`, `--pdf-only`, `--stress`. |
|
||||||
| `./Test/E2E/suite/run_stress.sh` | **Capacity/stress harness**: sweeps one load axis at a time (signal size/count, subscriber fan-out, source count, WS-client count, zoom rate), gates on survival+liveness (hard) and RSS+zoom-p95 latency (soft). Flags: `--skip-build`, `--only <id>`, `--axis <axis>`. |
|
| `./Test/E2E/suite/run_stress.sh` | **Capacity/stress harness**: sweeps one load axis at a time (signal size/count, subscriber fan-out, source count, WS-client count, zoom rate), gates on survival+liveness (hard) and RSS+zoom-p95 latency (soft). Flags: `--skip-build`, `--only <id>`, `--axis <axis>`. |
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ cd Client/streamhub && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --buil
|
|||||||
cd Client/streamhub-qt && cmake -B build && cmake --build build
|
cd Client/streamhub-qt && cmake -B build && cmake --build build
|
||||||
```
|
```
|
||||||
|
|
||||||
End-to-end demo scripts (build + launch full stack, see headers for ports/options): `./run_combined_test.sh`, `./run_streamhub.sh`.
|
End-to-end demo script (build + launch full stack, see header for ports/options): `./run_streamhub.sh`.
|
||||||
|
|
||||||
**Streaming-chain E2E suite** (`Test/E2E/suite/`): `./run_e2e.sh [--skip-build] [--only <id>] [--cpp-coverage]` drives the full chain per scenario (`scenarios.py`) — generates typed/shaped input + both cfgs, runs MARTe2+StreamHub, records via the Go `chain-client` (live/zoom/window/trigger), and validates the recorded waveform against an analytic/fed oracle (`validate_waveform.py`: fidelity gates correctness, sine shape-fit is a gross-sanity gate + tracked metric pending Phase-A timestamp calibration). It then runs the unit suites + coverage (`collect.py`: C++ GTest, Go, Python; `--cpp-coverage` does an instrumented `--coverage` rebuild, captures with lcov restricted to `Source/*`+`Test/*`, then restores the clean build), consolidates everything into `report_data.json` with per-field progression/regression vs the previous run and trend plots (`report_build.py`, history in `Build/x86-linux/E2E/chain/history.jsonl`), and compiles a Typst PDF (`E2E_Report.typ`). Python framework unit tests: `python3 -m unittest tests_py` (in `Test/E2E/suite/`).
|
**Streaming-chain E2E suite** (`Test/E2E/suite/`): `./run_e2e.sh [--skip-build] [--only <id>] [--cpp-coverage]` drives the full chain per scenario (`scenarios.py`) — generates typed/shaped input + both cfgs, runs MARTe2+StreamHub, records via the Go `chain-client` (live/zoom/window/trigger), and validates the recorded waveform against an analytic/fed oracle (`validate_waveform.py`: fidelity gates correctness, sine shape-fit is a gross-sanity gate + tracked metric pending Phase-A timestamp calibration). It then runs the unit suites + coverage (`collect.py`: C++ GTest, Go, Python; `--cpp-coverage` does an instrumented `--coverage` rebuild, captures with lcov restricted to `Source/*`+`Test/*`, then restores the clean build), consolidates everything into `report_data.json` with per-field progression/regression vs the previous run and trend plots (`report_build.py`, history in `Build/x86-linux/E2E/chain/history.jsonl`), and compiles a Typst PDF (`E2E_Report.typ`). Python framework unit tests: `python3 -m unittest tests_py` (in `Test/E2E/suite/`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,413 +0,0 @@
|
|||||||
// UDPStreamerClient — E2E Test Report
|
|
||||||
// Author: Martino Ferrari
|
|
||||||
// Date: June 2026
|
|
||||||
#set document(
|
|
||||||
title: "UDPStreamerClient — End-to-End Test Report",
|
|
||||||
author: "Martino Ferrari",
|
|
||||||
date: datetime(year: 2026, month: 6, day: 24),
|
|
||||||
)
|
|
||||||
#set page(numbering: "1 / 1", margin: (left: 2.5cm, right: 2.5cm, top: 2cm, bottom: 2cm))
|
|
||||||
#set heading(numbering: "1.")
|
|
||||||
#set par(justify: true)
|
|
||||||
#show link: underline
|
|
||||||
#show raw.where(block: true): set block(inset: 8pt, radius: 4pt, fill: luma(240))
|
|
||||||
#set table(stroke: 0.5pt, inset: 8pt)
|
|
||||||
|
|
||||||
// ── Live validation data (emitted by validate_binary.py --json) ──
|
|
||||||
#let uni = json("e2e_unicast.json")
|
|
||||||
#let multi = json("e2e_multicast.json")
|
|
||||||
#let fidx(v) = if v < 0 { [—] } else { [#v] }
|
|
||||||
#let pct(n, d) = if d > 0 { [#(calc.round(100 * n / d, digits: 1))%] } else { [—] }
|
|
||||||
#let status-badge(d) = {
|
|
||||||
let c = if d.passed { green.darken(20%) } else { red.darken(10%) }
|
|
||||||
text(fill: c, weight: "bold")[#d.status]
|
|
||||||
}
|
|
||||||
// Validation metrics table for one mode's json record.
|
|
||||||
#let metrics-table(d) = table(
|
|
||||||
columns: (auto, auto, auto),
|
|
||||||
align: (left, right, left),
|
|
||||||
[*Metric*], [*Value*], [*Notes*],
|
|
||||||
[Output rows], [#d.n_rows_out], [Cycles captured by `FileWriter`],
|
|
||||||
[Matching rows], [#d.matching_rows (#pct(d.matching_rows, d.n_rows_out))], [Non-zero rows equal to an input row],
|
|
||||||
[Zero rows], [#d.zero_rows (#pct(d.zero_rows, d.n_rows_out))], [Startup transient before first `DATA`],
|
|
||||||
[Mismatching rows], [#d.mismatching_rows (#pct(d.mismatching_rows, d.n_rows_out))], [Non-zero rows matching no input → corruption],
|
|
||||||
[First matching row], [#fidx(d.first_matching_row)], [Index of first transported row],
|
|
||||||
[First zero row], [#fidx(d.first_zero_row)], [Index of first all-zero row],
|
|
||||||
[First mismatching row], [#fidx(d.first_mismatch_row)], [`—` when no corruption],
|
|
||||||
[Status], [#status-badge(d)], [#d.message],
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Title page ──
|
|
||||||
#align(center)[
|
|
||||||
#v(4cm)
|
|
||||||
#text(size: 28pt, weight: "bold")[UDPStreamerClient]
|
|
||||||
#v(0.5cm)
|
|
||||||
#text(size: 18pt)[End-to-End Test Report]
|
|
||||||
#v(1.5cm)
|
|
||||||
#text(size: 11pt, fill: luma(120))[
|
|
||||||
MARTe2 Input DataSource for receiving signal data from UDPStreamer server \
|
|
||||||
Unicast and multicast modes with event-driven thread triggering
|
|
||||||
]
|
|
||||||
#v(3cm)
|
|
||||||
#text(size: 10pt)[Martino Ferrari — June 2026]
|
|
||||||
]
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
#outline(indent: 1.5em, depth: 3)
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
// 1. Architecture
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
= Architecture Overview
|
|
||||||
|
|
||||||
== End-to-End Dataflow
|
|
||||||
|
|
||||||
#figure(
|
|
||||||
caption: [Pipeline from binary file input to binary file output across two MARTe2 threads.],
|
|
||||||
{
|
|
||||||
set text(size: 9pt)
|
|
||||||
grid(
|
|
||||||
columns: (1fr, 1fr, 1fr, 1fr, 1fr),
|
|
||||||
rows: (auto, auto, auto, auto, auto, auto, auto, auto, auto),
|
|
||||||
gutter: 4pt,
|
|
||||||
// Header row
|
|
||||||
grid.cell(colspan: 5, align(center)[*Thread 1 — 1 kHz, CPU 0x1*]),
|
|
||||||
grid.cell(colspan: 5, align(center)[#line(length: 100%)]),
|
|
||||||
// Row 1: sources
|
|
||||||
align(center)[#block(fill: luma(220), inset: 4pt, radius: 3pt, width: 100%)[`LinuxTimer`\ Counter, Time]],
|
|
||||||
align(center)[#text(fill: luma(140))[→]],
|
|
||||||
align(center)[#block(fill: luma(220), inset: 4pt, radius: 3pt, width: 100%)[`FileReader`\ `Signal[10000]`]],
|
|
||||||
align(center)[],
|
|
||||||
align(center)[],
|
|
||||||
// Row 2: IOGAM
|
|
||||||
grid.cell(colspan: 5, align(center)[
|
|
||||||
#block(fill: luma(210), inset: 6pt, radius: 4pt, width: 100%)[
|
|
||||||
*IOGAM* `ReaderGAM` \
|
|
||||||
_Input:_ `Counter, Time, Signal` from `DDB` + `FileReaderDS` \
|
|
||||||
_Output:_ `Counter, Time, Signal` to `DDB` + `Streamer`
|
|
||||||
]
|
|
||||||
]),
|
|
||||||
grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↕ memcpy]]),
|
|
||||||
// Row 3: UDPStreamer
|
|
||||||
grid.cell(colspan: 5, align(center)[
|
|
||||||
#block(fill: luma(200), inset: 8pt, radius: 4pt, width: 100%)[
|
|
||||||
*UDPStreamer* (port `44600`)\
|
|
||||||
`Synchronise()` copies `memory` → `readyBuffer` → posts `dataSem`\
|
|
||||||
`Execute()` (background) waits on `dataSem`, serializes, sends UDP
|
|
||||||
]
|
|
||||||
]),
|
|
||||||
grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↓ UDP datagrams ↓]]),
|
|
||||||
// Row 4: Network
|
|
||||||
grid.cell(colspan: 5)[#block(fill: luma(235), inset: 6pt, radius: 3pt, width: 100%)[#align(center)[*Network* — localhost loopback, unicast or multicast]]],
|
|
||||||
grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↓ UDP datagrams ↓]]),
|
|
||||||
// Row 5: UDPStreamerClient
|
|
||||||
grid.cell(colspan: 5, align(center)[
|
|
||||||
#block(fill: luma(200), inset: 8pt, radius: 4pt, width: 100%)[
|
|
||||||
*UDPStreamerClient* (owns a shared `UDPSClient` — same receiver as the StreamHub hub)\
|
|
||||||
`UDPSClient` background thread receives UDP, reassembles fragments, auto-reconnects,\
|
|
||||||
then invokes `OnUDPSConfig()` / `OnUDPSData()` → decode to `scratchBuffer` → `readyBuffer`, post `dataSem`\
|
|
||||||
`Synchronise()` (RT) blocks on `dataSem.ResetWait()` — _no `LinuxTimer` needed_
|
|
||||||
]
|
|
||||||
]),
|
|
||||||
grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↕ memcpy]]),
|
|
||||||
// Row 6: IOGAM
|
|
||||||
grid.cell(colspan: 5, align(center)[
|
|
||||||
#block(fill: luma(210), inset: 6pt, radius: 4pt, width: 100%)[
|
|
||||||
*IOGAM* `ClientGAM` \
|
|
||||||
_Input:_ `Signal` from `ClientDS` \
|
|
||||||
_Output:_ `Signal` to `FileWriterDS`
|
|
||||||
]
|
|
||||||
]),
|
|
||||||
grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↕ async write]]),
|
|
||||||
// Row 7: FileWriter
|
|
||||||
grid.cell(colspan: 5, align(center)[#block(fill: luma(220), inset: 4pt, radius: 3pt, width: 100%)[`FileWriter`\ async flush to binary file]]),
|
|
||||||
// Footer
|
|
||||||
grid.cell(colspan: 5, align(center)[#line(length: 100%)]),
|
|
||||||
grid.cell(colspan: 5, align(center)[*Thread 2 — Event-driven, CPU 0x2*]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
== Event-Driven Thread Trigger
|
|
||||||
|
|
||||||
Thread 2 does _not_ use a `LinuxTimer`. Execution is driven entirely by data arrival
|
|
||||||
via the `EventSem` pattern (also used by `SDNSubscriber`, `NI6368ADC`, `UARTDataSource`).
|
|
||||||
Crucially, `UDPStreamerClient` does *not* reimplement the network stack: it owns a shared
|
|
||||||
`MARTe::UDPSClient` (the very same receiver the StreamHub hub uses) and only implements the
|
|
||||||
`UDPSClientListener` callbacks. Transport, fragment reassembly, multicast join and
|
|
||||||
auto-reconnect are therefore identical to the hub by construction, with the wire format
|
|
||||||
shared through `Common/UDP/UDPSProtocol.h`.
|
|
||||||
|
|
||||||
#enum(
|
|
||||||
numbering: "1.",
|
|
||||||
[`UDPSClient` background thread (`SingleThreadService`) receives datagrams, reassembles fragments and auto-reconnects],
|
|
||||||
[On a complete payload it invokes the listener: `OnUDPSConfig()` validates the server CONFIG against the local signals; `OnUDPSData()` decodes one snapshot],
|
|
||||||
[`OnUDPSData()` decodes (incl. dequantisation / accumulate) into a private `scratchBuffer`, then copies to `readyBuffer` under `FastPollingMutexSem`],
|
|
||||||
[Posts `EventSem dataSem` to wake the real-time thread],
|
|
||||||
[`UDPStreamerClient::Synchronise()` (RT) blocks on `dataSem.ResetWait(10 ms)`, copies `readyBuffer` to `memory`],
|
|
||||||
[GAM executes, data flows to `FileWriter`],
|
|
||||||
)
|
|
||||||
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
// 2. Latency Budget
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
= Latency Budget
|
|
||||||
|
|
||||||
#figure(
|
|
||||||
image("latency_budget.png", width: 100%),
|
|
||||||
caption: [Estimated per-cycle latency. Total: 54 ms → ∼18 Hz max throughput. Bottlenecks: `FileWriter` async flush (50 ms) and poll sleeps (2 ms).],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Breakdown
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto, auto),
|
|
||||||
[*Stage*], [*Latency (ms)*], [*Notes*],
|
|
||||||
[`FileReader::Synchronise()`], [1.0], [Blocking read from OS buffer],
|
|
||||||
[`IOGAM` (memcpy)], [0.1], [24 KB copy (6100 float32)],
|
|
||||||
[`UDPStreamer::Synchronise()`], [1.0], [Copy `memory` → `readyBuffer` + post semaphore],
|
|
||||||
[`UDPStreamer::Execute()` (bg)], [1.0], [`Sleep::MSec(1)` poll interval],
|
|
||||||
[Network (localhost)], [0.05], [Loopback, negligible],
|
|
||||||
[`UDPSClient` receiver (bg)], [1.0], [`select()` timeout + decode in `OnUDPSData()`],
|
|
||||||
[`UDPStreamerClient::Synchronise()`], [0.01], [`ResetWait(10ms)`, copy, return],
|
|
||||||
[`IOGAM` (memcpy)], [0.1], [24 KB copy (6100 floats)],
|
|
||||||
[`FileWriter` (async flush)], [50.0], [Disk I/O, buffer count configurable],
|
|
||||||
[*Total*], [*54.3*], [*18 Hz max throughput*],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Observations
|
|
||||||
|
|
||||||
#list(
|
|
||||||
tight: false,
|
|
||||||
[1 ms poll sleeps in both `Execute()` loops minimize software latency. Total poll overhead: 2 ms.],
|
|
||||||
[`FileWriter` async flush dominates at 50 ms; reducing `NumberOfBuffers` or using CSV format lowers this.],
|
|
||||||
[Maximum theoretical throughput with zero sleeps and sync FileWriter: ∼500 Hz (limited by 24 KB memcpy).],
|
|
||||||
[The `EventSem` pattern eliminates timer jitter — cycle rate exactly matches network data rate.],
|
|
||||||
)
|
|
||||||
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
// 3. Test Results
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
= End-to-End Test Results
|
|
||||||
|
|
||||||
== Input Data
|
|
||||||
|
|
||||||
Multi-signal test file with three channels of different sizes to verify
|
|
||||||
no data scrambling across UDP transport:
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto, auto, auto),
|
|
||||||
[*Signal*], [*Type*], [*Elements*], [*Value Range*],
|
|
||||||
[`Signal_100`], [`float32`], [`100`], [`(row*1000 + col) / 100.0`],
|
|
||||||
[`Signal_1K`], [`float32`], [`1000`], [`(row*500 + col) / 50.0`],
|
|
||||||
[`Signal_5K`], [`float32`], [`5000`], [`(row*200 + col) / 20.0`],
|
|
||||||
)
|
|
||||||
|
|
||||||
Format: MARTe2 binary (42 B signal descriptor) + 6100 floats per row (24.4 KB/row).
|
|
||||||
100 rows total, 2.44 MB data.
|
|
||||||
|
|
||||||
#figure(
|
|
||||||
image("e2e_plots.png", width: 100%),
|
|
||||||
caption: [3×3 grid: Input, the matching received Output, and their Difference per signal. The plot picks the first non-zero output row that matches an input row (skipping startup zero rows), so the near-zero Difference column confirms lossless, unscrambled transport.],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Latency Distribution
|
|
||||||
|
|
||||||
#figure(
|
|
||||||
image("latency_histogram.png", width: 100%),
|
|
||||||
caption: [Left: End-to-end latency histogram (median 54 ms, P95 103 ms, P99 129 ms). Right: Per-component boxplot showing `FileWriter` async flush dominates the distribution.],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Unicast Test
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto),
|
|
||||||
[*Parameter*], [*Value*],
|
|
||||||
[Configuration], [`E2ETest.cfg`],
|
|
||||||
[Signals], [`3` (100 / 1000 / 5000 float32)],
|
|
||||||
[Server port], [`44600`],
|
|
||||||
[`MaxPayloadSize`], [`65507` (UDP max, no fragmentation)],
|
|
||||||
[`PublishingMode`], [`Strict`],
|
|
||||||
[Client thread], [Event-driven (no `LinuxTimer`)],
|
|
||||||
)
|
|
||||||
|
|
||||||
#block(fill: luma(240), inset: 10pt, radius: 4pt)[
|
|
||||||
*Status*: #status-badge(uni) --- #uni.message
|
|
||||||
]
|
|
||||||
|
|
||||||
#metrics-table(uni)
|
|
||||||
|
|
||||||
== Multicast Test
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto),
|
|
||||||
[*Parameter*], [*Value*],
|
|
||||||
[Configuration], [`E2EMulticastTest.cfg`],
|
|
||||||
[Signals], [`3` (100 / 1000 / 5000 float32)],
|
|
||||||
[Server], [TCP control on `44600`, UDP DATA on `239.0.0.1:44610`],
|
|
||||||
[`MaxPayloadSize`], [`65507` (UDP max, no fragmentation)],
|
|
||||||
[`PublishingMode`], [`Strict`],
|
|
||||||
[Client thread], [Event-driven (no `LinuxTimer`)],
|
|
||||||
)
|
|
||||||
|
|
||||||
#block(fill: luma(240), inset: 10pt, radius: 4pt)[
|
|
||||||
*Status*: #status-badge(multi) --- #multi.message
|
|
||||||
]
|
|
||||||
|
|
||||||
#metrics-table(multi)
|
|
||||||
|
|
||||||
== Result Interpretation
|
|
||||||
|
|
||||||
Both transports *pass*: every non-zero output row is byte-identical to an input row
|
|
||||||
(*zero mismatching rows*), confirming the `UDPSClient`-based transport is lossless and
|
|
||||||
does not scramble the three different-sized signals
|
|
||||||
(#uni.matching_rows of #uni.n_rows_out rows matched for unicast,
|
|
||||||
#multi.matching_rows of #multi.n_rows_out for multicast).
|
|
||||||
|
|
||||||
The only non-matching rows are the leading all-zero rows (#uni.zero_rows for unicast;
|
|
||||||
first real match at row #uni.first_matching_row). These are an expected start-up
|
|
||||||
transient: `FileWriter` begins capturing cycles the instant the application reaches
|
|
||||||
`Running`, a few cycles before the client has received its first `CONFIG` + `DATA`,
|
|
||||||
so the `MemoryDataSourceI` signal memory is still zero-initialised. Once data arrives
|
|
||||||
the output tracks the input exactly, hence *zero* mismatching rows.
|
|
||||||
|
|
||||||
=== Pass / Fail Criteria
|
|
||||||
|
|
||||||
`validate_binary.py` sorts every output row into exactly one bucket --- *zero*
|
|
||||||
(all-zero startup), *matching* (equals some input row) or *mismatching* (non-zero but
|
|
||||||
matches no input row) --- and fails on genuine corruption:
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto),
|
|
||||||
[*Condition*], [*Verdict*],
|
|
||||||
[Signal count / per-signal size / row size differ, or a file is unreadable/empty], [*FAIL*],
|
|
||||||
[`matching == 0` (nothing transported, incl. all-zero output)], [*FAIL*],
|
|
||||||
[`mismatching > 0` (a non-zero row matches no input row)], [*FAIL* --- corruption],
|
|
||||||
[`matching > 0`, `mismatching == 0`, with some zero rows], [*PASS* (WARN)],
|
|
||||||
[`matching == n_rows_out`], [*PASS*],
|
|
||||||
)
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
// 4. Implementation
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
= Implementation Summary
|
|
||||||
|
|
||||||
== Source Code
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto, auto),
|
|
||||||
[*File*], [*Lines*], [*Description*],
|
|
||||||
[`UDPStreamerClient.h`], [`204`], [Class + `UDPStreamerClientSignal` metadata declaration],
|
|
||||||
[`UDPStreamerClient.cpp`], [`564`], [CONFIG/DATA decode, double-buffering, `Synchronise()`],
|
|
||||||
[`Makefile.inc`], [`60`], [Includes + links `-lUDPStream`, `-lMARTe2`],
|
|
||||||
[`Makefile.gcc`], [`25`], [GCC compiler rules],
|
|
||||||
[`Makefile.cov`], [`25`], [Coverage rules],
|
|
||||||
[*Total*], [*878*], [],
|
|
||||||
)
|
|
||||||
|
|
||||||
The transport, fragment reassembly, multicast and auto-reconnect logic is *not* counted
|
|
||||||
here: it lives in the shared `Source/Components/Interfaces/UDPStream/UDPSClient` library
|
|
||||||
that the StreamHub hub also uses, so the DataSource itself stays thin.
|
|
||||||
|
|
||||||
== Protocol Support
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto, auto),
|
|
||||||
[*Packet*], [*Direction*], [*Status*],
|
|
||||||
[`CONNECT` (3)], [Client → Server], [✓],
|
|
||||||
[`CONFIG` (1)], [Server → Client], [✓ parse + validate],
|
|
||||||
[`DATA` (0)], [Server → Client], [✓ deserialize + dequantize + accumulate],
|
|
||||||
[`DISCONNECT` (4)], [Bidirectional], [✓],
|
|
||||||
[`ACK` (2)], [Client → Server], [✓ optional],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Features
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto),
|
|
||||||
[*Feature*], [*Status*],
|
|
||||||
[Reuses StreamHub hub code base (shared `UDPSClient`)], [✓],
|
|
||||||
[Unicast mode], [✓],
|
|
||||||
[Multicast mode (TCP control + UDP DATA join)], [✓],
|
|
||||||
[Fragment reassembly (delegated to `UDPSClient`)], [✓],
|
|
||||||
[Auto-reconnect on silence (delegated to `UDPSClient`)], [✓],
|
|
||||||
[CONFIG validation against local signals], [✓],
|
|
||||||
[Dequantization (uint8 / int8 / uint16 / int16)], [✓],
|
|
||||||
[Accumulate mode batch deserialization], [✓],
|
|
||||||
[Event-driven thread trigger (`EventSem`, no `LinuxTimer`)], [✓],
|
|
||||||
[RT-safe double buffering (`FastPollingMutexSem`)], [✓],
|
|
||||||
[`CLASS_REGISTER("1.0")`], [✓],
|
|
||||||
[`MemoryMapSynchronisedInputBroker`], [✓],
|
|
||||||
[Integrated into root `Makefile.gcc` `core`/`clean`], [✓],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Test Infrastructure
|
|
||||||
|
|
||||||
#table(
|
|
||||||
columns: (auto, auto),
|
|
||||||
[*File*], [*Description*],
|
|
||||||
[`E2ETest.cfg`], [Unicast MARTe2 config with 3 multi-size signals],
|
|
||||||
[`E2EMulticastTest.cfg`], [Multicast MARTe2 config (`239.0.0.1:44610`)],
|
|
||||||
[`run_e2e_report.sh`], [Builds, runs unicast+multicast, validates, plots, compiles this report],
|
|
||||||
[`validate_binary.py`], [Row-bucket comparison + `--json` metrics export],
|
|
||||||
[`gen_test_data.py`], [Multi-signal binary file generator],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Build
|
|
||||||
|
|
||||||
#block(fill: luma(235), inset: 10pt, radius: 4pt)[
|
|
||||||
```sh
|
|
||||||
# Built as part of the library via the repo root (Interfaces/UDPStream first,
|
|
||||||
# since UDPStreamerClient links -lUDPStream):
|
|
||||||
$ make -f Makefile.gcc core
|
|
||||||
|
|
||||||
# Or the component on its own:
|
|
||||||
$ make -C Source/Components/DataSources/UDPStreamerClient -f Makefile.gcc
|
|
||||||
g++ -std=c++98 -Wall -Werror -Wno-invalid-offsetof \
|
|
||||||
-fPIC -fno-strict-aliasing -frtti -pthread -g \
|
|
||||||
-I. -I$ROOT/Common/UDP \
|
|
||||||
-I$ROOT/Source/Components/Interfaces/UDPStream \
|
|
||||||
UDPStreamerClient.cpp -o UDPStreamerClient.o
|
|
||||||
g++ -shared UDPStreamerClient.o \
|
|
||||||
-L$ROOT/Build/x86-linux/Components/Interfaces/UDPStream -lUDPStream \
|
|
||||||
-L$MARTe2_DIR/Build/x86-linux/Core -lMARTe2 -o UDPStreamerClient.so
|
|
||||||
```
|
|
||||||
]
|
|
||||||
|
|
||||||
Builds clean under `-Werror`; the DataSource reuses the hub's `UDPSClient` rather than
|
|
||||||
duplicating any socket code.
|
|
||||||
|
|
||||||
#pagebreak()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
// 5. Next Steps
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
= Next Steps
|
|
||||||
|
|
||||||
== Short-Term
|
|
||||||
|
|
||||||
#list(
|
|
||||||
[*Reduce poll latency*: Lower `RECV_TIMEOUT_MS` from 10 to 1 ms. Lower `ResetWait` timeout from 1000 to 100 ms.],
|
|
||||||
[*Add GTest unit tests*: Fragment reassembly (2/5/100 fragments), dequantization accuracy, CONFIG parsing, accumulate mode.],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Medium-Term
|
|
||||||
|
|
||||||
#list(
|
|
||||||
[*Benchmark throughput*: Measure with varying signal sizes (100 / 1K / 10K / 100K floats) and plot curve.],
|
|
||||||
[*Multicast multi-client*: Verify multiple `UDPStreamerClient` instances join same group simultaneously.],
|
|
||||||
[*Remove poll sleeps entirely*: Use continuous `select()` with zero timeout + `EventSem` back-pressure.],
|
|
||||||
)
|
|
||||||
|
|
||||||
== Long-Term
|
|
||||||
|
|
||||||
#list(
|
|
||||||
[*CI integration*: Add E2E test runner with automated comparison and regression detection.],
|
|
||||||
[*Performance profiling*: Identify exact memcpy and serialization costs with `perf`.],
|
|
||||||
)
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run_e2e_report.sh — End-to-end test + report generation
|
|
||||||
#
|
|
||||||
# Usage: ./run_e2e_report.sh [--skip-tests] [--pdf-only]
|
|
||||||
#
|
|
||||||
# Steps:
|
|
||||||
# 1. Generate multi-signal test data
|
|
||||||
# 2. Build UDPStreamer + UDPStreamerClient
|
|
||||||
# 3. Run unicast and multicast E2E tests
|
|
||||||
# 4. Compare output against input
|
|
||||||
# 5. Generate plots (input/output/diff, latency budget, latency histogram)
|
|
||||||
# 6. Compile Typst report → PDF
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
|
||||||
TARGET=x86-linux
|
|
||||||
BUILD_DIR="${REPO_ROOT}/Build/${TARGET}"
|
|
||||||
# Generated artifacts (plots, copied template, PDF) go here — never in the source tree.
|
|
||||||
OUT_DIR="${BUILD_DIR}/E2E/datasources"
|
|
||||||
mkdir -p "${OUT_DIR}"
|
|
||||||
|
|
||||||
SKIP_TESTS=0
|
|
||||||
PDF_ONLY=0
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--skip-tests) SKIP_TESTS=1 ;;
|
|
||||||
--pdf-only) PDF_ONLY=1 ;;
|
|
||||||
--help|-h)
|
|
||||||
echo "Usage: $0 [--skip-tests] [--pdf-only]"
|
|
||||||
echo " --skip-tests Skip E2E tests, only generate plots + PDF"
|
|
||||||
echo " --pdf-only Only compile Typst → PDF (requires existing plots)"
|
|
||||||
exit 0 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Load environment ─────────────────────────────────────────────────────────
|
|
||||||
ENV_SCRIPT="${REPO_ROOT}/env.sh"
|
|
||||||
if [ ! -f "${ENV_SCRIPT}" ]; then
|
|
||||||
echo "ERROR: ${ENV_SCRIPT} not found." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
source "${ENV_SCRIPT}"
|
|
||||||
|
|
||||||
COMP="${MARTe2_Components_DIR}/Build/${TARGET}/Components"
|
|
||||||
export LD_LIBRARY_PATH="\
|
|
||||||
${BUILD_DIR}/Components/DataSources/UDPStreamerClient:\
|
|
||||||
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
|
|
||||||
${BUILD_DIR}/Components/Interfaces/UDPStream:\
|
|
||||||
${MARTe2_DIR}/Build/${TARGET}/Core:\
|
|
||||||
${COMP}/DataSources/LinuxTimer:\
|
|
||||||
${COMP}/DataSources/LoggerDataSource:\
|
|
||||||
${COMP}/DataSources/FileDataSource:\
|
|
||||||
${COMP}/GAMs/IOGAM:\
|
|
||||||
${LD_LIBRARY_PATH}"
|
|
||||||
|
|
||||||
MARTE_APP="${MARTe2_DIR}/Build/${TARGET}/App/MARTeApp.ex"
|
|
||||||
INPUT="/tmp/udpstreamer_test_input.bin"
|
|
||||||
OUTPUT_U="/tmp/udpstreamer_test_output.bin"
|
|
||||||
OUTPUT_M="/tmp/udpstreamer_test_output_multicast.bin"
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo " UDPStreamer E2E Test & Report Generator"
|
|
||||||
echo "=========================================="
|
|
||||||
|
|
||||||
# ── Step 1-2: Generate data + build ──────────────────────────────────────────
|
|
||||||
if [ "${PDF_ONLY}" -eq 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "── Step 1: Generating test data ──"
|
|
||||||
python3 "${SCRIPT_DIR}/gen_test_data.py"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── Step 2: Building components ──"
|
|
||||||
make -C "${REPO_ROOT}/Source/Components/Interfaces/UDPStream" \
|
|
||||||
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
|
|
||||||
make -C "${REPO_ROOT}/Source/Components/DataSources/UDPStreamerClient" \
|
|
||||||
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
|
|
||||||
make -C "${REPO_ROOT}/Source/Components/DataSources/UDPStreamer" \
|
|
||||||
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 3: Run E2E tests ────────────────────────────────────────────────────
|
|
||||||
run_test() {
|
|
||||||
local name="$1" cfg="$2" output="$3"
|
|
||||||
echo ""
|
|
||||||
echo "── Test: ${name} ──"
|
|
||||||
rm -f "${output}"
|
|
||||||
if [ ! -x "${MARTE_APP}" ]; then
|
|
||||||
echo " SKIP: MARTeApp.ex not found"; return 0
|
|
||||||
fi
|
|
||||||
timeout 6 "${MARTE_APP}" -l RealTimeLoader -f "${cfg}" -s Running 2>&1 | grep -E "^\[" > /tmp/e2e_log_${name}.txt &
|
|
||||||
local pid=$!
|
|
||||||
sleep 5
|
|
||||||
kill "${pid}" 2>/dev/null || true
|
|
||||||
wait "${pid}" 2>/dev/null || true
|
|
||||||
# Show log messages (reader/client first-element values)
|
|
||||||
grep -E "Log100_|Log1K_|Log5K_" /tmp/e2e_log_${name}.txt 2>/dev/null | head -20 || true
|
|
||||||
echo " Done."
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${SKIP_TESTS}" -eq 0 ] && [ "${PDF_ONLY}" -eq 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "── Step 3: Running E2E tests ──"
|
|
||||||
run_test "Unicast" "${SCRIPT_DIR}/E2ETest.cfg" "${OUTPUT_U}"
|
|
||||||
run_test "Multicast" "${SCRIPT_DIR}/E2EMulticastTest.cfg" "${OUTPUT_M}"
|
|
||||||
|
|
||||||
# ── Step 3b: Validate ──
|
|
||||||
echo ""
|
|
||||||
echo "── Results ──"
|
|
||||||
RESULTS="${OUT_DIR}/e2e_results.txt"
|
|
||||||
: > "${RESULTS}"
|
|
||||||
for label in unicast multicast; do
|
|
||||||
[ "$label" = "unicast" ] && out="${OUTPUT_U}" || out="${OUTPUT_M}"
|
|
||||||
python3 "${SCRIPT_DIR}/validate_binary.py" "${INPUT}" "${out}" --label "${label}" \
|
|
||||||
--json "${OUT_DIR}/e2e_${label}.json" 2>&1 | tee -a "${RESULTS}" || true
|
|
||||||
done
|
|
||||||
echo " Results saved to ${RESULTS} (+ e2e_unicast.json, e2e_multicast.json)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 4: Generate plots ───────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── Step 4: Generating plots ──"
|
|
||||||
cd "${OUT_DIR}"
|
|
||||||
|
|
||||||
python3 << 'PLOT_EOF'
|
|
||||||
import struct, os, numpy as np
|
|
||||||
import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as plt
|
|
||||||
from matplotlib.gridspec import GridSpec
|
|
||||||
|
|
||||||
INPUT="/tmp/udpstreamer_test_input.bin"
|
|
||||||
OUTPUT_U="/tmp/udpstreamer_test_output.bin"
|
|
||||||
|
|
||||||
def read_binary(fn):
|
|
||||||
if not os.path.exists(fn): return None,None
|
|
||||||
with open(fn,'rb') as f:
|
|
||||||
ns=struct.unpack('<I',f.read(4))[0]; sigs=[]
|
|
||||||
for _ in range(ns):
|
|
||||||
tc=struct.unpack('<H',f.read(2))[0]; nm=f.read(32).rstrip(b'\x00').decode()
|
|
||||||
ne=struct.unpack('<I',f.read(4))[0]; sigs.append((nm,tc,ne))
|
|
||||||
return sigs,f.read()
|
|
||||||
|
|
||||||
def row_bytes(sigs): return sum(ne for _,_,ne in sigs)*4
|
|
||||||
def offsets(sigs):
|
|
||||||
off=[0]
|
|
||||||
for _,_,ne in sigs: off.append(off[-1]+ne*4)
|
|
||||||
return off
|
|
||||||
def extract_row(sigs,raw,r):
|
|
||||||
rb=row_bytes(sigs); off=offsets(sigs); row=raw[r*rb:(r+1)*rb]
|
|
||||||
return {nm:np.frombuffer(row[off[i]:off[i]+ne*4],dtype=np.float32)
|
|
||||||
for i,(nm,_,ne) in enumerate(sigs)}
|
|
||||||
|
|
||||||
in_sigs,in_raw=read_binary(INPUT)
|
|
||||||
out_sigs,out_raw=read_binary(OUTPUT_U)
|
|
||||||
if in_sigs is None: print("No input data"); exit(0)
|
|
||||||
|
|
||||||
rb_in=row_bytes(in_sigs); nr_in=len(in_raw)//rb_in
|
|
||||||
# Map each input row (bytes) → its index for fast lookup.
|
|
||||||
input_row_idx={in_raw[r*rb_in:(r+1)*rb_in]:r for r in range(nr_in)}
|
|
||||||
|
|
||||||
# Pick the first NON-ZERO output row that matches an input row, so the figure
|
|
||||||
# shows real transported data rather than a startup zero row.
|
|
||||||
in_idx,out_idx=0,None
|
|
||||||
if out_sigs and out_raw:
|
|
||||||
rb_out=row_bytes(out_sigs); nr_out=len(out_raw)//rb_out
|
|
||||||
for r in range(nr_out):
|
|
||||||
rowb=out_raw[r*rb_out:(r+1)*rb_out]
|
|
||||||
if any(rowb) and rowb in input_row_idx:
|
|
||||||
out_idx=r; in_idx=input_row_idx[rowb]; break
|
|
||||||
|
|
||||||
in_row=extract_row(in_sigs,in_raw,in_idx)
|
|
||||||
out_row=extract_row(out_sigs,out_raw,out_idx) if out_idx is not None else None
|
|
||||||
sigs_plot=[s[0] for s in in_sigs]; ns=len(sigs_plot)
|
|
||||||
|
|
||||||
fig=plt.figure(figsize=(18,4.5*ns))
|
|
||||||
gs=GridSpec(ns,3,figure=fig,hspace=0.4,wspace=0.3)
|
|
||||||
|
|
||||||
for ri,sn in enumerate(sigs_plot):
|
|
||||||
ne=[s[2] for s in in_sigs if s[0]==sn][0]; ia=in_row[sn]
|
|
||||||
x=np.arange(ne)
|
|
||||||
for ci,title in enumerate(['Input','Output','Difference']):
|
|
||||||
ax=fig.add_subplot(gs[ri,ci])
|
|
||||||
if ri==0: ax.set_title(title,fontsize=10,fontweight='bold')
|
|
||||||
ax.set_xlabel('Element'); ax.grid(True,alpha=0.3)
|
|
||||||
if ci==0:
|
|
||||||
ax.plot(x,ia,'b-',lw=0.3)
|
|
||||||
ax.set_ylabel(f'{sn}\nValue'); ax.set_ylim(np.min(ia)-0.1,np.max(ia)+0.1)
|
|
||||||
elif ci==1:
|
|
||||||
if out_row is not None:
|
|
||||||
ax.plot(x,out_row[sn],'r-',lw=0.3); ax.set_ylabel('Value')
|
|
||||||
else: ax.text(0.5,0.5,'No matching output row',transform=ax.transAxes,ha='center',va='center',color='gray')
|
|
||||||
else:
|
|
||||||
if out_row is not None:
|
|
||||||
diff=ia-out_row[sn]; ax.plot(x,diff,'g-',lw=0.3)
|
|
||||||
ax.set_ylabel('ΔValue'); ax.set_ylim(np.min(diff)-0.1,np.max(diff)+0.1)
|
|
||||||
md=np.max(np.abs(diff))
|
|
||||||
ax.text(0.98,0.95,f'max|Δ|={md:.4f}',transform=ax.transAxes,ha='right',va='top',fontsize=7,
|
|
||||||
bbox=dict(boxstyle='round',facecolor='wheat',alpha=0.5))
|
|
||||||
else: ax.text(0.5,0.5,'No matching output row',transform=ax.transAxes,ha='center',va='center',color='gray')
|
|
||||||
|
|
||||||
st=(f'UDPStreamer E2E — Input (row {in_idx}) vs Output (row {out_idx}) vs Difference'
|
|
||||||
if out_idx is not None else 'UDPStreamer E2E — Input vs Output (no matching output row)')
|
|
||||||
fig.suptitle(st,fontsize=13,fontweight='bold',y=0.998)
|
|
||||||
plt.savefig('e2e_plots.png',dpi=150,bbox_inches='tight'); plt.close()
|
|
||||||
print(' ✓ e2e_plots.png')
|
|
||||||
|
|
||||||
# Latency histogram
|
|
||||||
np.random.seed(42); n=10000
|
|
||||||
fr=np.random.normal(1,0.2,n); io1=np.random.normal(0.1,0.02,n)
|
|
||||||
us_s=np.random.normal(1,0.2,n); us_e=np.random.uniform(0.5,1.5,n)
|
|
||||||
net=np.random.normal(0.05,0.01,n); uc_e=np.random.uniform(0.5,1.5,n)
|
|
||||||
uc_s=np.random.exponential(0.01,n); io2=np.random.normal(0.1,0.02,n)
|
|
||||||
fw=np.random.lognormal(mean=np.log(50),sigma=0.4,size=n)
|
|
||||||
total=fr+io1+us_s+us_e+net+uc_e+uc_s+io2+fw
|
|
||||||
|
|
||||||
fig,(ax1,ax2)=plt.subplots(1,2,figsize=(16,6))
|
|
||||||
ax1.hist(total,bins=80,color='#3498db',edgecolor='white',alpha=0.8,density=True)
|
|
||||||
ax1.axvline(np.median(total),color='red',ls='--',lw=2,label=f'Median: {np.median(total):.1f} ms')
|
|
||||||
ax1.axvline(np.percentile(total,95),color='orange',ls='--',lw=2,label=f'P95: {np.percentile(total,95):.1f} ms')
|
|
||||||
ax1.axvline(np.percentile(total,99),color='darkred',ls='--',lw=2,label=f'P99: {np.percentile(total,99):.1f} ms')
|
|
||||||
ax1.set_xlabel('Latency (ms)'); ax1.set_ylabel('Density')
|
|
||||||
ax1.set_title('E2E Latency Distribution',fontweight='bold'); ax1.legend(fontsize=8); ax1.grid(True,alpha=0.3)
|
|
||||||
s=f'Median: {np.median(total):.1f} ms\nMean: {np.mean(total):.1f} ms\nP95: {np.percentile(total,95):.1f} ms\nP99: {np.percentile(total,99):.1f} ms'
|
|
||||||
ax1.text(0.98,0.95,s,transform=ax1.transAxes,ha='right',va='top',fontsize=8,family='monospace',
|
|
||||||
bbox=dict(boxstyle='round',facecolor='wheat',alpha=0.5))
|
|
||||||
|
|
||||||
data=[fr,io1,us_s,us_e,net,uc_e,uc_s,io2,fw]
|
|
||||||
lbls=['FileReader','IOGAM','Streamer\nSync','Streamer\nExec','Network','Client\nExec','Client\nSync','IOGAM','FileWriter']
|
|
||||||
cs=['#3498db','#2ecc71','#e74c3c','#f39c12','#9b59b6','#1abc9c','#e67e22','#2ecc71','#95a5a6']
|
|
||||||
bp=ax2.boxplot(data,patch_artist=True,showfliers=False)
|
|
||||||
for p,c in zip(bp['boxes'],cs): p.set_facecolor(c); p.set_alpha(0.7)
|
|
||||||
ax2.set_xticklabels(lbls,rotation=45,ha='right',fontsize=7)
|
|
||||||
ax2.set_ylabel('Latency (ms)'); ax2.set_title('Per-Component Distribution',fontweight='bold'); ax2.grid(True,alpha=0.3,axis='y')
|
|
||||||
plt.tight_layout(); plt.savefig('latency_histogram.png',dpi=150); plt.close()
|
|
||||||
print(' ✓ latency_histogram.png')
|
|
||||||
|
|
||||||
# Latency budget bar chart
|
|
||||||
fig,ax=plt.subplots(figsize=(12,6)); ax.axis('off')
|
|
||||||
comps=['FileReader Sync','IOGAM (memcpy)','Streamer Sync','Streamer Exec(bg)','Network(localhost)','Client Exec(bg)','Client Sync','IOGAM (memcpy)','FileWriter(async)']
|
|
||||||
lats=[1.0,0.1,1.0,1.0,0.05,1.0,0.01,0.1,50.0]
|
|
||||||
cs2=['#3498db','#2ecc71','#e74c3c','#f39c12','#9b59b6','#1abc9c','#e67e22','#2ecc71','#95a5a6']
|
|
||||||
yp=range(len(comps),0,-1)
|
|
||||||
bars=ax.barh(list(yp),lats,color=cs2,edgecolor='white',lw=1.5)
|
|
||||||
for b,l in zip(bars,lats):
|
|
||||||
ax.text(b.get_width()+0.2,b.get_y()+b.get_height()/2,f'{l:.1f} ms' if l>=1 else f'{l*1000:.0f} µs',va='center',fontsize=9,fontweight='bold')
|
|
||||||
ax.text(0.2,b.get_y()+b.get_height()/2,comps[len(comps)-int(b.get_y()+b.get_height())],va='center',fontsize=8,color='white',fontweight='bold')
|
|
||||||
ax.set_xlabel('Latency (ms)',fontsize=11)
|
|
||||||
ax.set_title('UDPStreamer E2E Latency Budget',fontsize=12,fontweight='bold')
|
|
||||||
t=sum(lats)
|
|
||||||
ax.text(0.15,-0.4,f'Total: {t:.1f} ms | Max throughput: {1000/t:.0f} Hz',fontsize=11,fontweight='bold',transform=ax.get_xaxis_transform())
|
|
||||||
plt.tight_layout(); plt.savefig('latency_budget.png',dpi=150); plt.close()
|
|
||||||
print(' ✓ latency_budget.png')
|
|
||||||
print(' All plots generated.')
|
|
||||||
PLOT_EOF
|
|
||||||
|
|
||||||
# ── Step 5: Compile Typst → PDF (optional) ──────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── Step 5: Compiling Typst report ──"
|
|
||||||
if [ ! -f "${SCRIPT_DIR}/E2E_Report.typ" ]; then
|
|
||||||
echo " SKIP: E2E_Report.typ template not present."
|
|
||||||
elif ! command -v typst >/dev/null 2>&1; then
|
|
||||||
echo " SKIP: typst not installed."
|
|
||||||
else
|
|
||||||
# Compile from the build dir so the template's relative image() paths
|
|
||||||
# resolve against the freshly generated PNGs; keep the source .typ pristine.
|
|
||||||
cp "${SCRIPT_DIR}/E2E_Report.typ" "${OUT_DIR}/E2E_Report.typ"
|
|
||||||
typst compile "${OUT_DIR}/E2E_Report.typ" "${OUT_DIR}/E2E_Report.pdf" 2>&1
|
|
||||||
if [ -f "${OUT_DIR}/E2E_Report.pdf" ]; then
|
|
||||||
SIZE=$(ls -lh "${OUT_DIR}/E2E_Report.pdf" | awk '{print $5}')
|
|
||||||
echo " ✓ Report generated: ${OUT_DIR}/E2E_Report.pdf (${SIZE})"
|
|
||||||
else
|
|
||||||
echo " ✗ Typst compilation failed"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " Done — artifacts in ${OUT_DIR}"
|
|
||||||
echo "=========================================="
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run_recorder_e2e.sh — End-to-end test for the StreamHub binary recorder.
|
|
||||||
#
|
|
||||||
# Data path:
|
|
||||||
# FileReader(/tmp/udpstreamer_test_input.bin)
|
|
||||||
# -> IOGAM -> UDPStreamer(:44600) [MARTe2 app, separate process]
|
|
||||||
# -> UDPS -> StreamHub UDPSClient
|
|
||||||
# -> BinaryRecorder -> /tmp/streamhub_rec_e2e/e2e_*.bin
|
|
||||||
#
|
|
||||||
# The recorder writes FileWriter-compatible binary files for un-quantized
|
|
||||||
# float32 signals, so each recorded row is byte-identical to a streamed row.
|
|
||||||
# validate_binary.py confirms every non-zero recorded row matches an input row.
|
|
||||||
#
|
|
||||||
# Usage: ./run_recorder_e2e.sh [--skip-build]
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
|
||||||
TARGET=x86-linux
|
|
||||||
BUILD_DIR="${REPO_ROOT}/Build/${TARGET}"
|
|
||||||
OUT_DIR="${BUILD_DIR}/E2E/recorder"
|
|
||||||
mkdir -p "${OUT_DIR}"
|
|
||||||
|
|
||||||
VALIDATOR="${SCRIPT_DIR}/../datasources/validate_binary.py"
|
|
||||||
GEN_DATA="${SCRIPT_DIR}/../datasources/gen_test_data.py"
|
|
||||||
INPUT="/tmp/udpstreamer_test_input.bin"
|
|
||||||
REC_DIR="/tmp/streamhub_rec_e2e"
|
|
||||||
|
|
||||||
SKIP_BUILD=0
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--skip-build) SKIP_BUILD=1 ;;
|
|
||||||
--help|-h) echo "Usage: $0 [--skip-build]"; exit 0 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Load environment ─────────────────────────────────────────────────────────
|
|
||||||
ENV_SCRIPT="${REPO_ROOT}/env.sh"
|
|
||||||
if [ ! -f "${ENV_SCRIPT}" ]; then
|
|
||||||
echo "ERROR: ${ENV_SCRIPT} not found." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
source "${ENV_SCRIPT}"
|
|
||||||
|
|
||||||
COMP="${MARTe2_Components_DIR}/Build/${TARGET}/Components"
|
|
||||||
export LD_LIBRARY_PATH="\
|
|
||||||
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
|
|
||||||
${BUILD_DIR}/Components/Interfaces/UDPStream:\
|
|
||||||
${MARTe2_DIR}/Build/${TARGET}/Core:\
|
|
||||||
${COMP}/DataSources/LinuxTimer:\
|
|
||||||
${COMP}/DataSources/FileDataSource:\
|
|
||||||
${COMP}/GAMs/IOGAM:\
|
|
||||||
${LD_LIBRARY_PATH:-}"
|
|
||||||
|
|
||||||
MARTE_APP="${MARTe2_DIR}/Build/${TARGET}/App/MARTeApp.ex"
|
|
||||||
STREAMHUB_EX="${BUILD_DIR}/StreamHub/StreamHub.ex"
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo " StreamHub Recorder E2E Test"
|
|
||||||
echo "=========================================="
|
|
||||||
|
|
||||||
# ── Step 1: Generate test data ───────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── Step 1: Generating test data ──"
|
|
||||||
python3 "${GEN_DATA}"
|
|
||||||
|
|
||||||
# ── Step 2: Build components ──────────────────────────────────────────────────
|
|
||||||
if [ "${SKIP_BUILD}" -eq 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "── Step 2: Building components ──"
|
|
||||||
make -C "${REPO_ROOT}/Source/Components/Interfaces/UDPStream" \
|
|
||||||
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
|
|
||||||
make -C "${REPO_ROOT}/Source/Components/DataSources/UDPStreamer" \
|
|
||||||
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
|
|
||||||
make -C "${REPO_ROOT}/Source/Applications/StreamHub" \
|
|
||||||
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -x "${MARTE_APP}" ]; then
|
|
||||||
echo "ERROR: MARTeApp.ex not found at ${MARTE_APP}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ ! -x "${STREAMHUB_EX}" ]; then
|
|
||||||
echo "ERROR: StreamHub.ex not found at ${STREAMHUB_EX}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 3: Run the stack ─────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── Step 3: Running StreamHub + UDPStreamer ──"
|
|
||||||
rm -rf "${REC_DIR}"
|
|
||||||
mkdir -p "${REC_DIR}"
|
|
||||||
|
|
||||||
HUB_LOG="${OUT_DIR}/streamhub.log"
|
|
||||||
APP_LOG="${OUT_DIR}/marte.log"
|
|
||||||
|
|
||||||
# Start StreamHub first so it is ready to receive the CONFIG packet.
|
|
||||||
"${STREAMHUB_EX}" -cfg "${SCRIPT_DIR}/StreamHubRec.cfg" > "${HUB_LOG}" 2>&1 &
|
|
||||||
HUB_PID=$!
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
kill "${HUB_PID}" 2>/dev/null || true
|
|
||||||
kill "${APP_PID}" 2>/dev/null || true
|
|
||||||
wait "${HUB_PID}" 2>/dev/null || true
|
|
||||||
wait "${APP_PID}" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
timeout 8 "${MARTE_APP}" -l RealTimeLoader -f "${SCRIPT_DIR}/RecorderStreamer.cfg" \
|
|
||||||
-s Running > "${APP_LOG}" 2>&1 &
|
|
||||||
APP_PID=$!
|
|
||||||
|
|
||||||
# Let data flow, then stop the streamer and give the push thread time to flush.
|
|
||||||
sleep 7
|
|
||||||
kill "${APP_PID}" 2>/dev/null || true
|
|
||||||
wait "${APP_PID}" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
kill "${HUB_PID}" 2>/dev/null || true
|
|
||||||
wait "${HUB_PID}" 2>/dev/null || true
|
|
||||||
trap - EXIT
|
|
||||||
|
|
||||||
echo " Done. StreamHub log: ${HUB_LOG}"
|
|
||||||
|
|
||||||
# ── Step 4: Validate the recorded file ───────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── Step 4: Validating recorded output ──"
|
|
||||||
# The recorder names files <sourceId>_<UTCstamp>_<seq>.bin; pick the newest.
|
|
||||||
REC_FILE="$(ls -t "${REC_DIR}"/*.bin 2>/dev/null | head -1 || true)"
|
|
||||||
if [ -z "${REC_FILE}" ]; then
|
|
||||||
echo " ✗ FAIL: no recorded .bin file in ${REC_DIR}"
|
|
||||||
echo " --- StreamHub log tail ---"
|
|
||||||
tail -20 "${HUB_LOG}" || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " Recorded file: ${REC_FILE} ($(stat -c%s "${REC_FILE}") B)"
|
|
||||||
|
|
||||||
python3 "${VALIDATOR}" "${INPUT}" "${REC_FILE}" --label "recorder" \
|
|
||||||
--json "${OUT_DIR}/recorder_e2e.json"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " Done — artifacts in ${OUT_DIR}"
|
|
||||||
echo "=========================================="
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module streamhub-e2e
|
|
||||||
|
|
||||||
go 1.21
|
|
||||||
|
|
||||||
require github.com/gorilla/websocket v1.5.1
|
|
||||||
|
|
||||||
require golang.org/x/net v0.17.0 // indirect
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
|
||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
|
||||||
@@ -1,561 +0,0 @@
|
|||||||
// Command streamhub-e2e is an end-to-end test client for the C++ StreamHub.
|
|
||||||
//
|
|
||||||
// It connects to a running StreamHub WebSocket endpoint (with at least one
|
|
||||||
// connected UDPStreamer source, e.g. the stack launched by run_e2e_test.sh)
|
|
||||||
// and verifies the full protocol:
|
|
||||||
//
|
|
||||||
// 1. "sources" event with at least one connected source
|
|
||||||
// 2. "config" event per source with at least one signal
|
|
||||||
// 3. binary v1 data pushes: parseable, per-signal monotonic time,
|
|
||||||
// timestamps within a few seconds of wall clock (Unix time base)
|
|
||||||
// 4. "stats" event with a positive receive rate
|
|
||||||
// 5. WS zoom round-trip: reqId echoed, points returned in [t0,t1]
|
|
||||||
// 6. hub-side trigger: setTrigger+arm → triggerState(armed) → binary v2
|
|
||||||
// capture frame with the latched pre/post window
|
|
||||||
//
|
|
||||||
// Exit code 0 on success; 1 with a FAIL message otherwise.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
var hub = flag.String("hub", "127.0.0.1:8090", "StreamHub host:port")
|
|
||||||
var timeout = flag.Duration("timeout", 30*time.Second, "overall test timeout")
|
|
||||||
var verbose = flag.Bool("v", false, "log every received event")
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Wire types (subset of the StreamHub JSON protocol)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type sourceInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Addr string `json:"addr"`
|
|
||||||
State string `json:"state"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type signalInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
TypeCode uint32 `json:"typeCode"`
|
|
||||||
NumRows uint32 `json:"numRows"`
|
|
||||||
NumCols uint32 `json:"numCols"`
|
|
||||||
TimeMode int `json:"timeMode"`
|
|
||||||
Rate float64 `json:"samplingRate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type statInfo struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
TotalReceived uint64 `json:"totalReceived"`
|
|
||||||
RateHz float64 `json:"rateHz"`
|
|
||||||
CycleHist []f64 `json:"cycleHist"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type f64 = float64
|
|
||||||
|
|
||||||
type zoomPoints struct {
|
|
||||||
T []float64 `json:"t"`
|
|
||||||
V []float64 `json:"v"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type historyInfoMsg struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
DurationHours float64 `json:"durationHours"`
|
|
||||||
Decimation uint32 `json:"decimation"`
|
|
||||||
Signals map[string]struct {
|
|
||||||
T0 float64 `json:"t0"`
|
|
||||||
T1 float64 `json:"t1"`
|
|
||||||
Count uint32 `json:"count"`
|
|
||||||
Capacity uint32 `json:"capacity"`
|
|
||||||
} `json:"signals"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type event struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Sources json.RawMessage `json:"sources"`
|
|
||||||
SourceID string `json:"sourceId"`
|
|
||||||
Signals json.RawMessage `json:"signals"`
|
|
||||||
ReqID uint32 `json:"reqId"`
|
|
||||||
State string `json:"state"`
|
|
||||||
TrigTime float64 `json:"trigTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parsed binary v1 push frame: sourceId → signal → samples.
|
|
||||||
type pushFrame struct {
|
|
||||||
sourceID string
|
|
||||||
signals map[string]zoomPoints
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parsed binary v2 capture frame.
|
|
||||||
type captureFrame struct {
|
|
||||||
trigTime, preSec, postSec float64
|
|
||||||
signals map[string]zoomPoints
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Binary parsers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parsePush(b []byte) (*pushFrame, error) {
|
|
||||||
if len(b) < 2 || b[0] != 1 {
|
|
||||||
return nil, fmt.Errorf("not a v1 frame")
|
|
||||||
}
|
|
||||||
idLen := int(b[1])
|
|
||||||
off := 2
|
|
||||||
if len(b) < off+idLen+4 {
|
|
||||||
return nil, fmt.Errorf("truncated header")
|
|
||||||
}
|
|
||||||
f := &pushFrame{sourceID: string(b[off : off+idLen]),
|
|
||||||
signals: map[string]zoomPoints{}}
|
|
||||||
off += idLen
|
|
||||||
nSig := int(binary.LittleEndian.Uint32(b[off:]))
|
|
||||||
off += 4
|
|
||||||
for s := 0; s < nSig; s++ {
|
|
||||||
if len(b) < off+2 {
|
|
||||||
return nil, fmt.Errorf("truncated keyLen (sig %d)", s)
|
|
||||||
}
|
|
||||||
keyLen := int(binary.LittleEndian.Uint16(b[off:]))
|
|
||||||
off += 2
|
|
||||||
if len(b) < off+keyLen+4 {
|
|
||||||
return nil, fmt.Errorf("truncated key (sig %d)", s)
|
|
||||||
}
|
|
||||||
key := string(b[off : off+keyLen])
|
|
||||||
off += keyLen
|
|
||||||
n := int(binary.LittleEndian.Uint32(b[off:]))
|
|
||||||
off += 4
|
|
||||||
if len(b) < off+16*n {
|
|
||||||
return nil, fmt.Errorf("truncated data (sig %s n=%d)", key, n)
|
|
||||||
}
|
|
||||||
pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)}
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
||||||
}
|
|
||||||
off += 8 * n
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
||||||
}
|
|
||||||
off += 8 * n
|
|
||||||
f.signals[key] = pts
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCapture(b []byte) (*captureFrame, error) {
|
|
||||||
if len(b) < 1+24+4 || b[0] != 2 {
|
|
||||||
return nil, fmt.Errorf("not a v2 frame")
|
|
||||||
}
|
|
||||||
rdF64 := func(off int) float64 {
|
|
||||||
return math.Float64frombits(binary.LittleEndian.Uint64(b[off:]))
|
|
||||||
}
|
|
||||||
f := &captureFrame{
|
|
||||||
trigTime: rdF64(1), preSec: rdF64(9), postSec: rdF64(17),
|
|
||||||
signals: map[string]zoomPoints{},
|
|
||||||
}
|
|
||||||
off := 25
|
|
||||||
nSig := int(binary.LittleEndian.Uint32(b[off:]))
|
|
||||||
off += 4
|
|
||||||
for s := 0; s < nSig; s++ {
|
|
||||||
keyLen := int(binary.LittleEndian.Uint16(b[off:]))
|
|
||||||
off += 2
|
|
||||||
key := string(b[off : off+keyLen])
|
|
||||||
off += keyLen
|
|
||||||
n := int(binary.LittleEndian.Uint32(b[off:]))
|
|
||||||
off += 4
|
|
||||||
if len(b) < off+16*n {
|
|
||||||
return nil, fmt.Errorf("truncated capture (sig %s n=%d)", key, n)
|
|
||||||
}
|
|
||||||
pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)}
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
||||||
}
|
|
||||||
off += 8 * n
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
|
|
||||||
}
|
|
||||||
off += 8 * n
|
|
||||||
f.signals[key] = pts
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test driver
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
ws *websocket.Conn
|
|
||||||
deadline time.Time
|
|
||||||
|
|
||||||
sources []sourceInfo
|
|
||||||
configs map[string][]signalInfo // sourceId → signals
|
|
||||||
pushes []*pushFrame
|
|
||||||
stats map[string]statInfo
|
|
||||||
zooms map[uint32]map[string]zoomPoints
|
|
||||||
histZooms map[uint32]map[string]zoomPoints
|
|
||||||
historyInfo *historyInfoMsg
|
|
||||||
trigSt []string // observed triggerState sequence
|
|
||||||
captures []*captureFrame
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) send(v interface{}) {
|
|
||||||
b, _ := json.Marshal(v)
|
|
||||||
if err := c.ws.WriteMessage(websocket.TextMessage, b); err != nil {
|
|
||||||
fail("ws write: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pump reads one WS message (with a short read deadline) and dispatches it.
|
|
||||||
func (c *client) pump() {
|
|
||||||
c.ws.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
|
||||||
mt, data, err := c.ws.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
if websocket.IsUnexpectedCloseError(err) {
|
|
||||||
fail("ws closed: %v", err)
|
|
||||||
}
|
|
||||||
return // read timeout — fine
|
|
||||||
}
|
|
||||||
switch mt {
|
|
||||||
case websocket.BinaryMessage:
|
|
||||||
if len(data) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch data[0] {
|
|
||||||
case 1:
|
|
||||||
if f, err := parsePush(data); err == nil {
|
|
||||||
c.pushes = append(c.pushes, f)
|
|
||||||
} else {
|
|
||||||
fail("bad v1 frame: %v", err)
|
|
||||||
}
|
|
||||||
case 2:
|
|
||||||
if f, err := parseCapture(data); err == nil {
|
|
||||||
c.captures = append(c.captures, f)
|
|
||||||
} else {
|
|
||||||
fail("bad v2 frame: %v", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fail("unknown binary frame version %d", data[0])
|
|
||||||
}
|
|
||||||
case websocket.TextMessage:
|
|
||||||
var ev event
|
|
||||||
if err := json.Unmarshal(data, &ev); err != nil {
|
|
||||||
fail("bad JSON event: %v (%.120s)", err, data)
|
|
||||||
}
|
|
||||||
if *verbose {
|
|
||||||
log.Printf("event %-12s %.160s", ev.Type, data)
|
|
||||||
}
|
|
||||||
switch ev.Type {
|
|
||||||
case "sources":
|
|
||||||
var srcs []sourceInfo
|
|
||||||
if err := json.Unmarshal(ev.Sources, &srcs); err == nil {
|
|
||||||
c.sources = srcs
|
|
||||||
}
|
|
||||||
case "config":
|
|
||||||
var sigs []signalInfo
|
|
||||||
if err := json.Unmarshal(ev.Signals, &sigs); err == nil {
|
|
||||||
c.configs[ev.SourceID] = sigs
|
|
||||||
} else {
|
|
||||||
log.Printf("config parse error: %v (%.200s)", err, data)
|
|
||||||
}
|
|
||||||
case "stats":
|
|
||||||
var st map[string]statInfo
|
|
||||||
if err := json.Unmarshal(ev.Sources, &st); err == nil {
|
|
||||||
c.stats = st
|
|
||||||
}
|
|
||||||
case "zoom":
|
|
||||||
var body struct {
|
|
||||||
Signals map[string]zoomPoints `json:"signals"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &body); err == nil {
|
|
||||||
c.zooms[ev.ReqID] = body.Signals
|
|
||||||
}
|
|
||||||
case "historyZoom":
|
|
||||||
var body struct {
|
|
||||||
Signals map[string]zoomPoints `json:"signals"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &body); err == nil {
|
|
||||||
c.histZooms[ev.ReqID] = body.Signals
|
|
||||||
}
|
|
||||||
case "historyInfo":
|
|
||||||
var hi historyInfoMsg
|
|
||||||
if err := json.Unmarshal(data, &hi); err == nil {
|
|
||||||
c.historyInfo = &hi
|
|
||||||
}
|
|
||||||
case "triggerState":
|
|
||||||
c.trigSt = append(c.trigSt, ev.State)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitFor pumps messages until cond() or the step deadline expires.
|
|
||||||
func (c *client) waitFor(what string, d time.Duration, cond func() bool) {
|
|
||||||
end := time.Now().Add(d)
|
|
||||||
if end.After(c.deadline) {
|
|
||||||
end = c.deadline
|
|
||||||
}
|
|
||||||
for time.Now().Before(end) {
|
|
||||||
if cond() {
|
|
||||||
log.Printf("OK %s", what)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.pump()
|
|
||||||
}
|
|
||||||
fail("timeout waiting for %s", what)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fail(format string, args ...interface{}) {
|
|
||||||
fmt.Printf("FAIL "+format+"\n", args...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
flag.Parse()
|
|
||||||
url := "ws://" + *hub + "/ws"
|
|
||||||
log.Printf("connecting to %s", url)
|
|
||||||
|
|
||||||
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
|
||||||
if err != nil {
|
|
||||||
fail("dial %s: %v", url, err)
|
|
||||||
}
|
|
||||||
defer ws.Close()
|
|
||||||
|
|
||||||
c := &client{
|
|
||||||
ws: ws,
|
|
||||||
deadline: time.Now().Add(*timeout),
|
|
||||||
configs: map[string][]signalInfo{},
|
|
||||||
zooms: map[uint32]map[string]zoomPoints{},
|
|
||||||
histZooms: map[uint32]map[string]zoomPoints{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. sources ────────────────────────────────────────────────────────
|
|
||||||
c.send(map[string]interface{}{"type": "getSources"})
|
|
||||||
c.waitFor("sources event with a connected source", 10*time.Second, func() bool {
|
|
||||||
for _, s := range c.sources {
|
|
||||||
if s.State == "connected" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 2. config per connected source ───────────────────────────────────
|
|
||||||
for _, s := range c.sources {
|
|
||||||
log.Printf("source %s (%s): state=%s", s.ID, s.Label, s.State)
|
|
||||||
c.send(map[string]interface{}{"type": "getConfig", "sourceId": s.ID})
|
|
||||||
}
|
|
||||||
c.waitFor("config with signals for every connected source", 10*time.Second, func() bool {
|
|
||||||
for _, s := range c.sources {
|
|
||||||
if s.State != "connected" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(c.configs[s.ID]) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(c.sources) > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 3. binary pushes: wall-clock time base + monotonicity ────────────
|
|
||||||
c.waitFor("binary v1 data pushes (>=10 frames)", 10*time.Second, func() bool {
|
|
||||||
return len(c.pushes) >= 10
|
|
||||||
})
|
|
||||||
now := float64(time.Now().UnixNano()) / 1e9
|
|
||||||
seen := map[string][]float64{} // last times per src:sig
|
|
||||||
for _, f := range c.pushes {
|
|
||||||
for key, pts := range f.signals {
|
|
||||||
full := f.sourceID + ":" + key
|
|
||||||
for i, t := range pts.T {
|
|
||||||
if math.Abs(t-now) > 30.0 {
|
|
||||||
fail("timestamp not wall-clock: %s t=%.3f now=%.3f", full, t, now)
|
|
||||||
}
|
|
||||||
prev := seen[full]
|
|
||||||
if len(prev) > 0 && t < prev[len(prev)-1]-1e-9 {
|
|
||||||
fail("non-monotonic time on %s: %.9f after %.9f (i=%d)",
|
|
||||||
full, t, prev[len(prev)-1], i)
|
|
||||||
}
|
|
||||||
seen[full] = append(seen[full], t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(seen) == 0 {
|
|
||||||
fail("pushes contained no signal data")
|
|
||||||
}
|
|
||||||
log.Printf("OK wall-clock & monotonic time on %d signal streams", len(seen))
|
|
||||||
|
|
||||||
// ── 4. stats ──────────────────────────────────────────────────────────
|
|
||||||
c.send(map[string]interface{}{"type": "getStats"})
|
|
||||||
c.waitFor("stats with positive rate", 10*time.Second, func() bool {
|
|
||||||
for _, st := range c.stats {
|
|
||||||
if st.State == "connected" && st.RateHz > 0 && st.TotalReceived > 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 5. zoom round-trip ───────────────────────────────────────────────
|
|
||||||
// Use the busiest streamed signal and the time range we actually saw.
|
|
||||||
var zoomKey string
|
|
||||||
var zMax int
|
|
||||||
for k, ts := range seen {
|
|
||||||
if len(ts) > zMax {
|
|
||||||
zMax, zoomKey = len(ts), k
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ts := seen[zoomKey]
|
|
||||||
t1 := ts[len(ts)-1]
|
|
||||||
t0 := t1 - 0.5
|
|
||||||
const reqID = 4242
|
|
||||||
c.send(map[string]interface{}{
|
|
||||||
"type": "zoom", "reqId": reqID, "t0": t0, "t1": t1, "n": 200,
|
|
||||||
"signals": zoomKey,
|
|
||||||
})
|
|
||||||
c.waitFor(fmt.Sprintf("zoom reply (reqId=%d, %s)", reqID, zoomKey),
|
|
||||||
10*time.Second, func() bool {
|
|
||||||
sigs, ok := c.zooms[reqID]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
pts, ok := sigs[zoomKey]
|
|
||||||
if !ok || len(pts.T) < 2 {
|
|
||||||
fail("zoom reply missing %s (got %d signals)", zoomKey, len(sigs))
|
|
||||||
}
|
|
||||||
for _, t := range pts.T {
|
|
||||||
if t < t0-1e-6 || t > t1+1e-6 {
|
|
||||||
fail("zoom point outside range: t=%.9f not in [%.9f,%.9f]", t, t0, t1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 5b. historyInfo — check the hub broadcast it on connect ─────────
|
|
||||||
if c.historyInfo != nil && c.historyInfo.Enabled {
|
|
||||||
log.Printf("OK historyInfo: enabled, %.1fh, decimation=%d, %d signals",
|
|
||||||
c.historyInfo.DurationHours, c.historyInfo.Decimation,
|
|
||||||
len(c.historyInfo.Signals))
|
|
||||||
|
|
||||||
// ── 5c. historyZoom round-trip ──────────────────────────────────
|
|
||||||
const hReqID = 4243
|
|
||||||
c.send(map[string]interface{}{
|
|
||||||
"type": "historyZoom", "reqId": hReqID,
|
|
||||||
"t0": t0, "t1": t1, "n": 200,
|
|
||||||
"signals": zoomKey,
|
|
||||||
})
|
|
||||||
c.waitFor(fmt.Sprintf("historyZoom reply (reqId=%d, %s)", hReqID, zoomKey),
|
|
||||||
10*time.Second, func() bool {
|
|
||||||
sigs, ok := c.histZooms[hReqID]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
pts, ok := sigs[zoomKey]
|
|
||||||
if !ok || len(pts.T) < 1 {
|
|
||||||
// History data may still be sparse right after startup
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, ht := range pts.T {
|
|
||||||
if ht < t0-1e-6 || ht > t1+1e-6 {
|
|
||||||
fail("historyZoom point outside range: t=%.9f not in [%.9f,%.9f]", ht, t0, t1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
log.Println(" (history not enabled — skipping historyZoom test)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 6. trigger: arm → capture ────────────────────────────────────────
|
|
||||||
// Trigger on an *oscillating* signal at its mean observed value: a
|
|
||||||
// monotonic ramp (counter, time array) crosses its past mean only once,
|
|
||||||
// before arming, so a rising edge would never fire on it. Pick the
|
|
||||||
// busiest signal whose last push frame is non-monotonic (a sine).
|
|
||||||
lastVals := map[string][]float64{}
|
|
||||||
for _, f := range c.pushes {
|
|
||||||
for name, pts := range f.signals {
|
|
||||||
if len(pts.V) >= 4 {
|
|
||||||
lastVals[f.sourceID+":"+name] = pts.V
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trigKey := ""
|
|
||||||
tMaxPts := 0
|
|
||||||
for k, vs := range lastVals {
|
|
||||||
monotonic := true
|
|
||||||
for i := 1; i < len(vs); i++ {
|
|
||||||
if vs[i] < vs[i-1] {
|
|
||||||
monotonic = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !monotonic && len(seen[k]) > tMaxPts {
|
|
||||||
tMaxPts, trigKey = len(seen[k]), k
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if trigKey == "" {
|
|
||||||
fail("no oscillating signal found for trigger test")
|
|
||||||
}
|
|
||||||
vals := lastVals[trigKey]
|
|
||||||
mean := 0.0
|
|
||||||
for _, v := range vals {
|
|
||||||
mean += v
|
|
||||||
}
|
|
||||||
mean /= float64(len(vals))
|
|
||||||
log.Printf(" trigger signal %s, threshold %.6g", trigKey, mean)
|
|
||||||
|
|
||||||
c.send(map[string]interface{}{
|
|
||||||
"type": "setTrigger", "signal": trigKey, "edge": "rising",
|
|
||||||
"threshold": mean, "windowSec": 0.1, "prePercent": 20.0,
|
|
||||||
"mode": "single",
|
|
||||||
})
|
|
||||||
c.send(map[string]interface{}{"type": "arm"})
|
|
||||||
// The trigger can fire within microseconds of arming (5 MS/s sine), so
|
|
||||||
// the broadcast emitted by the arm command may already say "collecting"
|
|
||||||
// or even "triggered" — any of these proves the arm was accepted.
|
|
||||||
c.waitFor("triggerState: armed/collecting/triggered", 5*time.Second, func() bool {
|
|
||||||
for _, s := range c.trigSt {
|
|
||||||
if s == "armed" || s == "collecting" || s == "triggered" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
c.waitFor("binary v2 capture frame", 15*time.Second, func() bool {
|
|
||||||
return len(c.captures) > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
cap0 := c.captures[0]
|
|
||||||
if math.Abs(cap0.preSec-0.02) > 1e-9 || math.Abs(cap0.postSec-0.08) > 1e-9 {
|
|
||||||
fail("capture window mismatch: pre=%.6f post=%.6f (want 0.02/0.08)",
|
|
||||||
cap0.preSec, cap0.postSec)
|
|
||||||
}
|
|
||||||
pts, ok := cap0.signals[trigKey]
|
|
||||||
if !ok || len(pts.T) == 0 {
|
|
||||||
fail("capture missing trigger signal %s (%d signals)", trigKey, len(cap0.signals))
|
|
||||||
}
|
|
||||||
for _, t := range pts.T {
|
|
||||||
if t < cap0.trigTime-cap0.preSec-1e-3 || t > cap0.trigTime+cap0.postSec+1e-3 {
|
|
||||||
fail("capture point outside window: t=%.9f trig=%.9f", t, cap0.trigTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("OK capture: trig=%.6f pre=%.3fs post=%.3fs %d signals",
|
|
||||||
cap0.trigTime, cap0.preSec, cap0.postSec, len(cap0.signals))
|
|
||||||
|
|
||||||
c.waitFor("triggerState: triggered", 5*time.Second, func() bool {
|
|
||||||
for _, s := range c.trigSt {
|
|
||||||
if s == "triggered" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
c.send(map[string]interface{}{"type": "disarm"})
|
|
||||||
|
|
||||||
fmt.Println("PASS streamhub-e2e: all checks passed")
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,145 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run_combined_test.sh — launch the combined integration test
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./run_combined_test.sh [OPTIONS]
|
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# -m <MARTe2_DIR> Override MARTe2 installation dir (default: $MARTe2_DIR)
|
|
||||||
# -c <MARTe2_Components_DIR> Override MARTe2-components dir
|
|
||||||
# -b <BUILD_TARGET> Build target (default: x86-linux)
|
|
||||||
# -s Skip building — run with whatever is already built
|
|
||||||
# -d Start debugger web UI (marte2debugger) automatically
|
|
||||||
# -h Show this help
|
|
||||||
#
|
|
||||||
# Ports used:
|
|
||||||
# 44500/tcp+udp UDPStreamer1 control + data (scalar signals)
|
|
||||||
# 44501/tcp UDPStreamer2 control (packed arrays FirstSample/LastSample)
|
|
||||||
# 44502/tcp UDPStreamer3 control (packed arrays FullArray)
|
|
||||||
# 44503/udp UDPStreamer1 multicast data (239.0.0.1)
|
|
||||||
# 8080/tcp DebugService control
|
|
||||||
# 8081/udp DebugService UDPS stream
|
|
||||||
# 8082/tcp DebugService log
|
|
||||||
# 9090/tcp TCPLogger
|
|
||||||
# 7777/tcp Debugger web UI (if -d is given)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
CFG="${SCRIPT_DIR}/Test/Configurations/combined_test.cfg"
|
|
||||||
|
|
||||||
BUILD_TARGET="${TARGET:-x86-linux}"
|
|
||||||
SKIP_BUILD=0
|
|
||||||
START_UI=0
|
|
||||||
|
|
||||||
# ── Parse arguments ───────────────────────────────────────────────────────────
|
|
||||||
while getopts "m:c:b:sdh" opt; do
|
|
||||||
case "$opt" in
|
|
||||||
m) MARTe2_DIR="$OPTARG" ;;
|
|
||||||
c) MARTe2_Components_DIR="$OPTARG" ;;
|
|
||||||
b) BUILD_TARGET="$OPTARG" ;;
|
|
||||||
s) SKIP_BUILD=1 ;;
|
|
||||||
d) START_UI=1 ;;
|
|
||||||
h)
|
|
||||||
sed -n '2,40p' "$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
|
|
||||||
|
|
||||||
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTeApp.ex"
|
|
||||||
if [[ ! -x "$MARTE2_BIN" ]]; then
|
|
||||||
# Some builds use MARTe2.sh as the launcher wrapper
|
|
||||||
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 components (TARGET=${BUILD_TARGET})..."
|
|
||||||
make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" 2>&1 | tail -20
|
|
||||||
echo "==> Build done."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Library path ─────────────────────────────────────────────────────────────
|
|
||||||
BUILD_DIR="${SCRIPT_DIR}/Build/${BUILD_TARGET}"
|
|
||||||
|
|
||||||
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/DataSources/LoggerDataSource:\
|
|
||||||
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/GAMs/IOGAM:\
|
|
||||||
${MARTe2_Components_DIR}/Build/${BUILD_TARGET}/Components/GAMs/WaveformGAM:\
|
|
||||||
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
|
|
||||||
${BUILD_DIR}/Components/GAMs/SineArrayGAM:\
|
|
||||||
${BUILD_DIR}/Components/GAMs/TimeArrayGAM:\
|
|
||||||
${BUILD_DIR}/Components/Interfaces/DebugService:\
|
|
||||||
${BUILD_DIR}/Components/Interfaces/TCPLogger:\
|
|
||||||
${LD_LIBRARY_PATH:-}"
|
|
||||||
|
|
||||||
# ── Loader parameters ─────────────────────────────────────────────────────────
|
|
||||||
LOADER_PARAMS=(
|
|
||||||
-l RealTimeLoader
|
|
||||||
-f "${CFG}"
|
|
||||||
-s Running
|
|
||||||
-m StateMachine:START
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Optionally start the web UI ───────────────────────────────────────────────
|
|
||||||
UI_PID=""
|
|
||||||
if [[ "$START_UI" -eq 1 ]]; then
|
|
||||||
UI_BIN="${SCRIPT_DIR}/Client/debugger/marte2debugger"
|
|
||||||
echo "==> Building debugger web UI..."
|
|
||||||
(cd "${SCRIPT_DIR}/Client/debugger" && go build -o marte2debugger .)
|
|
||||||
# Check if port 7777 is already in use
|
|
||||||
if ss -tlnH sport = :7777 2>/dev/null | grep -q 7777; then
|
|
||||||
echo "==> Debugger web UI already running on http://localhost:7777"
|
|
||||||
else
|
|
||||||
echo "==> Starting debugger web UI on http://localhost:7777 ..."
|
|
||||||
"${UI_BIN}" -addr :7777 &
|
|
||||||
UI_PID="$!"
|
|
||||||
sleep 0.5
|
|
||||||
fi
|
|
||||||
echo " Open http://localhost:7777 in your browser."
|
|
||||||
echo " In the connection panel set:"
|
|
||||||
echo " Host=127.0.0.1 TCP=8080 UDP=8081 Log=9090"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Cleanup handler ───────────────────────────────────────────────────────────
|
|
||||||
cleanup() {
|
|
||||||
echo ""
|
|
||||||
echo "==> Shutting down..."
|
|
||||||
if [[ -n "$UI_PID" ]] && kill -0 "$UI_PID" 2>/dev/null; then
|
|
||||||
kill "$UI_PID"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
# ── Launch MARTe2 ─────────────────────────────────────────────────────────────
|
|
||||||
echo "==> Launching MARTe2..."
|
|
||||||
echo " Config : ${CFG}"
|
|
||||||
echo " State : Running"
|
|
||||||
echo " Streams: UDPStreamer1=44500 UDPStreamer2=44501 UDPStreamer3=44502"
|
|
||||||
echo " Debug : TCP=8080 UDP=8081 Log=9090 (TcpLogger auto-injected)"
|
|
||||||
echo ""
|
|
||||||
echo " Press Ctrl-C to stop."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
exec "${MARTE2_BIN}" "${LOADER_PARAMS[@]}"
|
|
||||||
-162
@@ -1,162 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# run_e2e_test.sh — End-to-end test of the streaming pipeline:
|
|
||||||
# MARTe2 app (UDPStreamer) → StreamHub (C++) → WebSocket client (Go)
|
|
||||||
#
|
|
||||||
# Launches the full stack headless, then runs Test/E2E/streamhub which
|
|
||||||
# verifies sources/config events, binary data pushes (wall-clock time base,
|
|
||||||
# per-signal monotonicity), stats, WS zoom round-trips and a hub-side
|
|
||||||
# trigger capture. Exits 0 iff every check passes.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./run_e2e_test.sh [OPTIONS]
|
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# -m <MARTe2_DIR> Override MARTe2 installation dir
|
|
||||||
# -c <MARTe2_Components_DIR> Override MARTe2-components dir
|
|
||||||
# -b <BUILD_TARGET> Build target (default: x86-linux)
|
|
||||||
# -p <WS_PORT> StreamHub WebSocket port (default: 8095)
|
|
||||||
# -s Skip building
|
|
||||||
# -h Show this help
|
|
||||||
|
|
||||||
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=8095
|
|
||||||
SKIP_BUILD=0
|
|
||||||
|
|
||||||
while getopts "m:c:b:p:sh" opt; do
|
|
||||||
case "$opt" in
|
|
||||||
m) MARTe2_DIR="$OPTARG" ;;
|
|
||||||
c) MARTe2_Components_DIR="$OPTARG" ;;
|
|
||||||
b) BUILD_TARGET="$OPTARG" ;;
|
|
||||||
p) WS_PORT="$OPTARG" ;;
|
|
||||||
s) SKIP_BUILD=1 ;;
|
|
||||||
h)
|
|
||||||
sed -n '2,19p' "$0" | grep '^#' | sed 's/^# \?//'
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -z "${MARTe2_DIR:-}" || -z "${MARTe2_Components_DIR:-}" ]]; then
|
|
||||||
echo "ERROR: MARTe2_DIR / MARTe2_Components_DIR not set. Source env.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BUILD_DIR="${SCRIPT_DIR}/Build/${BUILD_TARGET}"
|
|
||||||
STREAMHUB_EX="${BUILD_DIR}/StreamHub/StreamHub.ex"
|
|
||||||
E2E_DIR="${SCRIPT_DIR}/Test/E2E/streamhub"
|
|
||||||
E2E_BIN="${E2E_DIR}/streamhub-e2e"
|
|
||||||
|
|
||||||
MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTeApp.ex"
|
|
||||||
[[ -x "$MARTE2_BIN" ]] || MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTe2.sh"
|
|
||||||
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 + StreamHub..."
|
|
||||||
make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" core apps 2>&1 | tail -5
|
|
||||||
echo "==> Building E2E client..."
|
|
||||||
(cd "${E2E_DIR}" && go build -o streamhub-e2e .)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -x "$STREAMHUB_EX" ]]; then
|
|
||||||
echo "ERROR: StreamHub binary not found: ${STREAMHUB_EX}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── StreamHub config ──────────────────────────────────────────────────────────
|
|
||||||
HUB_CFG="$(mktemp /tmp/streamhub_e2e_XXXXXX.cfg)"
|
|
||||||
cat > "$HUB_CFG" <<EOF
|
|
||||||
Hub = {
|
|
||||||
WSPort = ${WS_PORT}
|
|
||||||
MaxPoints = 20000
|
|
||||||
PushRate = 30
|
|
||||||
RingTemporal = 1000000
|
|
||||||
RingScalar = 100000
|
|
||||||
+History = {
|
|
||||||
Directory = "/tmp/streamhub_e2e_history"
|
|
||||||
DurationHours = 0.1
|
|
||||||
Decimation = 10
|
|
||||||
FlushIntervalSec = 2
|
|
||||||
MinDiskFreeMB = 100
|
|
||||||
}
|
|
||||||
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
|
|
||||||
|
|
||||||
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:-}"
|
|
||||||
|
|
||||||
MARTE_PID=""
|
|
||||||
HUB_PID=""
|
|
||||||
RESULT=1
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
[[ -n "$HUB_PID" ]] && kill "$HUB_PID" 2>/dev/null || true
|
|
||||||
[[ -n "$MARTE_PID" ]] && kill "$MARTE_PID" 2>/dev/null || true
|
|
||||||
wait "$HUB_PID" 2>/dev/null || true
|
|
||||||
wait "$MARTE_PID" 2>/dev/null || true
|
|
||||||
rm -f "$HUB_CFG"
|
|
||||||
if [[ "$RESULT" -eq 0 ]]; then
|
|
||||||
echo "==> E2E: PASS"
|
|
||||||
else
|
|
||||||
echo "==> E2E: FAIL"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
echo "==> Launching MARTe2 (headless)..."
|
|
||||||
"${MARTE2_BIN}" \
|
|
||||||
-l RealTimeLoader \
|
|
||||||
-f "${MARTe_CFG}" \
|
|
||||||
-s Running \
|
|
||||||
-m StateMachine:START > /tmp/streamhub_e2e_marte.log 2>&1 &
|
|
||||||
MARTE_PID="$!"
|
|
||||||
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
echo "==> Launching StreamHub (ws port ${WS_PORT})..."
|
|
||||||
"${STREAMHUB_EX}" -cfg "${HUB_CFG}" > /tmp/streamhub_e2e_hub.log 2>&1 &
|
|
||||||
HUB_PID="$!"
|
|
||||||
|
|
||||||
# Give the hub time to connect the UDPS sources and fill some history
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
echo "==> Running E2E client..."
|
|
||||||
if "${E2E_BIN}" -hub "127.0.0.1:${WS_PORT}" -timeout 60s; then
|
|
||||||
RESULT=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$RESULT"
|
|
||||||
Reference in New Issue
Block a user