From cf815e1d3f687105d0f6025fec4e043f8fcafb97 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 27 Aug 2026 16:35:41 +0200 Subject: [PATCH] docs: implementation plan for the UDPScope direct-UDPS oscilloscope Eighteen TDD tasks covering the build scaffold, pane tree, time base, frame decoding, trigger FSM, threading, UI, persistence and export, so the scope can be built task-by-task with a reviewable deliverable at each step. Co-Authored-By: Claude Opus 4.6 --- docs/superpowers/plans/2026-08-27-udpscope.md | 10314 ++++++++++++++++ 1 file changed, 10314 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-27-udpscope.md diff --git a/docs/superpowers/plans/2026-08-27-udpscope.md b/docs/superpowers/plans/2026-08-27-udpscope.md new file mode 100644 index 0000000..b843ee7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-udpscope.md @@ -0,0 +1,10314 @@ +# UDPScope Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `Client/udpscope`, an ImGui oscilloscope that attaches directly to one UDPStreamer through the standalone C client, with a splittable multi-pane view and a local trigger. + +**Architecture:** Two threads. A receiver thread runs `udps_client_poll()` forever, reconstructs per-sample timestamps, appends to per-signal ring buffers and runs the trigger edge detector. The GUI thread runs SDL2 + ImGui + ImPlot, reads windows out of the rings each frame, decimates them with a min/max envelope and draws. `SignalStore` is the single shared object, guarded by one mutex. + +**Tech Stack:** C++17, SDL2, OpenGL 3.3, Dear ImGui v1.91.8, ImPlot v0.17, GoogleTest, CMake ≥ 3.16. The transport is `Common/Client/c/udps_client.c` (C99, compiled into the build). + +**Spec:** `docs/superpowers/specs/2026-08-27-udpscope-direct-udps-imgui-scope-design.md` + +## Global Constraints + +- Language: C++17 for the app, C99 for the vendored `udps_client.c`. +- Namespace: everything in `namespace udpscope`. +- Warnings: app compiled `-Wall -Wextra -Wno-unused-parameter`; vendored ImGui/ImPlot compiled `-w`. +- **Nothing under `Client/streamhub/` may be modified.** `SignalBuffer.h` and `resources/` are consumed read-only. +- `PaneTree`, `TimeBase`, `FrameDecoder`, `Trigger`, `Measure`, `Decimate`, `Settings`, `Export` must not include any ImGui, SDL or UDPS header. They are unit-tested without a window or a socket. +- `SignalBuffer.h`'s comment claims it is thread-safe. It is not — it has no locks. All locking lives in `SignalStore`. +- Ring margin constant is `kRingMargin = 4.0` and must carry the comment explaining why (a capture harvested after its last sample needs the ring to reach further back than the window itself). +- Decimation is min/max envelope, never LTTB. +- Signals are identified by **name** everywhere in the UI and in settings, never by index. +- This is not a MARTe2 component: STL and C++17 are fine. The "no STL" rule applies only to `Source/Components/`. +- EUPL v1.1 headers are not required here (matching `Client/streamhub`, which has none). + +## File Structure + +| File | Responsibility | +|---|---| +| `Client/udpscope/CMakeLists.txt` | Build: SDL2, OpenGL, FetchContent ImGui/ImPlot/GoogleTest, `udpsclient` C target | +| `Types.h` | `Series`, `Color`, `Rect`, `SignalMeta`, `FrameView` — shared plain data, no logic | +| `Decimate.{h,cpp}` | Min/max envelope decimation | +| `PaneTree.{h,cpp}` | BSP split tree: split, close, layout, hit-test | +| `TimeBase.{h,cpp}` | Producer-clock → wall-clock offset; `hrt` tick-rate fit | +| `FrameDecoder.{h,cpp}` | Per-element timestamp reconstruction (spec §5) | +| `Trigger.{h,cpp}` | Edge detector and capture FSM | +| `SignalStore.{h,cpp}` | Rings, ring sizing, signal table lifecycle, the one mutex | +| `Receiver.{h,cpp}` | The thread, the `udps_client_t`, the three C callbacks | +| `Measure.{h,cpp}` | Window statistics and cursor readouts | +| `Axes.{h,cpp}` | Shared X-axis controller; per-trace division scaling | +| `CaptureLatch.{h,cpp}` | Which capture the panes draw; trigger status badge text | +| `PlotData.{h,cpp}` | Fetching a trace out of a ring or a capture, ready to draw | +| `Cli.{h,cpp}` | Command-line parsing, usage text, default config path | +| `Settings.{h,cpp}` | Session file writer and recursive-descent parser | +| `Export.{h,cpp}` | Long-format CSV writer | +| `App.{h,cpp}` | Owns everything; per-frame `update()` | +| `main.cpp` | SDL/ImGui/ImPlot bootstrap, style, fonts, event loop | +| `SignalList.cpp` | Left panel, drag source, profile toggle | +| `PaneView.cpp` | ImPlot rendering of one pane, split handles | +| `TriggerBar.cpp` | Trigger controls and status badge | +| `tests/*.cpp` | GoogleTest suites for the framework-free modules | + +--- + +### Task 1: Build scaffold and min/max decimation + +Establishes the CMake build (including the test binary) and lands the first framework-free module through it, so the scaffold is proven by something real rather than by an empty target. + +**Files:** +- Create: `Client/udpscope/CMakeLists.txt` +- Create: `Client/udpscope/Types.h` +- Create: `Client/udpscope/Decimate.h` +- Create: `Client/udpscope/Decimate.cpp` +- Create: `Client/udpscope/tests/DecimateTest.cpp` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `udpscope::Series { std::vector t, v; }` + - `udpscope::Color { float r, g, b, a; }` + - `udpscope::Rect { double x, y, w, h; }` + - `void udpscope::MinMaxDecimate(const double* t, const double* v, size_t n, size_t maxPoints, Series& out)` + - CMake targets `udpsclient`, `imgui_lib`, `UDPScope`, `udpscope_tests`. + +- [ ] **Step 1: Write the failing test** + +Create `Client/udpscope/tests/DecimateTest.cpp`: + +```cpp +#include "Decimate.h" + +#include + +#include + +using namespace udpscope; + +TEST(MinMaxDecimate, PassesShortInputThroughUnchanged) { + const std::vector t{0.0, 1.0, 2.0}; + const std::vector v{5.0, 6.0, 7.0}; + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 100, out); + + EXPECT_EQ(out.t, t); + EXPECT_EQ(out.v, v); +} + +// The whole reason for preferring min/max over LTTB: a single-sample spike is +// usually the thing the user is looking for, and it must survive decimation. +TEST(MinMaxDecimate, PreservesAnIsolatedSpike) { + std::vector t(1000), v(1000, 0.0); + for (size_t i = 0; i < t.size(); i++) { t[i] = static_cast(i); } + v[437] = 42.0; + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 50, out); + + ASSERT_FALSE(out.v.empty()); + EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 42.0); +} + +TEST(MinMaxDecimate, PreservesTheExtremesOfEveryBucket) { + std::vector t(100), v(100); + for (size_t i = 0; i < t.size(); i++) { + t[i] = static_cast(i); + v[i] = (i % 10 == 3) ? -9.0 : ((i % 10 == 7) ? 9.0 : 0.0); + } + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 20, out); + + EXPECT_EQ(*std::min_element(out.v.begin(), out.v.end()), -9.0); + EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 9.0); +} + +// A ring whose timestamps are not monotonic breaks any later binary search by +// time, so the pair emitted per bucket must be ordered by time, not by value. +TEST(MinMaxDecimate, EmitsPointsInTimeOrder) { + std::vector t(400), v(400); + for (size_t i = 0; i < t.size(); i++) { + t[i] = static_cast(i); + v[i] = (i % 2 == 0) ? -static_cast(i) : static_cast(i); + } + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 40, out); + + ASSERT_GT(out.t.size(), 1u); + for (size_t i = 1; i < out.t.size(); i++) { + EXPECT_LE(out.t[i - 1], out.t[i]) << "at index " << i; + } +} + +TEST(MinMaxDecimate, HandlesEmptyInput) { + Series out; + out.t.push_back(1.0); // must be cleared + MinMaxDecimate(nullptr, nullptr, 0, 10, out); + EXPECT_TRUE(out.t.empty()); + EXPECT_TRUE(out.v.empty()); +} +``` + +- [ ] **Step 2: Create the shared plain-data header** + +Create `Client/udpscope/Types.h`: + +```cpp +/** + * @file Types.h + * @brief Plain data shared across UDPScope modules. No logic, no dependencies. + */ +#pragma once + +#include +#include +#include +#include + +namespace udpscope { + +/** A time series as two parallel arrays, which is what ImPlot wants. */ +struct Series { + std::vector t; + std::vector v; + + void clear() { t.clear(); v.clear(); } + size_t size() const { return t.size(); } + bool empty() const { return t.empty(); } +}; + +/** RGBA in 0..1. Framework-free so PaneTree needs no ImGui. */ +struct Color { + float r = 1.f, g = 1.f, b = 1.f, a = 1.f; +}; + +/** Screen rectangle in pixels. */ +struct Rect { + double x = 0.0, y = 0.0, w = 0.0, h = 0.0; + + bool contains(double px, double py) const { + return px >= x && px < (x + w) && py >= y && py < (y + h); + } +}; + +} /* namespace udpscope */ +``` + +- [ ] **Step 3: Write the decimation header** + +Create `Client/udpscope/Decimate.h`: + +```cpp +/** + * @file Decimate.h + * @brief Min/max envelope decimation for screen rendering. + */ +#pragma once + +#include "Types.h" + +namespace udpscope { + +/** + * @brief Reduce n points to at most maxPoints by emitting each bucket's + * minimum and maximum, in time order. + * + * LTTB is deliberately not used. It selects representative points and will + * silently drop a one-sample glitch; on a scope that glitch is usually the + * thing being looked for. The emitted pair stays in time order rather than + * value order because callers binary-search the result by time. + * + * Input shorter than maxPoints is copied through unchanged. + */ +void MinMaxDecimate(const double* t, const double* v, size_t n, + size_t maxPoints, Series& out); + +} /* namespace udpscope */ +``` + +- [ ] **Step 4: Write the minimal implementation** + +Create `Client/udpscope/Decimate.cpp`: + +```cpp +#include "Decimate.h" + +#include + +namespace udpscope { + +void MinMaxDecimate(const double* t, const double* v, size_t n, + size_t maxPoints, Series& out) { + out.clear(); + if (n == 0 || t == nullptr || v == nullptr) { + return; + } + if (n <= maxPoints || maxPoints < 4) { + out.t.assign(t, t + n); + out.v.assign(v, v + n); + return; + } + + /* Two points per bucket, so the bucket count is half the budget. */ + const size_t buckets = maxPoints / 2; + out.t.reserve(buckets * 2); + out.v.reserve(buckets * 2); + + for (size_t b = 0; b < buckets; b++) { + const size_t begin = (n * b) / buckets; + size_t end = (n * (b + 1)) / buckets; + if (end <= begin) { end = begin + 1; } + if (end > n) { end = n; } + + size_t lo = begin, hi = begin; + for (size_t i = begin + 1; i < end; i++) { + if (v[i] < v[lo]) { lo = i; } + if (v[i] > v[hi]) { hi = i; } + } + + const size_t first = std::min(lo, hi); + const size_t second = std::max(lo, hi); + out.t.push_back(t[first]); + out.v.push_back(v[first]); + if (second != first) { + out.t.push_back(t[second]); + out.v.push_back(v[second]); + } + } +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 5: Write the CMake build** + +Create `Client/udpscope/CMakeLists.txt`: + +```cmake +cmake_minimum_required(VERSION 3.16) +project(UDPScope CXX C) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_C_STANDARD 99) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +option(UDPSCOPE_BUILD_TESTS "Build the unit tests" ON) + +set(STREAMHUB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../streamhub) +set(CCLIENT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../Common/Client/c) + +# ── The standalone C UDPS client, compiled in directly ──────────────────────── +# Building it here rather than shelling out to its own Makefile keeps this a +# single cmake --build away from a working binary. +add_library(udpsclient STATIC ${CCLIENT_DIR}/udps_client.c) +target_include_directories(udpsclient PUBLIC ${CCLIENT_DIR}) +target_compile_options(udpsclient PRIVATE -Wall -Wextra -Wpedantic) + +# ── System packages ─────────────────────────────────────────────────────────── +find_package(OpenGL REQUIRED) + +find_package(SDL2 QUIET CONFIG) +if(NOT SDL2_FOUND) + find_package(PkgConfig REQUIRED) + pkg_check_modules(SDL2 REQUIRED sdl2) + add_library(SDL2::SDL2 INTERFACE IMPORTED) + target_include_directories(SDL2::SDL2 INTERFACE ${SDL2_INCLUDE_DIRS}) + target_link_libraries(SDL2::SDL2 INTERFACE ${SDL2_LIBRARIES}) + target_compile_options(SDL2::SDL2 INTERFACE ${SDL2_CFLAGS_OTHER}) +endif() + +# ── Dear ImGui + ImPlot ─────────────────────────────────────────────────────── +include(FetchContent) + +FetchContent_Declare(imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.91.8 + GIT_SHALLOW TRUE) +FetchContent_MakeAvailable(imgui) + +FetchContent_Declare(implot + GIT_REPOSITORY https://github.com/epezent/implot.git + GIT_TAG v0.17 + GIT_SHALLOW TRUE) +FetchContent_MakeAvailable(implot) + +add_library(imgui_lib STATIC + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl2.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp + ${implot_SOURCE_DIR}/implot.cpp + ${implot_SOURCE_DIR}/implot_items.cpp) +target_include_directories(imgui_lib PUBLIC + ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends ${implot_SOURCE_DIR}) +target_link_libraries(imgui_lib PUBLIC SDL2::SDL2 OpenGL::GL) +target_compile_options(imgui_lib PRIVATE -w) + +# ── Bundled resources, borrowed read-only from the StreamHub client ─────────── +set(RESOURCE_DIR ${STREAMHUB_DIR}/resources) +set(FONT_DIR ${RESOURCE_DIR}/fonts) + +if(EXISTS ${FONT_DIR}/fa-solid-900.ttf AND EXISTS ${FONT_DIR}/IconsFontAwesome6.h) + set(HAVE_FONT_AWESOME TRUE) + message(STATUS "Font Awesome icons enabled (${FONT_DIR})") +else() + set(HAVE_FONT_AWESOME FALSE) + message(WARNING "Bundled Font Awesome missing — using ASCII icon fallbacks") +endif() + +file(COPY ${FONT_DIR} DESTINATION ${CMAKE_BINARY_DIR}/resources) + +# ── Core library: everything except main.cpp, so tests can link it ──────────── +set(CORE_SOURCES + Decimate.cpp +) + +add_library(udpscope_core STATIC ${CORE_SOURCES}) +target_include_directories(udpscope_core PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${STREAMHUB_DIR}) # SignalBuffer.h, reused verbatim +target_link_libraries(udpscope_core PUBLIC udpsclient pthread) +target_compile_options(udpscope_core PRIVATE -Wall -Wextra -Wno-unused-parameter) + +# ── Application ─────────────────────────────────────────────────────────────── +set(APP_SOURCES + main.cpp +) + +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp) + add_executable(UDPScope ${APP_SOURCES}) + target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL) + target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}") + if(HAVE_FONT_AWESOME) + target_include_directories(UDPScope PRIVATE ${FONT_DIR}) + target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME) + endif() + target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter) + + install(TARGETS UDPScope DESTINATION bin) + install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope) +endif() + +# ── Tests ───────────────────────────────────────────────────────────────────── +if(UDPSCOPE_BUILD_TESTS) + FetchContent_Declare(googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.15.2 + GIT_SHALLOW TRUE) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) + + file(GLOB TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/*.cpp) + add_executable(udpscope_tests ${TEST_SOURCES}) + target_link_libraries(udpscope_tests PRIVATE udpscope_core GTest::gtest_main) + target_compile_options(udpscope_tests PRIVATE -Wall -Wextra -Wno-unused-parameter) + + enable_testing() + include(GoogleTest) + gtest_discover_tests(udpscope_tests) +endif() +``` + +Note the `if(EXISTS main.cpp)` guard: it lets Tasks 1–8 build and test with no GUI code present at all, and disappears in Task 9 when `main.cpp` lands. + +- [ ] **Step 6: Configure, then run the tests and watch them fail** + +```bash +cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j +``` + +The first configure downloads ImGui, ImPlot and GoogleTest, so it needs network access and takes a minute. + +Then: + +```bash +./build/udpscope_tests +``` + +Expected on a first run *before* `Decimate.cpp` is written: a link error for `MinMaxDecimate`. Since Steps 3–4 already wrote it, the expected result here is **PASS, 5 tests**. If you want to see the red first, comment out `Decimate.cpp` in `CORE_SOURCES`, rebuild, observe the undefined-reference failure, then restore it. + +- [ ] **Step 7: Add the build directory to git ignore** + +Create `Client/udpscope/.gitignore`: + +``` +build/ +compile_commands.json +``` + +- [ ] **Step 8: Commit** + +```bash +git add Client/udpscope/CMakeLists.txt Client/udpscope/.gitignore \ + Client/udpscope/Types.h Client/udpscope/Decimate.h \ + Client/udpscope/Decimate.cpp Client/udpscope/tests/DecimateTest.cpp +git commit -m "feat(udpscope): build scaffold and min/max envelope decimation" +``` + +--- + +### Task 2: Pane tree + +The BSP layout that the whole UI hangs off. Framework-free, so all the fiddly geometry is settled before a single ImGui call exists. + +**Files:** +- Create: `Client/udpscope/PaneTree.h` +- Create: `Client/udpscope/PaneTree.cpp` +- Create: `Client/udpscope/tests/PaneTreeTest.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` (add `PaneTree.cpp` to `CORE_SOURCES`) + +**Interfaces:** +- Consumes: `udpscope::Rect`, `udpscope::Color` from `Types.h` (Task 1). +- Produces: + - `enum class Orient { Columns, Rows }` + - `enum class VMode { Auto, Range, Manual }` + - `struct VScale { VMode mode; double div; double offset; }` + - `struct Assignment { std::string signalName; Color color; float lineWidth; VScale vs; }` + - `struct PaneNode { bool leaf; std::vector signals; Orient orient; double ratio; std::unique_ptr a, b; }` + - `class PaneTree` with `root()`, `layout()`, `splitLeaf()`, `closeLeaf()`, `leafCount()`, `hitTestSplitter()`, `hitTestHandle()` + - `struct PaneTree::Placed { PaneNode* leaf; Rect rect; }` + - `struct PaneTree::Splitter { PaneNode* node; Rect rect; Orient orient; }` + - `enum class Handle { None, Left, Right, Top, Bottom, Close }` + - `constexpr double kMinPaneSize = 80.0;` + +- [ ] **Step 1: Write the failing test** + +Create `Client/udpscope/tests/PaneTreeTest.cpp`: + +```cpp +#include "PaneTree.h" + +#include + +using namespace udpscope; + +namespace { + +const Rect kScreen{0.0, 0.0, 1000.0, 600.0}; + +std::vector leavesOf(const PaneTree& tree, const Rect& area) { + std::vector leaves; + std::vector splitters; + tree.layout(area, leaves, splitters); + return leaves; +} + +} /* namespace */ + +TEST(PaneTree, StartsAsOneEmptyLeafFillingTheArea) { + PaneTree tree; + EXPECT_EQ(tree.leafCount(), 1u); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 1u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0); + EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0); + EXPECT_TRUE(leaves[0].leaf->signals.empty()); +} + +TEST(PaneTree, SplittingIntoColumnsHalvesTheWidth) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 500.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.w, 500.0); + EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.x, 500.0); +} + +TEST(PaneTree, SplittingIntoRowsHalvesTheHeight) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Rows); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + EXPECT_DOUBLE_EQ(leaves[0].rect.h, 300.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.y, 300.0); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0); +} + +// The pane being split keeps its content; the new pane is the empty one. +TEST(PaneTree, SplitKeepsTheOriginalContentInTheFirstChild) { + PaneTree tree; + tree.root()->signals.push_back(Assignment{"Voltage", Color{}, 1.5f, VScale{}}); + tree.splitLeaf(tree.root(), Orient::Columns); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + ASSERT_EQ(leaves[0].leaf->signals.size(), 1u); + EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Voltage"); + EXPECT_TRUE(leaves[1].leaf->signals.empty()); +} + +TEST(PaneTree, ClosingALeafGivesItsSpaceToTheSibling) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + leaves[1].leaf->signals.push_back(Assignment{"Keep", Color{}, 1.5f, VScale{}}); + + tree.closeLeaf(leaves[0].leaf); + + EXPECT_EQ(tree.leafCount(), 1u); + leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 1u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0); + ASSERT_EQ(leaves[0].leaf->signals.size(), 1u); + EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Keep"); +} + +TEST(PaneTree, RefusesToCloseTheLastLeaf) { + PaneTree tree; + tree.closeLeaf(tree.root()); + EXPECT_EQ(tree.leafCount(), 1u); +} + +// A pane in the middle of a 3x3 touches no window edge. It must still be +// splittable, which is why handles are inset inside the pane rather than +// keyed on the window border. +TEST(PaneTree, AnInteriorPaneIsStillSplittable) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Rows); // top / bottom + auto leaves = leavesOf(tree, kScreen); + tree.splitLeaf(leaves[1].leaf, Orient::Rows); // 3 rows + leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 3u); + + PaneNode* middle = leaves[1].leaf; + tree.splitLeaf(middle, Orient::Columns); + leaves = leavesOf(tree, kScreen); + tree.splitLeaf(leaves[2].leaf, Orient::Columns); + + EXPECT_EQ(tree.leafCount(), 5u); +} + +TEST(PaneTree, LayoutReportsOneSplitterPerSplitNode) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + auto leaves = leavesOf(tree, kScreen); + tree.splitLeaf(leaves[0].leaf, Orient::Rows); + + std::vector out; + std::vector splitters; + tree.layout(kScreen, out, splitters); + + EXPECT_EQ(out.size(), 3u); + EXPECT_EQ(splitters.size(), 2u); +} + +TEST(PaneTree, RatioSurvivesALayoutRoundTrip) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + tree.setRatio(tree.root(), 0.25); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 250.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.w, 750.0); +} + +TEST(PaneTree, RatioIsClampedSoNeitherPaneGoesBelowTheMinimum) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + tree.setRatio(tree.root(), 0.001); + + const auto leaves = leavesOf(tree, kScreen); + EXPECT_GE(leaves[0].rect.w, kMinPaneSize); + EXPECT_GE(leaves[1].rect.w, kMinPaneSize); +} + +TEST(PaneTree, HitTestFindsTheSplitterBetweenTwoPanes) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + + std::vector leaves; + std::vector splitters; + tree.layout(kScreen, leaves, splitters); + ASSERT_EQ(splitters.size(), 1u); + + const PaneTree::Splitter* hit = tree.hitTestSplitter(splitters, 500.0, 300.0); + ASSERT_NE(hit, nullptr); + EXPECT_EQ(hit->orient, Orient::Columns); + + EXPECT_EQ(tree.hitTestSplitter(splitters, 100.0, 300.0), nullptr); +} + +TEST(PaneTree, HitTestFindsInsetSplitHandlesAndTheCloseButton) { + const Rect pane{0.0, 0.0, 400.0, 300.0}; + + EXPECT_EQ(PaneTree::hitTestHandle(pane, 8.0, 150.0), Handle::Left); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 150.0), Handle::Right); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 8.0), Handle::Top); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 292.0), Handle::Bottom); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 8.0), Handle::Close); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 150.0), Handle::None); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd Client/udpscope && cmake --build build -j +``` + +Expected: FAIL — `PaneTree.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +Create `Client/udpscope/PaneTree.h`: + +```cpp +/** + * @file PaneTree.h + * @brief Binary-space-partition layout of the plot area. + * + * Framework-free: no ImGui, no UDPS. The geometry and the hit-testing are the + * fiddly part of the pane UI and are unit-tested without a window. + */ +#pragma once + +#include "Types.h" + +#include +#include +#include + +namespace udpscope { + +/** Direction a node splits its rectangle in. */ +enum class Orient { Columns, Rows }; + +/** Vertical scaling strategy for one trace. */ +enum class VMode { Auto, Range, Manual }; + +struct VScale { + VMode mode = VMode::Auto; + double div = 1.0; /**< Units per division, Manual only. */ + double offset = 0.0; /**< Centre value, Manual only. */ +}; + +/** One signal drawn in one pane. Signals are named, never indexed. */ +struct Assignment { + std::string signalName; + Color color; + float lineWidth = 1.5f; + VScale vs; +}; + +/** Smallest a pane may be squeezed to, in pixels. */ +constexpr double kMinPaneSize = 80.0; + +/** Thickness of the splitter drag zone and of the inset handles, in pixels. */ +constexpr double kSplitterGrab = 6.0; +constexpr double kHandleSize = 18.0; + +/** What the pointer is over inside a pane. */ +enum class Handle { None, Left, Right, Top, Bottom, Close }; + +struct PaneNode { + bool leaf = true; + + /* leaf only */ + std::vector signals; + bool profilePane = false; /**< Holds vector signals, not time series. */ + + /* split only */ + Orient orient = Orient::Columns; + double ratio = 0.5; /**< First child's share of the parent. */ + std::unique_ptr a, b; +}; + +class PaneTree { +public: + struct Placed { PaneNode* leaf; Rect rect; }; + struct Splitter { PaneNode* node; Rect rect; Orient orient; }; + + PaneTree(); + + PaneNode* root() { return root_.get(); } + const PaneNode* root() const { return root_.get(); } + + /** Replaces the whole tree, e.g. when loading a session. */ + void setRoot(std::unique_ptr node); + + /** + * @brief Walk the tree, producing every leaf's rectangle and every split's + * drag zone. + */ + void layout(const Rect& area, + std::vector& leaves, + std::vector& splitters) const; + + /** Turn a leaf into a split; the original content stays in the first child. */ + void splitLeaf(PaneNode* leaf, Orient orient); + + /** Replace the leaf's parent with its sibling. No-op on the last leaf. */ + void closeLeaf(PaneNode* leaf); + + void setRatio(PaneNode* split, double ratio); + + size_t leafCount() const; + + /** @return the splitter under the point, or nullptr. */ + const Splitter* hitTestSplitter(const std::vector& splitters, + double px, double py) const; + + /** + * @brief Which inset handle of @p pane the point is over. + * + * Handles sit inside the pane so they never overlap the splitter drag zone, + * and every pane has all four regardless of whether it touches a window + * edge — a pane in the middle of a 3x3 touches none. + */ + static Handle hitTestHandle(const Rect& pane, double px, double py); + +private: + static void layoutNode(PaneNode* node, const Rect& r, + std::vector& leaves, + std::vector& splitters); + static size_t countLeaves(const PaneNode* node); + static PaneNode* findParent(PaneNode* node, const PaneNode* child); + static double clampRatio(double ratio, double extent); + + std::unique_ptr root_; +}; + +} /* namespace udpscope */ +``` + +- [ ] **Step 4: Write the implementation** + +Create `Client/udpscope/PaneTree.cpp`: + +```cpp +#include "PaneTree.h" + +#include + +namespace udpscope { + +PaneTree::PaneTree() : root_(new PaneNode()) {} + +void PaneTree::setRoot(std::unique_ptr node) { + if (node) { root_ = std::move(node); } +} + +double PaneTree::clampRatio(double ratio, double extent) { + if (extent <= 2.0 * kMinPaneSize) { + return 0.5; /* Too small to honour the minimum on both sides. */ + } + const double lo = kMinPaneSize / extent; + return std::min(std::max(ratio, lo), 1.0 - lo); +} + +void PaneTree::layoutNode(PaneNode* node, const Rect& r, + std::vector& leaves, + std::vector& splitters) { + if (node == nullptr) { return; } + if (node->leaf) { + leaves.push_back(Placed{node, r}); + return; + } + + if (node->orient == Orient::Columns) { + const double ratio = clampRatio(node->ratio, r.w); + const double wA = r.w * ratio; + layoutNode(node->a.get(), Rect{r.x, r.y, wA, r.h}, leaves, splitters); + layoutNode(node->b.get(), Rect{r.x + wA, r.y, r.w - wA, r.h}, leaves, splitters); + splitters.push_back(Splitter{ + node, + Rect{r.x + wA - kSplitterGrab * 0.5, r.y, kSplitterGrab, r.h}, + Orient::Columns}); + } else { + const double ratio = clampRatio(node->ratio, r.h); + const double hA = r.h * ratio; + layoutNode(node->a.get(), Rect{r.x, r.y, r.w, hA}, leaves, splitters); + layoutNode(node->b.get(), Rect{r.x, r.y + hA, r.w, r.h - hA}, leaves, splitters); + splitters.push_back(Splitter{ + node, + Rect{r.x, r.y + hA - kSplitterGrab * 0.5, r.w, kSplitterGrab}, + Orient::Rows}); + } +} + +void PaneTree::layout(const Rect& area, + std::vector& leaves, + std::vector& splitters) const { + leaves.clear(); + splitters.clear(); + layoutNode(root_.get(), area, leaves, splitters); +} + +void PaneTree::splitLeaf(PaneNode* leaf, Orient orient) { + if (leaf == nullptr || !leaf->leaf) { return; } + + /* Move the existing content into a new first child; the second is empty. */ + std::unique_ptr first(new PaneNode()); + first->signals = std::move(leaf->signals); + first->profilePane = leaf->profilePane; + + std::unique_ptr second(new PaneNode()); + + leaf->leaf = false; + leaf->orient = orient; + leaf->ratio = 0.5; + leaf->signals.clear(); + leaf->a = std::move(first); + leaf->b = std::move(second); +} + +PaneNode* PaneTree::findParent(PaneNode* node, const PaneNode* child) { + if (node == nullptr || node->leaf) { return nullptr; } + if (node->a.get() == child || node->b.get() == child) { return node; } + if (PaneNode* p = findParent(node->a.get(), child)) { return p; } + return findParent(node->b.get(), child); +} + +void PaneTree::closeLeaf(PaneNode* leaf) { + if (leaf == nullptr || !leaf->leaf) { return; } + + PaneNode* parent = findParent(root_.get(), leaf); + if (parent == nullptr) { + return; /* The root is the only leaf; a scope with no pane is useless. */ + } + + std::unique_ptr survivor = + (parent->a.get() == leaf) ? std::move(parent->b) : std::move(parent->a); + + /* Collapse the parent into the survivor in place, so the parent pointer + * held by any caller stays valid. */ + parent->leaf = survivor->leaf; + parent->signals = std::move(survivor->signals); + parent->profilePane = survivor->profilePane; + parent->orient = survivor->orient; + parent->ratio = survivor->ratio; + parent->a = std::move(survivor->a); + parent->b = std::move(survivor->b); +} + +void PaneTree::setRatio(PaneNode* split, double ratio) { + if (split != nullptr && !split->leaf) { + split->ratio = std::min(std::max(ratio, 0.0), 1.0); + } +} + +size_t PaneTree::countLeaves(const PaneNode* node) { + if (node == nullptr) { return 0; } + if (node->leaf) { return 1; } + return countLeaves(node->a.get()) + countLeaves(node->b.get()); +} + +size_t PaneTree::leafCount() const { return countLeaves(root_.get()); } + +const PaneTree::Splitter* PaneTree::hitTestSplitter( + const std::vector& splitters, double px, double py) const { + for (const Splitter& s : splitters) { + if (s.rect.contains(px, py)) { return &s; } + } + return nullptr; +} + +Handle PaneTree::hitTestHandle(const Rect& pane, double px, double py) { + if (!pane.contains(px, py)) { return Handle::None; } + + const double relX = px - pane.x; + const double relY = py - pane.y; + const double midY = pane.h * 0.5; + const double midX = pane.w * 0.5; + const double half = kHandleSize * 0.5; + + /* Close sits in the top-right corner and wins over the edge handles. */ + if (relX >= pane.w - kHandleSize && relY <= kHandleSize) { + return Handle::Close; + } + if (relX <= kHandleSize && std::abs(relY - midY) <= half * 3.0) { + return Handle::Left; + } + if (relX >= pane.w - kHandleSize && std::abs(relY - midY) <= half * 3.0) { + return Handle::Right; + } + if (relY <= kHandleSize && std::abs(relX - midX) <= half * 3.0) { + return Handle::Top; + } + if (relY >= pane.h - kHandleSize && std::abs(relX - midX) <= half * 3.0) { + return Handle::Bottom; + } + return Handle::None; +} + +} /* namespace udpscope */ +``` + +Add `#include ` at the top of `PaneTree.cpp` for `std::abs` on doubles. + +- [ ] **Step 5: Register the source with CMake** + +In `Client/udpscope/CMakeLists.txt`, change `CORE_SOURCES` to: + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp +) +``` + +- [ ] **Step 6: Run the tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneTree*' +``` + +Expected: PASS, 12 tests. + +- [ ] **Step 7: Commit** + +```bash +git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \ + Client/udpscope/tests/PaneTreeTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): BSP pane tree with split, close and hit-testing" +``` + +--- + +### Task 3: Time base + +Maps a producer clock onto wall clock. Two pieces: a one-off offset with drift-triggered recalibration, and a least-squares fit that recovers the `hrt` tick rate without assuming the client runs on the producer's host. + +**Files:** +- Create: `Client/udpscope/TimeBase.h` +- Create: `Client/udpscope/TimeBase.cpp` +- Create: `Client/udpscope/tests/TimeBaseTest.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` (add `TimeBase.cpp`) + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `class ClockOffset` — `double map(double producerSec, double wallSec)`, `bool valid() const`, `void reset()`, `static constexpr double kRecalibThresholdS = 0.5` + - `class HrtRateFit` — `void add(uint64_t hrt, double wallSec)`, `bool ready() const`, `double ticksPerSecond() const`, `double toSeconds(uint64_t hrt) const`, `void reset()`, `static constexpr size_t kMinSamples = 32` + - `double TimeSignalScale(uint8_t typeCode)` + +- [ ] **Step 1: Write the failing test** + +Create `Client/udpscope/tests/TimeBaseTest.cpp`: + +```cpp +#include "TimeBase.h" + +#include + +using namespace udpscope; + +TEST(ClockOffset, MapsTheFirstReadingOntoWallClockExactly) { + ClockOffset off; + EXPECT_FALSE(off.valid()); + + const double wall = 1756291200.5; + EXPECT_DOUBLE_EQ(off.map(10.0, wall), wall); + EXPECT_TRUE(off.valid()); +} + +// Network delay jitters the arrival time. If the offset chased every packet +// the whole trace would wobble, so it is latched and only corrected on real +// drift. +TEST(ClockOffset, HoldsTheOffsetThroughSmallArrivalJitter) { + ClockOffset off; + off.map(10.0, 1000.0); // offset = 990 + + EXPECT_DOUBLE_EQ(off.map(11.0, 1001.02), 1001.0); + EXPECT_DOUBLE_EQ(off.map(12.0, 1000.97), 1002.0); +} + +TEST(ClockOffset, RecalibratesWhenDriftExceedsTheThreshold) { + ClockOffset off; + off.map(10.0, 1000.0); // offset = 990 + + /* Producer clock jumped (restart, re-phase): 5 s of error is not jitter. */ + const double mapped = off.map(11.0, 1006.0); + EXPECT_DOUBLE_EQ(mapped, 1006.0); +} + +TEST(ClockOffset, ResetForgetsTheCalibration) { + ClockOffset off; + off.map(10.0, 1000.0); + off.reset(); + EXPECT_FALSE(off.valid()); + EXPECT_DOUBLE_EQ(off.map(50.0, 2000.0), 2000.0); +} + +// The tick rate of the producer's high-resolution timer is not carried by the +// protocol, and StreamHub's trick of using the local MARTe timer frequency only +// works on the producer's own host. Recover it from the data instead. +TEST(HrtRateFit, RecoversAKnownTickRate) { + HrtRateFit fit; + const double ticksPerSec = 2.5e9; + + EXPECT_FALSE(fit.ready()); + for (int i = 0; i < 64; i++) { + const double wall = 1000.0 + i * 0.01; + fit.add(static_cast(wall * ticksPerSec), wall); + } + + ASSERT_TRUE(fit.ready()); + EXPECT_NEAR(fit.ticksPerSecond(), ticksPerSec, ticksPerSec * 1e-6); +} + +TEST(HrtRateFit, IsNotReadyBeforeTheMinimumSampleCount) { + HrtRateFit fit; + for (size_t i = 0; i < HrtRateFit::kMinSamples - 1; i++) { + fit.add(static_cast(i) * 1000000u, 1000.0 + i * 0.001); + } + EXPECT_FALSE(fit.ready()); + + fit.add(static_cast(HrtRateFit::kMinSamples) * 1000000u, + 1000.0 + HrtRateFit::kMinSamples * 0.001); + EXPECT_TRUE(fit.ready()); +} + +TEST(HrtRateFit, ToSecondsUsesTheFittedRate) { + HrtRateFit fit; + const double ticksPerSec = 1.0e9; + for (int i = 0; i < 64; i++) { + const double wall = 500.0 + i * 0.005; + fit.add(static_cast(wall * ticksPerSec), wall); + } + ASSERT_TRUE(fit.ready()); + EXPECT_NEAR(fit.toSeconds(2000000000ull), 2.0, 1e-4); +} + +TEST(HrtRateFit, SurvivesAStalledClock) { + HrtRateFit fit; + for (int i = 0; i < 64; i++) { + fit.add(12345u, 1000.0 + i * 0.01); /* hrt never advances */ + } + /* A degenerate fit must not produce a rate that would divide by zero. */ + if (fit.ready()) { + EXPECT_GT(fit.ticksPerSecond(), 0.0); + } +} + +TEST(TimeSignalScale, UsesNanosecondsForUint64AndMicrosecondsOtherwise) { + EXPECT_DOUBLE_EQ(TimeSignalScale(6 /* UDPS_T_UINT64 */), 1.0e-9); + EXPECT_DOUBLE_EQ(TimeSignalScale(9 /* UDPS_T_FLOAT64 */), 1.0e-6); + EXPECT_DOUBLE_EQ(TimeSignalScale(4 /* UDPS_T_UINT32 */), 1.0e-6); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd Client/udpscope && cmake --build build -j +``` + +Expected: FAIL — `TimeBase.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +Create `Client/udpscope/TimeBase.h`: + +```cpp +/** + * @file TimeBase.h + * @brief Producer-clock to wall-clock reconstruction. + * + * Framework-free. A UDPS stream's accurate timestamps come from a producer + * clock — either a declared time signal or the packet's embedded hrt — and both + * need mapping onto the client's wall clock before they can be plotted. + */ +#pragma once + +#include +#include +#include + +namespace udpscope { + +/** + * @brief Seconds per count of a time signal, from its type code. + * + * The protocol carries uint64 time signals in nanoseconds and everything else + * in microseconds; this mirrors UDPSourceSession so the two agree on a stream. + */ +double TimeSignalScale(uint8_t typeCode); + +/** + * @brief A latched producer-to-wall offset. + * + * Established from the first sample and then held, so network jitter does not + * wobble the trace. Only a drift beyond kRecalibThresholdS — a producer restart + * or re-phase, not delivery noise — forces a new calibration. + */ +class ClockOffset { +public: + static constexpr double kRecalibThresholdS = 0.5; + + /** @return producerSec mapped onto wall clock. */ + double map(double producerSec, double wallSec); + + bool valid() const { return valid_; } + void reset() { valid_ = false; offset_ = 0.0; } + double offset() const { return offset_; } + +private: + double offset_ = 0.0; + bool valid_ = false; +}; + +/** + * @brief Recovers the producer's hrt tick rate by least squares against arrival + * time. + * + * The protocol does not carry the tick rate, and StreamHub's approach of using + * the local MARTe HighResolutionTimer frequency is only valid when the client + * runs on the producer's host. A remote bench scope cannot assume that, so the + * rate is measured: hrt against recv_time is a straight line whose slope is + * ticks per second. + */ +class HrtRateFit { +public: + static constexpr size_t kMinSamples = 32; + static constexpr size_t kWindow = 256; + + void add(uint64_t hrt, double wallSec); + bool ready() const { return n_ >= kMinSamples && rate_ > 0.0; } + double ticksPerSecond() const { return rate_; } + double toSeconds(uint64_t hrt) const; + void reset(); + +private: + void refit(); + + struct Sample { double hrt; double wall; }; + std::deque samples_; + size_t n_ = 0; + double rate_ = 0.0; +}; + +} /* namespace udpscope */ +``` + +- [ ] **Step 4: Write the implementation** + +Create `Client/udpscope/TimeBase.cpp`: + +```cpp +#include "TimeBase.h" + +#include + +namespace udpscope { + +/* UDPS_T_UINT64 == 6 in Common/UDP/UDPSProtocol.h. Spelled numerically so this + * translation unit stays free of the C client header. */ +static constexpr uint8_t kTypeUint64 = 6u; + +double TimeSignalScale(uint8_t typeCode) { + return (typeCode == kTypeUint64) ? 1.0e-9 : 1.0e-6; +} + +double ClockOffset::map(double producerSec, double wallSec) { + if (!valid_ || std::fabs((offset_ + producerSec) - wallSec) > kRecalibThresholdS) { + offset_ = wallSec - producerSec; + valid_ = true; + } + return offset_ + producerSec; +} + +void HrtRateFit::reset() { + samples_.clear(); + n_ = 0; + rate_ = 0.0; +} + +void HrtRateFit::add(uint64_t hrt, double wallSec) { + samples_.push_back(Sample{static_cast(hrt), wallSec}); + if (samples_.size() > kWindow) { samples_.pop_front(); } + n_++; + if (n_ >= kMinSamples) { refit(); } +} + +void HrtRateFit::refit() { + const size_t n = samples_.size(); + if (n < 2) { return; } + + /* Least squares slope of hrt against wall time. Both are subtracted from + * their first value first: raw hrt counts and epoch seconds are large + * enough that the naive sums lose precision. */ + const double h0 = samples_.front().hrt; + const double w0 = samples_.front().wall; + + double sw = 0.0, sh = 0.0, sww = 0.0, swh = 0.0; + for (const Sample& s : samples_) { + const double w = s.wall - w0; + const double h = s.hrt - h0; + sw += w; + sh += h; + sww += w * w; + swh += w * h; + } + const double dn = static_cast(n); + const double denom = dn * sww - sw * sw; + if (std::fabs(denom) < 1e-12) { return; } + + const double slope = (dn * swh - sw * sh) / denom; + if (slope > 0.0 && std::isfinite(slope)) { rate_ = slope; } +} + +double HrtRateFit::toSeconds(uint64_t hrt) const { + if (rate_ <= 0.0) { return 0.0; } + return static_cast(hrt) / rate_; +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 5: Register the source with CMake** + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp + TimeBase.cpp +) +``` + +- [ ] **Step 6: Run the tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ClockOffset*:HrtRateFit*:TimeSignalScale*' +``` + +Expected: PASS, 9 tests. + +- [ ] **Step 7: Commit** + +```bash +git add Client/udpscope/TimeBase.h Client/udpscope/TimeBase.cpp \ + Client/udpscope/tests/TimeBaseTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): producer-clock calibration and hrt tick-rate fit" +``` + +--- + +### Task 4: Frame decoder — per-element timestamps + +Spec §5, the part the C library deliberately does not do for you. `udps_frame_element_time()` is an arrival-anchored *estimate*; relying on it renders bursty delivery as a sawtooth. This reproduces `UDPSourceSession.cpp`'s rules on top of the C API. + +**Files:** +- Create: `Client/udpscope/FrameDecoder.h` +- Create: `Client/udpscope/FrameDecoder.cpp` +- Create: `Client/udpscope/tests/FrameDecoderTest.cpp` +- Modify: `Client/udpscope/Types.h` (add `SignalMeta`, `FrameView`, protocol constants) +- Modify: `Client/udpscope/CMakeLists.txt` (add `FrameDecoder.cpp`) + +**Interfaces:** +- Consumes: `ClockOffset`, `HrtRateFit`, `TimeSignalScale` (Task 3). +- Produces: + - `udpscope::SignalMeta` with `numElements()`, `hasTimeSignal(uint32_t)`, `isVectorProfile()` + - `udpscope::FrameView` — a non-owning mirror of `udps_frame_t` + - `constexpr uint8_t kTimePacket/kTimeFullArray/kTimeFirstSample/kTimeLastSample` + - `constexpr uint32_t kNoTimeSignal` + - `class FrameDecoder` with `setSignals()`, `beginFrame()`, `timestamps()`, `reset()` + +- [ ] **Step 1: Extend the shared types** + +Append to `Client/udpscope/Types.h`, inside `namespace udpscope`: + +```cpp +/* Protocol constants, spelled out rather than included, so the framework-free + * modules stay independent of udps_client.h. They mirror Common/UDP/UDPSProtocol.h. */ +constexpr uint8_t kTimePacket = 0; +constexpr uint8_t kTimeFullArray = 1; +constexpr uint8_t kTimeFirstSample = 2; +constexpr uint8_t kTimeLastSample = 3; +constexpr uint32_t kNoTimeSignal = 0xFFFFFFFFu; + +/** Framework-free mirror of udps_signal_t, plus UI state. */ +struct SignalMeta { + std::string name; + uint8_t typeCode = 255; + uint8_t quantType = 0; + uint32_t numRows = 1; + uint32_t numCols = 1; + double rangeMin = 0.0; + double rangeMax = 0.0; + uint8_t timeMode = kTimePacket; + double samplingRate = 0.0; + uint32_t timeSignalIdx = kNoTimeSignal; + std::string unit; + + /** User override: treat an ambiguous PACKET array as a profile, not a burst. */ + bool profileOverride = false; + + uint32_t numElements() const { + const uint64_t n = static_cast(numRows ? numRows : 1u) * + static_cast(numCols ? numCols : 1u); + return n == 0u ? 1u : static_cast(n); + } + + bool hasTimeSignal(uint32_t numSignals) const { + return timeSignalIdx != kNoTimeSignal && timeSignalIdx < numSignals; + } + + /** + * @brief True when this array should be plotted against element index + * rather than unrolled onto the time axis. + * + * Only PACKET arrays are ambiguous: the producer stamped the whole datagram + * with one time, which is what a genuine vector looks like and also what a + * burst carrying no time metadata looks like. Default is burst, matching + * UDPSourceSession, with this flag as the user's override. + */ + bool isVectorProfile() const { + return profileOverride && numElements() > 1u && timeMode == kTimePacket; + } +}; + +/** + * @brief Non-owning mirror of udps_frame_t. + * + * Kept separate from the C struct so FrameDecoder can be tested with plain + * arrays and no socket. Points at memory owned by the caller. + */ +struct FrameView { + uint32_t counter = 0; + uint64_t hrt = 0; + double recvTime = 0.0; + uint32_t numSamples = 1; + uint32_t numSignals = 0; + const double* const* values = nullptr; /**< values[i][0..counts[i]) */ + const uint32_t* counts = nullptr; +}; +``` + +- [ ] **Step 2: Write the failing test** + +Create `Client/udpscope/tests/FrameDecoderTest.cpp`: + +```cpp +#include "FrameDecoder.h" + +#include + +#include + +using namespace udpscope; + +namespace { + +/** Builds a FrameView over vectors the test owns. */ +struct FrameBuilder { + std::vector> storage; + std::vector ptrs; + std::vector counts; + FrameView view; + + void addSignal(std::vector vals) { + storage.push_back(std::move(vals)); + } + + const FrameView& build(uint64_t hrt, double recvTime, uint32_t numSamples = 1) { + ptrs.clear(); + counts.clear(); + for (const auto& s : storage) { + ptrs.push_back(s.data()); + counts.push_back(static_cast(s.size())); + } + view.hrt = hrt; + view.recvTime = recvTime; + view.numSamples = numSamples; + view.numSignals = static_cast(storage.size()); + view.values = ptrs.data(); + view.counts = counts.data(); + return view; + } +}; + +SignalMeta burst(const char* name, uint8_t timeMode, double rate, + uint32_t elems, uint32_t timeIdx) { + SignalMeta m; + m.name = name; + m.typeCode = 8; /* float32 */ + m.numRows = elems; + m.numCols = 1; + m.timeMode = timeMode; + m.samplingRate = rate; + m.timeSignalIdx = timeIdx; + return m; +} + +SignalMeta timeSignal(const char* name, uint32_t elems) { + SignalMeta m; + m.name = name; + m.typeCode = 6; /* uint64 -> nanoseconds */ + m.numRows = elems; + m.numCols = 1; + return m; +} + +} /* namespace */ + +TEST(FrameDecoder, FullArrayTakesOneStampPerElementFromTheTimeSignal) { + FrameDecoder dec; + dec.setSignals({burst("Sine", kTimeFullArray, 1000.0, 4, 1), + timeSignal("Time", 4)}); + + FrameBuilder fb; + fb.addSignal({1.0, 2.0, 3.0, 4.0}); + /* Nanoseconds: 5.000, 5.001, 5.002, 5.003 s of producer time. */ + fb.addSignal({5.0e9, 5.001e9, 5.002e9, 5.003e9}); + const FrameView& f = fb.build(0, 1000.0); + + dec.beginFrame(f); + std::vector ts; + ASSERT_TRUE(dec.timestamps(f, 0, ts)); + ASSERT_EQ(ts.size(), 4u); + + /* Element 0 lands on the arrival time; the rest keep the producer spacing. */ + EXPECT_NEAR(ts[0], 1000.000, 1e-9); + EXPECT_NEAR(ts[1], 1000.001, 1e-9); + EXPECT_NEAR(ts[2], 1000.002, 1e-9); + EXPECT_NEAR(ts[3], 1000.003, 1e-9); +} + +TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) { + FrameDecoder dec; + dec.setSignals({burst("Sine", kTimeFirstSample, 1000.0, 4, 1), + timeSignal("Time", 1)}); + + FrameBuilder fb; + fb.addSignal({1.0, 2.0, 3.0, 4.0}); + fb.addSignal({7.0e9}); + const FrameView& f = fb.build(0, 2000.0); + + dec.beginFrame(f); + std::vector ts; + ASSERT_TRUE(dec.timestamps(f, 0, ts)); + ASSERT_EQ(ts.size(), 4u); + EXPECT_NEAR(ts[0], 2000.000, 1e-9); + EXPECT_NEAR(ts[3], 2000.003, 1e-9); +} + +TEST(FrameDecoder, LastSampleAnchorsTheFinalElementAndCountsBackward) { + FrameDecoder dec; + dec.setSignals({burst("Sine", kTimeLastSample, 1000.0, 4, 1), + timeSignal("Time", 1)}); + + FrameBuilder fb; + fb.addSignal({1.0, 2.0, 3.0, 4.0}); + fb.addSignal({7.0e9}); + const FrameView& f = fb.build(0, 3000.0); + + dec.beginFrame(f); + std::vector ts; + ASSERT_TRUE(dec.timestamps(f, 0, ts)); + ASSERT_EQ(ts.size(), 4u); + EXPECT_NEAR(ts[3], 3000.000, 1e-9); + EXPECT_NEAR(ts[0], 3000.000 - 0.003, 1e-9); +} + +TEST(FrameDecoder, PlainScalarUsesArrivalTime) { + FrameDecoder dec; + SignalMeta m; + m.name = "Level"; + m.typeCode = 9; + dec.setSignals({m}); + + FrameBuilder fb; + fb.addSignal({42.0}); + const FrameView& f = fb.build(0, 1234.5); + + dec.beginFrame(f); + std::vector ts; + ASSERT_TRUE(dec.timestamps(f, 0, ts)); + ASSERT_EQ(ts.size(), 1u); + EXPECT_DOUBLE_EQ(ts[0], 1234.5); +} + +// This is the failure UDPSourceSession.cpp:560 documents. The kernel delivers +// two queued datagrams microseconds apart even though each carries 10 ms of +// signal. Dating from arrival crams the second packet's samples into that gap +// and the trace becomes a sawtooth; dating from the producer hrt does not. +TEST(FrameDecoder, AccumulatedScalarSurvivesBurstyDelivery) { + FrameDecoder dec; + SignalMeta m; + m.name = "Acc"; + m.typeCode = 9; + m.numRows = 1; + m.samplingRate = 1000.0; /* 1 kHz, 10 samples = 10 ms per packet */ + dec.setSignals({m}); + + const double ticks = 1.0e9; + std::vector all; + + for (int p = 0; p < 40; p++) { + FrameBuilder fb; + fb.addSignal(std::vector(10, static_cast(p))); + const double producerSec = 100.0 + p * 0.010; + /* Packets 20+ arrive in a burst, all within 50 us of each other. */ + const double arrival = (p < 20) ? (500.0 + p * 0.010) + : (500.2 + (p - 20) * 0.00005); + const FrameView& f = fb.build(static_cast(producerSec * ticks), + arrival, 10); + dec.beginFrame(f); + std::vector ts; + if (dec.timestamps(f, 0, ts)) { + all.insert(all.end(), ts.begin(), ts.end()); + } + } + + ASSERT_GT(all.size(), 300u); + for (size_t i = 1; i < all.size(); i++) { + EXPECT_GT(all[i], all[i - 1]) << "non-monotonic at " << i; + EXPECT_NEAR(all[i] - all[i - 1], 0.001, 2e-4) + << "spacing collapsed at " << i << " (sawtooth)"; + } +} + +TEST(FrameDecoder, AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared) { + FrameDecoder dec; + SignalMeta m; + m.name = "Acc"; + m.typeCode = 9; + m.samplingRate = 0.0; /* undeclared */ + dec.setSignals({m}); + + const double ticks = 1.0e9; + std::vector last; + for (int p = 0; p < 40; p++) { + FrameBuilder fb; + fb.addSignal(std::vector(10, 1.0)); + const double producerSec = 100.0 + p * 0.010; /* 10 ms per packet */ + const FrameView& f = fb.build(static_cast(producerSec * ticks), + 700.0 + p * 0.010, 10); + dec.beginFrame(f); + std::vector ts; + if (dec.timestamps(f, 0, ts)) { last = ts; } + } + + ASSERT_EQ(last.size(), 10u); + /* 10 ms of producer time across 10 samples is a 1 ms period. */ + EXPECT_NEAR(last[1] - last[0], 0.001, 1e-5); +} + +// A PACKET burst has no per-element time at all. Elements span +// (lastPacket, thisPacket] — backwards from arrival, because the samples were +// acquired before the packet landed. Forward extrapolation would let a jittered +// packet overlap the next one and break ring monotonicity. +TEST(FrameDecoder, PacketBurstDropsTheFirstFrameThenSpansBackwards) { + FrameDecoder dec; + dec.setSignals({burst("Raw", kTimePacket, 0.0, 5, kNoTimeSignal)}); + + FrameBuilder fb1; + fb1.addSignal({1.0, 2.0, 3.0, 4.0, 5.0}); + const FrameView& f1 = fb1.build(0, 10.0); + dec.beginFrame(f1); + std::vector ts; + EXPECT_FALSE(dec.timestamps(f1, 0, ts)) + << "the first packet has no previous arrival to span from"; + + FrameBuilder fb2; + fb2.addSignal({6.0, 7.0, 8.0, 9.0, 10.0}); + const FrameView& f2 = fb2.build(0, 10.05); + dec.beginFrame(f2); + ASSERT_TRUE(dec.timestamps(f2, 0, ts)); + ASSERT_EQ(ts.size(), 5u); + EXPECT_GT(ts[0], 10.0); + EXPECT_NEAR(ts[4], 10.05, 1e-12); + EXPECT_NEAR(ts[1] - ts[0], 0.01, 1e-12); +} + +TEST(FrameDecoder, PacketBurstStaysMonotonicUnderJitteredArrivals) { + FrameDecoder dec; + dec.setSignals({burst("Raw", kTimePacket, 0.0, 8, kNoTimeSignal)}); + + const double jitter[] = {0.0, 0.004, -0.003, 0.006, -0.002, 0.0, 0.005, -0.004}; + std::vector all; + for (int p = 0; p < 8; p++) { + FrameBuilder fb; + fb.addSignal(std::vector(8, 1.0)); + const FrameView& f = fb.build(0, 20.0 + p * 0.05 + jitter[p]); + dec.beginFrame(f); + std::vector ts; + if (dec.timestamps(f, 0, ts)) { + all.insert(all.end(), ts.begin(), ts.end()); + } + } + + ASSERT_GT(all.size(), 8u); + for (size_t i = 1; i < all.size(); i++) { + EXPECT_GT(all[i], all[i - 1]) << "packets overlapped at " << i; + } +} + +TEST(FrameDecoder, ResetForgetsPerSignalHistory) { + FrameDecoder dec; + dec.setSignals({burst("Raw", kTimePacket, 0.0, 4, kNoTimeSignal)}); + + FrameBuilder fb; + fb.addSignal({1.0, 2.0, 3.0, 4.0}); + const FrameView& f = fb.build(0, 5.0); + dec.beginFrame(f); + std::vector ts; + EXPECT_FALSE(dec.timestamps(f, 0, ts)); + + const FrameView& f2 = fb.build(0, 5.1); + dec.beginFrame(f2); + EXPECT_TRUE(dec.timestamps(f2, 0, ts)); + + dec.reset(); + const FrameView& f3 = fb.build(0, 5.2); + dec.beginFrame(f3); + EXPECT_FALSE(dec.timestamps(f3, 0, ts)) + << "after reset the next packet is again the first one"; +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +cd Client/udpscope && cmake --build build -j +``` + +Expected: FAIL — `FrameDecoder.h: No such file or directory`. + +- [ ] **Step 4: Write the header** + +Create `Client/udpscope/FrameDecoder.h`: + +```cpp +/** + * @file FrameDecoder.h + * @brief Per-element timestamp reconstruction for UDPS frames. + * + * The C client's udps_frame_element_time() is explicitly an arrival-anchored + * estimate. It is not sufficient: the kernel frequently delivers several queued + * datagrams in one burst, so two packets are processed microseconds apart even + * though each represents ~10 ms of signal, and arrival-time interpolation then + * crams a packet's samples into that tiny gap — the trace renders as a sawtooth. + * Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and + * solves it; these are the same rules, computed from udps_frame_t's own fields + * so the scope and StreamHub agree on the same stream. + */ +#pragma once + +#include "TimeBase.h" +#include "Types.h" + +#include + +namespace udpscope { + +class FrameDecoder { +public: + /** Installs the signal table. Clears all per-signal timing history. */ + void setSignals(const std::vector& signals); + + const std::vector& signals() const { return signals_; } + + /** Call once per frame, before any timestamps() call for that frame. */ + void beginFrame(const FrameView& f); + + /** + * @brief Timestamps for every value of signal @p idx in this frame. + * @return false when the signal produced nothing usable — an empty slot, or + * the first PACKET burst after connect, which has no previous + * arrival to span from and would otherwise poison the ring with + * wrongly spaced timestamps. + */ + bool timestamps(const FrameView& f, uint32_t idx, std::vector& tsOut); + + /** Forgets all timing history; call on reconnect. */ + void reset(); + +private: + bool packetBurst(uint32_t idx, uint32_t nElems, double wallNow, + std::vector& tsOut); + + struct SigState { + ClockOffset offset; + double lastPacketWall = 0.0; + bool lastPacketValid = false; + double lastAccHrtSec = 0.0; + bool lastAccValid = false; + uint32_t prevAccCount = 0; + }; + + std::vector signals_; + std::vector state_; + HrtRateFit hrtFit_; +}; + +} /* namespace udpscope */ +``` + +- [ ] **Step 5: Write the implementation** + +Create `Client/udpscope/FrameDecoder.cpp`: + +```cpp +#include "FrameDecoder.h" + +namespace udpscope { + +/** Fallback cycle period before the first inter-packet gap is known. */ +static constexpr double kDefaultDt = 1.0e-3; + +void FrameDecoder::setSignals(const std::vector& signals) { + signals_ = signals; + state_.assign(signals_.size(), SigState{}); + hrtFit_.reset(); +} + +void FrameDecoder::reset() { + state_.assign(signals_.size(), SigState{}); + hrtFit_.reset(); +} + +void FrameDecoder::beginFrame(const FrameView& f) { + if (f.hrt != 0u) { hrtFit_.add(f.hrt, f.recvTime); } +} + +bool FrameDecoder::packetBurst(uint32_t idx, uint32_t nElems, double wallNow, + std::vector& tsOut) { + SigState& st = state_[idx]; + if (!st.lastPacketValid || wallNow <= st.lastPacketWall) { + /* No previous arrival to span from, or time went backwards. Remember + * this one and drop the samples rather than store them at made-up + * spacing. */ + st.lastPacketWall = wallNow; + st.lastPacketValid = true; + return false; + } + + const double dt = (wallNow - st.lastPacketWall) / static_cast(nElems); + tsOut.resize(nElems); + for (uint32_t e = 0; e < nElems; e++) { + tsOut[e] = st.lastPacketWall + static_cast(e + 1u) * dt; + } + st.lastPacketWall = wallNow; + return true; +} + +bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx, + std::vector& tsOut) { + tsOut.clear(); + if (idx >= signals_.size() || idx >= f.numSignals || f.counts == nullptr) { + return false; + } + + const SignalMeta& d = signals_[idx]; + const uint32_t nElems = f.counts[idx]; + if (nElems == 0u) { return false; } + + const double wallNow = f.recvTime; + SigState& st = state_[idx]; + + const bool hasTimeSig = d.hasTimeSignal(f.numSignals); + const uint32_t tIdx = hasTimeSig ? d.timeSignalIdx : 0u; + const double tScale = hasTimeSig + ? TimeSignalScale(signals_[tIdx].typeCode) + : 1.0e-6; + + /* Rule 1: one stamp per element, straight from the time signal. */ + if (d.timeMode == kTimeFullArray && hasTimeSig && + f.counts[tIdx] >= nElems && f.values[tIdx] != nullptr) { + const double* tv = f.values[tIdx]; + const double t0 = tv[0] * tScale; + (void) st.offset.map(t0, wallNow); + const double base = st.offset.offset(); + tsOut.resize(nElems); + for (uint32_t e = 0; e < nElems; e++) { + tsOut[e] = base + tv[e] * tScale; + } + return true; + } + + /* Rule 2: anchor from the time signal, spread by the sampling rate. */ + if ((d.timeMode == kTimeFirstSample || d.timeMode == kTimeLastSample) && + hasTimeSig && f.counts[tIdx] >= 1u && f.values[tIdx] != nullptr) { + const double anchor = st.offset.map(f.values[tIdx][0] * tScale, wallNow); + const double dt = (d.samplingRate > 0.0) ? (1.0 / d.samplingRate) : 0.0; + tsOut.resize(nElems); + for (uint32_t e = 0; e < nElems; e++) { + tsOut[e] = (d.timeMode == kTimeFirstSample) + ? (anchor + static_cast(e) * dt) + : (anchor - static_cast(nElems - 1u - e) * dt); + } + return true; + } + + /* Rule 3: accumulated scalar, based on the producer's own hrt. */ + if (d.numElements() == 1u && nElems > 1u) { + if (!hrtFit_.ready()) { + return packetBurst(idx, nElems, wallNow, tsOut); + } + const double hrtSec = hrtFit_.toSeconds(f.hrt); + const double base = st.offset.map(hrtSec, wallNow); + + double dt; + if (d.samplingRate > 0.0) { + dt = 1.0 / d.samplingRate; + } else if (st.lastAccValid && st.prevAccCount > 0u && + hrtSec > st.lastAccHrtSec) { + /* The flushes carry contiguous RT cycles, so the gap divided by the + * previous packet's sample count is exactly one cycle period. */ + dt = (hrtSec - st.lastAccHrtSec) / + static_cast(st.prevAccCount); + } else { + dt = kDefaultDt; + } + + tsOut.resize(nElems); + for (uint32_t e = 0; e < nElems; e++) { + tsOut[e] = base + static_cast(e) * dt; + } + st.lastAccHrtSec = hrtSec; + st.lastAccValid = true; + st.prevAccCount = nElems; + return true; + } + + /* Rule 4: PACKET burst with no time reference at all. */ + if (nElems > 1u) { + return packetBurst(idx, nElems, wallNow, tsOut); + } + + /* Rule 5: plain scalar. */ + tsOut.assign(1, wallNow); + return true; +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 6: Register the source with CMake** + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp + TimeBase.cpp + FrameDecoder.cpp +) +``` + +- [ ] **Step 7: Run the tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*' +``` + +Expected: PASS, 9 tests. + +If `AccumulatedScalarSurvivesBurstyDelivery` fails on the first few samples, check that `beginFrame()` is being called before `timestamps()` — the hrt fit needs 32 packets before rule 3 engages, and the packets before that legitimately go through rule 4. + +- [ ] **Step 8: Commit** + +```bash +git add Client/udpscope/FrameDecoder.h Client/udpscope/FrameDecoder.cpp \ + Client/udpscope/Types.h Client/udpscope/tests/FrameDecoderTest.cpp \ + Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): per-element timestamp reconstruction from UDPS frames" +``` + +--- + +### Task 5: Trigger FSM + +The trigger runs in the receiver thread, fed the same reconstructed +`(t, v)` arrays that go into the ring. It never stores samples itself — it +only decides *when* a capture window opened and closed. The GUI later reads +`[tTrig - preSec, tTrig + postSec]` out of the ring. + +**Files:** +- Create: `Client/udpscope/Trigger.h` +- Create: `Client/udpscope/Trigger.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` (add `Trigger.cpp` to `CORE_SOURCES`) +- Test: `Client/udpscope/tests/TriggerTest.cpp` + +**Interfaces:** +- Consumes: nothing from earlier tasks (pure logic over `double` arrays). +- Produces: + +```cpp +enum class Edge { Rising, Falling, Both }; +enum class TrigMode { Normal, Single }; +enum class TrigState{ Idle, Armed, Collecting, Held }; + +struct TrigConfig { + std::string signalName; + Edge edge = Edge::Rising; + double threshold = 0.0; + double hysteresis = 0.0; + double windowSec = 0.1; + double prePercent = 20.0; + TrigMode mode = TrigMode::Normal; + double preSec() const; + double postSec() const; +}; + +class Trigger { +public: + void setConfig(const TrigConfig& c); + const TrigConfig& config() const; + void arm(); + void disarm(); + void rearm(); + void feed(const double* t, const double* v, size_t n, double ringOldestTime); + TrigState state() const; + double fillFraction() const; + double trigTime() const; + bool captureReady() const; + void captureTaken(); + static constexpr double kHarvestMarginSec = 0.05; +}; +``` + +**Why the harvest margin exists:** the GUI reads the capture out of the ring +one repaint tick after the receiver declares it complete. Without a margin +the last samples of the window can be overwritten before they are read. This +is exactly the bug that was found and fixed in the Go hub +(`Common/Client/go/wshub/ringbuf.go`, `captureLagSec`) — do not remove it. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/TriggerTest.cpp`: + +```cpp +#include "Trigger.h" +#include +#include +#include + +using namespace udpscope; + +namespace { + +// Builds a Trigger armed on a rising edge through 0.5 with the given window. +Trigger makeTrigger(Edge e, double thr, double hyst, + double windowSec, double prePercent, + TrigMode mode = TrigMode::Normal) { + TrigConfig c; + c.signalName = "sig"; + c.edge = e; + c.threshold = thr; + c.hysteresis = hyst; + c.windowSec = windowSec; + c.prePercent = prePercent; + c.mode = mode; + Trigger tr; + tr.setConfig(c); + return tr; +} + +// Feeds one sample at a time so the FSM sees realistic packet granularity. +void feedOne(Trigger& tr, double t, double v, double oldest) { + tr.feed(&t, &v, 1, oldest); +} + +} // namespace + +TEST(TriggerConfig, SplitsTheWindowByThePrePercentage) { + TrigConfig c; + c.windowSec = 0.2; + c.prePercent = 25.0; + EXPECT_DOUBLE_EQ(c.preSec(), 0.05); + EXPECT_DOUBLE_EQ(c.postSec(), 0.15); +} + +TEST(Trigger, StartsIdleAndOnlyArmsWhenAsked) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); + EXPECT_EQ(tr.state(), TrigState::Idle); + // An unarmed trigger ignores a crossing entirely. + feedOne(tr, 1.0, 0.0, 0.0); + feedOne(tr, 1.001, 1.0, 0.0); + EXPECT_EQ(tr.state(), TrigState::Idle); + EXPECT_FALSE(tr.captureReady()); +} + +// The pre-window has to already be in the ring when the edge lands, or the +// capture has nothing to back-fill from. Arming reports Armed but the FSM +// refuses to accept an edge until the ring reaches back far enough. +TEST(Trigger, RefusesToFireBeforeThePreWindowIsBuffered) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 50.0); // preSec = 0.05 + tr.arm(); + ASSERT_EQ(tr.state(), TrigState::Armed); + + // Ring only reaches back to t = 0.98, i.e. 0.02 s of history at t = 1.0. + feedOne(tr, 1.000, 0.0, 0.98); + feedOne(tr, 1.001, 1.0, 0.98); + EXPECT_EQ(tr.state(), TrigState::Armed) << "fired without a full pre-window"; + EXPECT_LT(tr.fillFraction(), 1.0); + + // Now the ring reaches back 0.06 s and the same edge is accepted. + feedOne(tr, 1.100, 0.0, 1.04); + feedOne(tr, 1.101, 1.0, 1.04); + EXPECT_EQ(tr.state(), TrigState::Collecting); +} + +TEST(Trigger, FillFractionReportsPreWindowProgress) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 50.0); // preSec = 0.05 + tr.arm(); + feedOne(tr, 1.0, 0.0, 0.975); // 0.025 s of 0.05 s + EXPECT_NEAR(tr.fillFraction(), 0.5, 1e-9); + feedOne(tr, 1.0, 0.0, 0.90); // more than enough + EXPECT_DOUBLE_EQ(tr.fillFraction(), 1.0); +} + +// The crossing almost never lands exactly on a sample. Interpolating gives a +// stable trigger point instead of one that jitters by a sample period. +TEST(Trigger, InterpolatesTheCrossingBetweenSamples) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); + tr.arm(); + feedOne(tr, 1.000, 0.0, 0.0); + feedOne(tr, 1.010, 1.0, 0.0); + // tTrig = 1.000 + (0.5 - 0.0)/(1.0 - 0.0) * 0.010 = 1.005 + ASSERT_EQ(tr.state(), TrigState::Collecting); + EXPECT_NEAR(tr.trigTime(), 1.005, 1e-12); +} + +TEST(Trigger, FallingEdgeFiresOnTheDownwardCrossing) { + Trigger tr = makeTrigger(Edge::Falling, 0.5, 0.0, 0.1, 20.0); + tr.arm(); + feedOne(tr, 1.000, 1.0, 0.0); + EXPECT_EQ(tr.state(), TrigState::Armed); + feedOne(tr, 1.010, 0.0, 0.0); + ASSERT_EQ(tr.state(), TrigState::Collecting); + EXPECT_NEAR(tr.trigTime(), 1.005, 1e-12); +} + +TEST(Trigger, RisingEdgeIgnoresADownwardCrossing) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); + tr.arm(); + feedOne(tr, 1.000, 1.0, 0.0); + feedOne(tr, 1.010, 0.0, 0.0); + EXPECT_EQ(tr.state(), TrigState::Armed); +} + +TEST(Trigger, BothEdgesFireOnWhicheverComesFirst) { + Trigger tr = makeTrigger(Edge::Both, 0.5, 0.0, 0.1, 20.0); + tr.arm(); + feedOne(tr, 1.000, 1.0, 0.0); + feedOne(tr, 1.010, 0.0, 0.0); + EXPECT_EQ(tr.state(), TrigState::Collecting); +} + +// A noisy signal riding on the threshold produces a burst of crossings. With +// hysteresis the signal must first retreat past threshold - hysteresis before +// another rising edge counts, so one physical event yields one trigger. +TEST(Trigger, HysteresisSuppressesARecrossFromNoise) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.2, 0.1, 20.0); + tr.arm(); + feedOne(tr, 1.000, 0.0, 0.0); + feedOne(tr, 1.010, 1.0, 0.0); + ASSERT_EQ(tr.state(), TrigState::Collecting); + const double first = tr.trigTime(); + + // Close the capture and re-arm, then wiggle just below threshold: 0.45 is + // under 0.5 but has not retreated past the 0.3 arm level. + feedOne(tr, 1.200, 0.0, 0.0); // past 1.005 + 0.08 + 0.05 + ASSERT_TRUE(tr.captureReady()); + tr.captureTaken(); + ASSERT_EQ(tr.state(), TrigState::Armed); + + feedOne(tr, 1.300, 0.45, 0.0); + feedOne(tr, 1.310, 0.60, 0.0); + EXPECT_EQ(tr.state(), TrigState::Armed) << "re-armed inside the hysteresis band"; + + // A genuine retreat below 0.3 re-arms the detector. + feedOne(tr, 1.400, 0.10, 0.0); + feedOne(tr, 1.410, 0.60, 0.0); + EXPECT_EQ(tr.state(), TrigState::Collecting); + EXPECT_GT(tr.trigTime(), first); +} + +// Collecting must run past the end of the post-window by the harvest margin, +// because the GUI reads the ring a tick after the receiver says "done". +TEST(Trigger, CollectingEndsAPostWindowPlusMarginAfterTheEdge) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); // post = 0.08 + tr.arm(); + feedOne(tr, 1.000, 0.0, 0.0); + feedOne(tr, 1.010, 1.0, 0.0); // tTrig = 1.005 + ASSERT_EQ(tr.state(), TrigState::Collecting); + + const double end = 1.005 + 0.08 + Trigger::kHarvestMarginSec; + feedOne(tr, end - 1e-3, 1.0, 0.0); + EXPECT_FALSE(tr.captureReady()) << "harvested before the margin elapsed"; + + feedOne(tr, end + 1e-3, 1.0, 0.0); + EXPECT_TRUE(tr.captureReady()); +} + +TEST(Trigger, NormalModeRearmsAfterTheCaptureIsTaken) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0, TrigMode::Normal); + tr.arm(); + feedOne(tr, 1.000, 0.0, 0.0); + feedOne(tr, 1.010, 1.0, 0.0); + feedOne(tr, 1.500, 1.0, 0.0); + ASSERT_TRUE(tr.captureReady()); + tr.captureTaken(); + EXPECT_EQ(tr.state(), TrigState::Armed); + EXPECT_FALSE(tr.captureReady()); +} + +TEST(Trigger, SingleModeHoldsAfterTheCaptureIsTaken) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0, TrigMode::Single); + tr.arm(); + feedOne(tr, 1.000, 0.0, 0.0); + feedOne(tr, 1.010, 1.0, 0.0); + feedOne(tr, 1.500, 1.0, 0.0); + ASSERT_TRUE(tr.captureReady()); + tr.captureTaken(); + EXPECT_EQ(tr.state(), TrigState::Held); + + // A Held trigger ignores further edges until explicitly re-armed. + feedOne(tr, 2.000, 0.0, 0.0); + feedOne(tr, 2.010, 1.0, 0.0); + EXPECT_EQ(tr.state(), TrigState::Held); + + tr.rearm(); + EXPECT_EQ(tr.state(), TrigState::Armed); +} + +TEST(Trigger, ChangingTheConfigAbandonsAnInFlightCapture) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); + tr.arm(); + feedOne(tr, 1.000, 0.0, 0.0); + feedOne(tr, 1.010, 1.0, 0.0); + ASSERT_EQ(tr.state(), TrigState::Collecting); + + TrigConfig c = tr.config(); + c.threshold = 0.9; + tr.setConfig(c); + EXPECT_EQ(tr.state(), TrigState::Idle) << "kept a capture cut to the old config"; +} + +TEST(Trigger, DisarmDropsBackToIdle) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); + tr.arm(); + tr.disarm(); + EXPECT_EQ(tr.state(), TrigState::Idle); + feedOne(tr, 1.000, 0.0, 0.0); + feedOne(tr, 1.010, 1.0, 0.0); + EXPECT_EQ(tr.state(), TrigState::Idle); +} + +// A single packet carries thousands of samples; the edge is somewhere inside +// it, and the same packet can also carry the whole post-window. +TEST(Trigger, FindsAnEdgeInTheMiddleOfALargeBlockAndCanCompleteInIt) { + Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.02, 25.0); // post = 0.015 + tr.arm(); + std::vector t(1000), v(1000); + for (size_t i = 0; i < t.size(); ++i) { + t[i] = 1.0 + static_cast(i) * 1e-4; // 10 kHz, 0.1 s span + v[i] = (i < 500) ? 0.0 : 1.0; + } + tr.feed(t.data(), v.data(), t.size(), 0.5); + // Crossing between i=499 (t=1.0499, v=0) and i=500 (t=1.05, v=1): + // tTrig = 1.0499 + 0.5 * 1e-4 = 1.04995 + EXPECT_NEAR(tr.trigTime(), 1.04995, 1e-9); + EXPECT_TRUE(tr.captureReady()) << "block extends past the post-window and margin"; +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `Trigger.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +Create `Client/udpscope/Trigger.h`: + +```cpp +/** + * @file Trigger.h + * @brief Client-side edge trigger FSM for UDPScope. + * + * The trigger observes one signal's reconstructed samples in the receiver + * thread and decides when a capture window opened and closed. It stores no + * samples: the GUI reads [trigTime() - preSec, trigTime() + postSec] out of + * the ring once captureReady() goes true. + */ +#ifndef UDPSCOPE_TRIGGER_H +#define UDPSCOPE_TRIGGER_H + +#include +#include + +namespace udpscope { + +enum class Edge { Rising, Falling, Both }; +enum class TrigMode { Normal, Single }; +enum class TrigState{ Idle, Armed, Collecting, Held }; + +struct TrigConfig { + std::string signalName; + Edge edge = Edge::Rising; + double threshold = 0.0; + double hysteresis = 0.0; + double windowSec = 0.1; + double prePercent = 20.0; + TrigMode mode = TrigMode::Normal; + + double preSec() const { return windowSec * prePercent / 100.0; } + double postSec() const { return windowSec - preSec(); } +}; + +class Trigger { +public: + /** + * Extra time the FSM keeps Collecting past the end of the post-window. + * + * The GUI harvests the capture out of the ring on its next repaint, which + * is up to one frame after the receiver declares the window complete. The + * ring must therefore still hold the window's last samples then. The Go + * hub had this same bug without a margin (captureLagSec in + * Common/Client/go/wshub/ringbuf.go) and truncated every capture. Do not + * remove this. + */ + static constexpr double kHarvestMarginSec = 0.05; + + void setConfig(const TrigConfig& c); + const TrigConfig& config() const { return cfg_; } + + void arm(); /**< Idle -> Armed. No-op if already armed. */ + void disarm(); /**< Any state -> Idle, abandoning an in-flight capture. */ + void rearm(); /**< Held -> Armed, discarding the held capture. */ + + /** + * Feed one packet's worth of reconstructed samples. + * + * @param t per-sample times, strictly increasing + * @param v per-sample values of the trigger signal + * @param n number of samples + * @param ringOldestTime time of the oldest sample still in the ring; used + * to gate arming until the pre-window is buffered + */ + void feed(const double* t, const double* v, size_t n, double ringOldestTime); + + TrigState state() const { return state_; } + /** Fraction of the pre-window currently held by the ring, clamped to 1. */ + double fillFraction() const { return fill_; } + double trigTime() const { return trigTime_; } + bool captureReady() const { return ready_; } + /** Called by the GUI once it has copied the capture out of the ring. */ + void captureTaken(); + +private: + void reset(); + bool crosses(double v0, double v1) const; + + TrigConfig cfg_; + TrigState state_ = TrigState::Idle; + double fill_ = 0.0; + double trigTime_ = 0.0; + double endTime_ = 0.0; + bool ready_ = false; + bool havePrev_ = false; + double prevT_ = 0.0; + double prevV_ = 0.0; + /** Hysteresis gate: false until the signal has retreated past the arm level. */ + bool gateOpen_ = true; +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_TRIGGER_H */ +``` + +- [ ] **Step 4: Write the implementation** + +Create `Client/udpscope/Trigger.cpp`: + +```cpp +#include "Trigger.h" + +#include + +namespace udpscope { + +void Trigger::reset() { + state_ = TrigState::Idle; + fill_ = 0.0; + trigTime_ = 0.0; + endTime_ = 0.0; + ready_ = false; + havePrev_ = false; + gateOpen_ = true; +} + +void Trigger::setConfig(const TrigConfig& c) { + cfg_ = c; + /* A capture cut to the old threshold/window would be misleading once the + config changes, so drop it rather than finish it. */ + reset(); +} + +void Trigger::arm() { + if (state_ == TrigState::Idle) { + state_ = TrigState::Armed; + ready_ = false; + havePrev_ = false; + gateOpen_ = true; + } +} + +void Trigger::disarm() { reset(); } + +void Trigger::rearm() { + reset(); + arm(); +} + +void Trigger::captureTaken() { + if (!ready_) { + return; + } + ready_ = false; + if (cfg_.mode == TrigMode::Single) { + state_ = TrigState::Held; + } else { + state_ = TrigState::Armed; + havePrev_ = false; + /* Force a retreat past the arm level before the next edge counts, so + ringing on the tail of this capture cannot immediately re-trigger. */ + gateOpen_ = (cfg_.hysteresis <= 0.0); + } +} + +bool Trigger::crosses(double v0, double v1) const { + const double thr = cfg_.threshold; + const bool up = (v0 < thr) && (v1 >= thr); + const bool down = (v0 > thr) && (v1 <= thr); + switch (cfg_.edge) { + case Edge::Rising: return up; + case Edge::Falling: return down; + case Edge::Both: return up || down; + } + return false; +} + +void Trigger::feed(const double* t, const double* v, size_t n, double ringOldestTime) { + if (n == 0u || state_ == TrigState::Idle || state_ == TrigState::Held) { + return; + } + + for (size_t i = 0u; i < n; ++i) { + const double ti = t[i]; + const double vi = v[i]; + + if (state_ == TrigState::Collecting) { + if (ti >= endTime_) { + ready_ = true; + return; /* the rest of the block belongs to the next capture */ + } + continue; + } + + /* Armed. The pre-window must already be in the ring or the capture + has nothing to back-fill from. */ + const double pre = cfg_.preSec(); + const double have = ti - ringOldestTime; + fill_ = (pre > 0.0) ? std::min(1.0, std::max(0.0, have / pre)) : 1.0; + + if (!havePrev_) { + havePrev_ = true; + prevT_ = ti; + prevV_ = vi; + continue; + } + + /* Hysteresis: after a fire the signal must retreat past the arm level + before another edge of the same polarity is accepted. */ + if (!gateOpen_ && cfg_.hysteresis > 0.0) { + const double armLevel = (cfg_.edge == Edge::Falling) + ? cfg_.threshold + cfg_.hysteresis + : cfg_.threshold - cfg_.hysteresis; + const bool retreated = (cfg_.edge == Edge::Falling) ? (vi >= armLevel) + : (vi <= armLevel); + if (retreated) { + gateOpen_ = true; + } + prevT_ = ti; + prevV_ = vi; + continue; + } + + if (fill_ >= 1.0 && crosses(prevV_, vi)) { + const double dv = vi - prevV_; + const double frac = (dv != 0.0) ? (cfg_.threshold - prevV_) / dv : 0.0; + trigTime_ = prevT_ + frac * (ti - prevT_); + endTime_ = trigTime_ + cfg_.postSec() + kHarvestMarginSec; + state_ = TrigState::Collecting; + gateOpen_ = (cfg_.hysteresis <= 0.0); + continue; /* re-enters the Collecting branch on the next sample */ + } + + prevT_ = ti; + prevV_ = vi; + } +} + +} /* namespace udpscope */ +``` + +Note on `Edge::Both` with hysteresis: the arm level is derived from the +configured edge, and `Both` uses the rising form. That is a deliberate +simplification — a symmetric band would need two gates and no bench scope +exposes that. It is documented in `Docs/UDPScope.md` (Task 16). + +- [ ] **Step 5: Register the source with CMake** + +In `Client/udpscope/CMakeLists.txt`, extend `CORE_SOURCES`: + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp + TimeBase.cpp + FrameDecoder.cpp + Trigger.cpp +) +``` + +- [ ] **Step 6: Run the tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Trigger*' +``` + +Expected: PASS, 15 tests. + +- [ ] **Step 7: Run the whole suite to check nothing regressed** + +```bash +cd Client/udpscope && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–5. + +- [ ] **Step 8: Commit** + +```bash +git add Client/udpscope/Trigger.h Client/udpscope/Trigger.cpp \ + Client/udpscope/tests/TriggerTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): edge trigger FSM with fill gate and harvest margin" +``` + +--- + +### Task 6: SignalStore — the one lock between the two threads + +`SignalStore` owns every ring, the latest vector profiles, and the most +recent completed capture. The receiver thread writes; the GUI thread reads; +one `std::mutex` guards all of it. Nothing else in the program is shared +between the threads. + +**Files:** +- Create: `Client/udpscope/SignalStore.h` +- Create: `Client/udpscope/SignalStore.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` (add `SignalStore.cpp` to `CORE_SOURCES`) +- Test: `Client/udpscope/tests/SignalStoreTest.cpp` + +**Interfaces:** +- Consumes: `SignalMeta`, `Series` from `Types.h` (Task 1/4); + `StreamHubClient::SignalBuffer` from `../streamhub/SignalBuffer.h` + (already on the include path from Task 1). +- Produces: + +```cpp +struct Profile { double time = 0.0; std::vector x, v; }; +struct Capture { + uint64_t seq = 0; + double trigTime = 0.0, t0 = 0.0, t1 = 0.0; + std::vector names; + std::vector series; +}; + +class SignalStore { +public: + static constexpr double kRingMargin = 4.0; + static constexpr size_t kMinRingPoints = 4096u; + static constexpr size_t kMaxRingPoints = 4000000u; + static constexpr size_t kTotalPointBudget = 16000000u; + + void setSignals(const std::vector& metas); + std::vector signals() const; + uint64_t generation() const; + + void push(const std::string& name, const double* t, const double* v, size_t n); + void pushProfile(const std::string& name, double time, const double* v, size_t n); + + size_t readLast(const std::string& name, size_t n, Series& out) const; + size_t readRange(const std::string& name, double t0, double t1, Series& out) const; + bool readProfile(const std::string& name, Profile& out) const; + + bool span(const std::string& name, double& oldest, double& newest) const; + double rate(const std::string& name) const; + size_t capacity(const std::string& name) const; + + void setWindowSec(double windowSec); + double windowSec() const; + void maintain(); + + void publishCapture(Capture&& c); + uint64_t captureSeq() const; + bool readCapture(Capture& out) const; +}; +``` + +**Why `kRingMargin` is 4.0 and must stay explicit:** the rings must hold +more than the trigger window itself. The pre-window has to be resident +*before* the edge arrives, the post-window accumulates *after* it, the +capture is harvested a repaint later (`Trigger::kHarvestMarginSec`), and the +rate estimate that sizes the ring lags a step behind a rate change. Four +window-lengths covers all four at a cost of a few tens of MB. Sizing the +rings to the window alone is precisely the bug that truncated every capture +in the Go hub. Do not tune this down without re-running the capture tests. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/SignalStoreTest.cpp`: + +```cpp +#include "SignalStore.h" +#include +#include +#include +#include +#include + +using namespace udpscope; + +namespace { + +SignalMeta scalarMeta(const std::string& name) { + SignalMeta m; + m.name = name; + m.typeCode = 8; /* float32 */ + m.numRows = 1; + m.numCols = 1; + return m; +} + +// Pushes `n` samples at `rate` Hz starting at t0; returns the time just past +// the last sample so callers can chain blocks. +double pushBlock(SignalStore& s, const std::string& name, + double t0, double rate, size_t n) { + std::vector t(n), v(n); + const double dt = 1.0 / rate; + for (size_t i = 0; i < n; ++i) { + t[i] = t0 + static_cast(i) * dt; + v[i] = static_cast(i); + } + s.push(name, t.data(), v.data(), n); + return t0 + static_cast(n) * dt; +} + +} // namespace + +TEST(SignalStore, CreatesARingPerSignalAtTheMinimumCapacity) { + SignalStore s; + s.setSignals({scalarMeta("a"), scalarMeta("b")}); + EXPECT_EQ(s.signals().size(), 2u); + EXPECT_EQ(s.capacity("a"), SignalStore::kMinRingPoints); + EXPECT_EQ(s.capacity("b"), SignalStore::kMinRingPoints); +} + +TEST(SignalStore, PushAndReadLastRoundTrip) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + const double t[] = {1.0, 2.0, 3.0}; + const double v[] = {10.0, 20.0, 30.0}; + s.push("a", t, v, 3); + + Series out; + ASSERT_EQ(s.readLast("a", 10, out), 3u); + EXPECT_DOUBLE_EQ(out.t[0], 1.0); + EXPECT_DOUBLE_EQ(out.v[2], 30.0); +} + +TEST(SignalStore, ReadRangeClipsToTheRequestedInterval) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + pushBlock(s, "a", 0.0, 1000.0, 1000); // 0 .. 0.999 s + + Series out; + ASSERT_EQ(s.readRange("a", 0.100, 0.200, out), 101u); + EXPECT_NEAR(out.t.front(), 0.100, 1e-9); + EXPECT_NEAR(out.t.back(), 0.200, 1e-9); +} + +TEST(SignalStore, SpanReportsTheOldestAndNewestResidentSample) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + double oldest = 0.0, newest = 0.0; + EXPECT_FALSE(s.span("a", oldest, newest)) << "empty ring must report no span"; + + // Overfill the ring so the oldest samples are gone. + const size_t n = SignalStore::kMinRingPoints + 500u; + pushBlock(s, "a", 0.0, 1000.0, n); + + ASSERT_TRUE(s.span("a", oldest, newest)); + EXPECT_NEAR(oldest, 500.0 / 1000.0, 1e-9); + EXPECT_NEAR(newest, static_cast(n - 1) / 1000.0, 1e-9); +} + +TEST(SignalStore, RateEstimateTracksThePushCadence) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + double t = 0.0; + for (int i = 0; i < 50; ++i) { + t = pushBlock(s, "a", t, 10000.0, 100); // 10 kHz in 10 ms blocks + } + EXPECT_NEAR(s.rate("a"), 10000.0, 200.0); +} + +TEST(SignalStore, UnknownSignalsAreIgnoredRatherThanCreated) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + const double t = 1.0, v = 2.0; + s.push("ghost", &t, &v, 1); + + Series out; + EXPECT_EQ(s.readLast("ghost", 10, out), 0u); + EXPECT_EQ(s.capacity("ghost"), 0u); + EXPECT_EQ(s.signals().size(), 1u) << "push must not invent a signal"; +} + +TEST(SignalStore, SetSignalsClearsPreviousDataAndBumpsTheGeneration) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + pushBlock(s, "a", 0.0, 1000.0, 100); + const uint64_t g0 = s.generation(); + + s.setSignals({scalarMeta("a"), scalarMeta("b")}); + EXPECT_GT(s.generation(), g0); + + Series out; + EXPECT_EQ(s.readLast("a", 10, out), 0u) << "stale samples survived a reconfigure"; +} + +// The whole point of the store: a 1 MSps signal with a 0.2 s trigger window +// needs 200 k points for the window itself and kRingMargin times that in the +// ring, or the capture is overwritten before the GUI can read it. +TEST(SignalStore, MaintainGrowsTheRingToTheWindowTimesTheMargin) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + s.setWindowSec(0.2); + double t = 0.0; + for (int i = 0; i < 50; ++i) { + t = pushBlock(s, "a", t, 1.0e6, 1000); + } + s.maintain(); + + const size_t want = static_cast(1.0e6 * 0.2 * SignalStore::kRingMargin); + EXPECT_NEAR(static_cast(s.capacity("a")), static_cast(want), + 0.1 * static_cast(want)); +} + +TEST(SignalStore, MaintainPreservesTheSamplesAlreadyBuffered) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + s.setWindowSec(0.2); + double t = 0.0; + for (int i = 0; i < 50; ++i) { + t = pushBlock(s, "a", t, 1.0e6, 1000); + } + Series before; + const size_t n = s.readLast("a", 1000, before); + ASSERT_EQ(n, 1000u); + + s.maintain(); + + Series after; + ASSERT_EQ(s.readLast("a", 1000, after), 1000u) << "resize threw the ring away"; + for (size_t i = 0; i < n; ++i) { + ASSERT_DOUBLE_EQ(after.t[i], before.t[i]) << "sample " << i << " moved"; + ASSERT_DOUBLE_EQ(after.v[i], before.v[i]); + } +} + +TEST(SignalStore, MaintainShrinksTheRingWhenTheWindowShrinks) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + s.setWindowSec(1.0); + double t = 0.0; + for (int i = 0; i < 50; ++i) { + t = pushBlock(s, "a", t, 1.0e6, 1000); + } + s.maintain(); + const size_t big = s.capacity("a"); + + s.setWindowSec(0.01); + s.maintain(); + EXPECT_LT(s.capacity("a"), big / 2u); + EXPECT_GE(s.capacity("a"), SignalStore::kMinRingPoints); +} + +TEST(SignalStore, MaintainClampsToTheMinimumAndTheMaximum) { + SignalStore s; + s.setSignals({scalarMeta("slow")}); + s.setWindowSec(1e-6); + double t = 0.0; + for (int i = 0; i < 50; ++i) { + t = pushBlock(s, "slow", t, 10.0, 2); + } + s.maintain(); + EXPECT_EQ(s.capacity("slow"), SignalStore::kMinRingPoints); + + SignalStore fast; + fast.setSignals({scalarMeta("fast")}); + fast.setWindowSec(3600.0); + t = 0.0; + for (int i = 0; i < 50; ++i) { + t = pushBlock(fast, "fast", t, 1.0e6, 1000); + } + fast.maintain(); + EXPECT_EQ(fast.capacity("fast"), SignalStore::kMaxRingPoints); +} + +// Rings are not allowed to sum past the global budget, however many signals +// the streamer publishes. +TEST(SignalStore, MaintainSharesTheGlobalBudgetBetweenSignals) { + SignalStore s; + std::vector metas; + for (int i = 0; i < 8; ++i) { + metas.push_back(scalarMeta("s" + std::to_string(i))); + } + s.setSignals(metas); + s.setWindowSec(10.0); + for (int i = 0; i < 8; ++i) { + double t = 0.0; + for (int b = 0; b < 50; ++b) { + t = pushBlock(s, "s" + std::to_string(i), t, 1.0e6, 1000); + } + } + s.maintain(); + + size_t total = 0u; + for (int i = 0; i < 8; ++i) { + total += s.capacity("s" + std::to_string(i)); + } + EXPECT_LE(total, SignalStore::kTotalPointBudget); + EXPECT_GT(total, SignalStore::kTotalPointBudget / 2u) << "budget left unused"; +} + +// A resize on every maintain() would clear-and-refill 64 MB per call at 60 Hz. +TEST(SignalStore, MaintainIsAStableNoOpWhenNothingChanged) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + s.setWindowSec(0.2); + double t = 0.0; + for (int i = 0; i < 50; ++i) { + t = pushBlock(s, "a", t, 1.0e6, 1000); + } + s.maintain(); + const size_t cap = s.capacity("a"); + for (int i = 0; i < 10; ++i) { + t = pushBlock(s, "a", t, 1.0e6, 1000); + s.maintain(); + } + EXPECT_EQ(s.capacity("a"), cap); +} + +TEST(SignalStore, ProfileKeepsOnlyTheLatestSnapshot) { + SignalStore s; + SignalMeta m = scalarMeta("vec"); + m.numCols = 4; + m.profileOverride = true; + s.setSignals({m}); + + const double a[] = {1.0, 2.0, 3.0, 4.0}; + const double b[] = {5.0, 6.0, 7.0, 8.0}; + s.pushProfile("vec", 1.0, a, 4); + s.pushProfile("vec", 2.0, b, 4); + + Profile p; + ASSERT_TRUE(s.readProfile("vec", p)); + EXPECT_DOUBLE_EQ(p.time, 2.0); + ASSERT_EQ(p.v.size(), 4u); + EXPECT_DOUBLE_EQ(p.v[0], 5.0); + EXPECT_DOUBLE_EQ(p.x[3], 3.0) << "x must be the element index"; +} + +TEST(SignalStore, CaptureIsPublishedAndReadBackWithARisingSequence) { + SignalStore s; + EXPECT_EQ(s.captureSeq(), 0u); + Capture none; + EXPECT_FALSE(s.readCapture(none)); + + Capture c; + c.trigTime = 5.0; + c.t0 = 4.9; + c.t1 = 5.1; + c.names.push_back("a"); + c.series.emplace_back(); + c.series[0].t = {4.9, 5.0, 5.1}; + c.series[0].v = {0.0, 1.0, 0.0}; + s.publishCapture(std::move(c)); + + EXPECT_EQ(s.captureSeq(), 1u); + Capture got; + ASSERT_TRUE(s.readCapture(got)); + EXPECT_EQ(got.seq, 1u); + EXPECT_DOUBLE_EQ(got.trigTime, 5.0); + ASSERT_EQ(got.series.size(), 1u); + EXPECT_EQ(got.series[0].t.size(), 3u); + + Capture c2; + c2.trigTime = 6.0; + s.publishCapture(std::move(c2)); + EXPECT_EQ(s.captureSeq(), 2u); +} + +// Not a proof of correctness, but it catches an unlocked member or a +// use-after-resize. Run the suite under -fsanitize=thread occasionally. +TEST(SignalStore, ConcurrentPushAndReadDoNotCrash) { + SignalStore s; + s.setSignals({scalarMeta("a")}); + s.setWindowSec(0.05); + std::atomic stop(false); + + std::thread writer([&] { + double t = 0.0; + while (!stop.load()) { + t = pushBlock(s, "a", t, 1.0e6, 1000); + s.maintain(); + } + }); + + Series out; + for (int i = 0; i < 2000; ++i) { + s.readLast("a", 4096, out); + double o = 0.0, nw = 0.0; + (void)s.span("a", o, nw); + (void)s.rate("a"); + } + stop.store(true); + writer.join(); + SUCCEED(); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `SignalStore.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +Create `Client/udpscope/SignalStore.h`: + +```cpp +/** + * @file SignalStore.h + * @brief The single mutex-guarded handoff between the receiver and GUI threads. + * + * Owns one ring per scalar signal, the latest snapshot per vector-profile + * signal, and the most recent completed capture. Every accessor takes the + * same lock; the reused StreamHubClient::SignalBuffer has none of its own + * despite what its comment claims. + */ +#ifndef UDPSCOPE_SIGNALSTORE_H +#define UDPSCOPE_SIGNALSTORE_H + +#include "SignalBuffer.h" +#include "Types.h" + +#include +#include +#include +#include +#include + +namespace udpscope { + +/** Latest snapshot of a true vector signal, plotted against element index. */ +struct Profile { + double time = 0.0; + std::vector x, v; +}; + +/** One completed trigger capture, copied out of the rings by the receiver. */ +struct Capture { + uint64_t seq = 0u; + double trigTime = 0.0; + double t0 = 0.0; + double t1 = 0.0; + std::vector names; + std::vector series; +}; + +class SignalStore { +public: + /** + * Ring length as a multiple of the trigger window. + * + * The window alone is not enough: the pre-window must be resident before + * the edge arrives, the post-window fills after it, the GUI harvests a + * repaint later (Trigger::kHarvestMarginSec), and the rate estimate that + * sizes the ring lags one step behind a rate change. Undersized rings are + * what truncated every capture in the Go hub. Do not reduce this without + * re-running the capture tests. + */ + static constexpr double kRingMargin = 4.0; + /** Floor, so a slow signal still shows a usable live trace. */ + static constexpr size_t kMinRingPoints = 4096u; + /** Per-signal ceiling: 4 M points is 64 MB of (t,v) pairs. */ + static constexpr size_t kMaxRingPoints = 4000000u; + /** Ceiling on the sum of all rings: 16 M points is 256 MB. */ + static constexpr size_t kTotalPointBudget = 16000000u; + + /** Replace the signal set. Clears all rings and bumps generation(). */ + void setSignals(const std::vector& metas); + std::vector signals() const; + /** Increments on every setSignals(); the GUI uses it to drop stale state. */ + uint64_t generation() const; + + /** Append samples. Unknown names are dropped, never auto-created. */ + void push(const std::string& name, const double* t, const double* v, size_t n); + void pushProfile(const std::string& name, double time, const double* v, size_t n); + + size_t readLast(const std::string& name, size_t n, Series& out) const; + size_t readRange(const std::string& name, double t0, double t1, Series& out) const; + bool readProfile(const std::string& name, Profile& out) const; + + /** @return false when the ring is empty. */ + bool span(const std::string& name, double& oldest, double& newest) const; + /** Smoothed samples-per-second, independent of the ring's current length. */ + double rate(const std::string& name) const; + size_t capacity(const std::string& name) const; + + void setWindowSec(double windowSec); + double windowSec() const; + /** Re-size rings whose target has drifted. Call once per receiver poll. */ + void maintain(); + + void publishCapture(Capture&& c); + uint64_t captureSeq() const; + bool readCapture(Capture& out) const; + +private: + struct Entry { + StreamHubClient::SignalBuffer buf{kMinRingPoints}; + double rate = 0.0; + double lastT = 0.0; + bool haveLast = false; + bool profile = false; + Profile snapshot; + }; + + /** Caller holds mu_. */ + size_t targetCapacity(const Entry& e, size_t ringCount) const; + + mutable std::mutex mu_; + std::vector metas_; + std::map entries_; + double windowSec_ = 0.1; + uint64_t generation_ = 0u; + Capture capture_; + uint64_t captureSeq_ = 0u; +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_SIGNALSTORE_H */ +``` + +- [ ] **Step 4: Write the implementation** + +Create `Client/udpscope/SignalStore.cpp`: + +```cpp +#include "SignalStore.h" + +#include +#include + +namespace udpscope { + +namespace { + +/** Smoothing factor for the per-signal rate estimate (one block per update). */ +const double kRateAlpha = 0.1; +/** Relative drift that justifies paying for a resize. */ +const double kResizeHysteresis = 0.25; + +double oldestOf(const StreamHubClient::SignalBuffer& b) { + const size_t idx = (b.head + b.capacity - b.count) % b.capacity; + return b.t[idx]; +} + +double newestOf(const StreamHubClient::SignalBuffer& b) { + const size_t idx = (b.head + b.capacity - 1u) % b.capacity; + return b.t[idx]; +} + +} /* namespace */ + +void SignalStore::setSignals(const std::vector& metas) { + std::lock_guard lk(mu_); + metas_ = metas; + entries_.clear(); + for (size_t i = 0u; i < metas_.size(); ++i) { + Entry e; + e.profile = metas_[i].isVectorProfile(); + entries_.emplace(metas_[i].name, std::move(e)); + } + ++generation_; +} + +std::vector SignalStore::signals() const { + std::lock_guard lk(mu_); + return metas_; +} + +uint64_t SignalStore::generation() const { + std::lock_guard lk(mu_); + return generation_; +} + +void SignalStore::push(const std::string& name, const double* t, const double* v, size_t n) { + if (n == 0u) { + return; + } + std::lock_guard lk(mu_); + std::map::iterator it = entries_.find(name); + if (it == entries_.end()) { + return; + } + Entry& e = it->second; + for (size_t i = 0u; i < n; ++i) { + e.buf.push(t[i], v[i]); + } + + /* Rate from this block's span, so it survives a ring resize. */ + const double last = t[n - 1u]; + if (e.haveLast) { + const double dt = last - e.lastT; + if (dt > 0.0) { + const double inst = static_cast(n) / dt; + e.rate = (e.rate > 0.0) ? (1.0 - kRateAlpha) * e.rate + kRateAlpha * inst + : inst; + } + } + e.lastT = last; + e.haveLast = true; +} + +void SignalStore::pushProfile(const std::string& name, double time, + const double* v, size_t n) { + std::lock_guard lk(mu_); + std::map::iterator it = entries_.find(name); + if (it == entries_.end()) { + return; + } + Profile& p = it->second.snapshot; + p.time = time; + p.x.resize(n); + p.v.resize(n); + for (size_t i = 0u; i < n; ++i) { + p.x[i] = static_cast(i); + p.v[i] = v[i]; + } +} + +size_t SignalStore::readLast(const std::string& name, size_t n, Series& out) const { + std::lock_guard lk(mu_); + std::map::const_iterator it = entries_.find(name); + if (it == entries_.end()) { + out.clear(); + return 0u; + } + return it->second.buf.readLast(n, out.t, out.v); +} + +size_t SignalStore::readRange(const std::string& name, double t0, double t1, + Series& out) const { + std::lock_guard lk(mu_); + std::map::const_iterator it = entries_.find(name); + if (it == entries_.end()) { + out.clear(); + return 0u; + } + return it->second.buf.readRange(t0, t1, out.t, out.v); +} + +bool SignalStore::readProfile(const std::string& name, Profile& out) const { + std::lock_guard lk(mu_); + std::map::const_iterator it = entries_.find(name); + if (it == entries_.end() || it->second.snapshot.v.empty()) { + return false; + } + out = it->second.snapshot; + return true; +} + +bool SignalStore::span(const std::string& name, double& oldest, double& newest) const { + std::lock_guard lk(mu_); + std::map::const_iterator it = entries_.find(name); + if (it == entries_.end() || it->second.buf.count == 0u) { + return false; + } + oldest = oldestOf(it->second.buf); + newest = newestOf(it->second.buf); + return true; +} + +double SignalStore::rate(const std::string& name) const { + std::lock_guard lk(mu_); + std::map::const_iterator it = entries_.find(name); + return (it == entries_.end()) ? 0.0 : it->second.rate; +} + +size_t SignalStore::capacity(const std::string& name) const { + std::lock_guard lk(mu_); + std::map::const_iterator it = entries_.find(name); + return (it == entries_.end()) ? 0u : it->second.buf.capacity; +} + +void SignalStore::setWindowSec(double windowSec) { + std::lock_guard lk(mu_); + if (windowSec > 0.0) { + windowSec_ = windowSec; + } +} + +double SignalStore::windowSec() const { + std::lock_guard lk(mu_); + return windowSec_; +} + +size_t SignalStore::targetCapacity(const Entry& e, size_t ringCount) const { + if (e.rate <= 0.0) { + return kMinRingPoints; + } + const double want = e.rate * windowSec_ * kRingMargin; + size_t cap = (want >= static_cast(kMaxRingPoints)) + ? kMaxRingPoints + : static_cast(want); + const size_t share = kTotalPointBudget / std::max(1u, ringCount); + cap = std::min(cap, share); + cap = std::min(cap, kMaxRingPoints); + return std::max(cap, kMinRingPoints); +} + +void SignalStore::maintain() { + std::lock_guard lk(mu_); + const size_t ringCount = entries_.size(); + std::vector t, v; + for (std::map::iterator it = entries_.begin(); + it != entries_.end(); ++it) { + Entry& e = it->second; + if (e.profile) { + continue; + } + const size_t want = targetCapacity(e, ringCount); + const double cur = static_cast(e.buf.capacity); + if (std::fabs(static_cast(want) - cur) <= kResizeHysteresis * cur) { + continue; + } + /* setCapacity() clears, so read the survivors out and push them back. */ + const size_t keep = std::min(want, e.buf.count); + e.buf.readLast(keep, t, v); + e.buf.setCapacity(want); + for (size_t i = 0u; i < t.size(); ++i) { + e.buf.push(t[i], v[i]); + } + } +} + +void SignalStore::publishCapture(Capture&& c) { + std::lock_guard lk(mu_); + ++captureSeq_; + capture_ = std::move(c); + capture_.seq = captureSeq_; +} + +uint64_t SignalStore::captureSeq() const { + std::lock_guard lk(mu_); + return captureSeq_; +} + +bool SignalStore::readCapture(Capture& out) const { + std::lock_guard lk(mu_); + if (captureSeq_ == 0u) { + return false; + } + out = capture_; + return true; +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 5: Register the source with CMake** + +In `Client/udpscope/CMakeLists.txt`, extend `CORE_SOURCES`: + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp + TimeBase.cpp + FrameDecoder.cpp + Trigger.cpp + SignalStore.cpp +) +``` + +The tests now use threads, so link them: + +```cmake +find_package(Threads REQUIRED) +target_link_libraries(udpscope_core PUBLIC Threads::Threads) +``` + +- [ ] **Step 6: Run the tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='SignalStore*' +``` + +Expected: PASS, 15 tests. + +If `MaintainSharesTheGlobalBudgetBetweenSignals` reports an unused budget, +check that `targetCapacity()` divides by the number of *rings*, not the +number of assigned signals. + +- [ ] **Step 7: Commit** + +```bash +git add Client/udpscope/SignalStore.h Client/udpscope/SignalStore.cpp \ + Client/udpscope/tests/SignalStoreTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): mutex-guarded signal store with window-driven ring sizing" +``` + +--- + +### Task 7: Receiver — frame path (no thread, no socket) + +The receiver's decision-making is factored out of the C callback into plain +methods so it can be driven from tests with synthetic frames. This task +writes those methods and their tests; Task 8 bolts the thread and the C +client onto the same class. + +**Files:** +- Create: `Client/udpscope/Receiver.h` +- Create: `Client/udpscope/Receiver.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` (add `Receiver.cpp` to `CORE_SOURCES`) +- Test: `Client/udpscope/tests/ReceiverTest.cpp` + +**Interfaces:** +- Consumes: `SignalStore`, `Capture` (Task 6); `Trigger`, `TrigConfig`, + `TrigState`, `TrigMode` (Task 5); `FrameDecoder` (Task 4); `SignalMeta`, + `FrameView`, `Series` (Tasks 1/4). +- Produces: + +```cpp +struct TrigStatus { + TrigState state = TrigState::Idle; + double fill = 0.0; + double trigTime = 0.0; + uint64_t captures = 0u; +}; + +class Receiver { +public: + explicit Receiver(SignalStore& store); + void handleConfig(const std::vector& metas); + void handleFrame(const FrameView& f); + void setTrigConfig(const TrigConfig& c); + TrigConfig trigConfig() const; + void arm(); + void disarm(); + void rearm(); + TrigStatus trigStatus() const; +}; +``` + +**Threading contract, stated once and relied on everywhere below:** +`handleConfig`/`handleFrame` run only on the receiver thread. The GUI calls +only `setTrigConfig`/`arm`/`disarm`/`rearm`/`trigConfig`/`trigStatus`, which +take `ctlMu_` and never touch the `Trigger` directly — commands are queued +and applied at the top of the next frame. `ctlMu_` is never held across a +`SignalStore` call, so the two locks cannot deadlock. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/ReceiverTest.cpp`: + +```cpp +#include "Receiver.h" +#include +#include +#include +#include + +using namespace udpscope; + +namespace { + +// Owns the value arrays for one synthetic frame and hands out a FrameView +// pointing into them. Keep the Synth alive for as long as the view is used. +struct Synth { + std::vector > vals; + std::vector ptrs; + std::vector counts; + + void add(const std::vector& v) { vals.push_back(v); } + + FrameView view(uint32_t counter, uint64_t hrt, double recvTime, + uint32_t numSamples) { + ptrs.clear(); + counts.clear(); + for (size_t i = 0; i < vals.size(); ++i) { + ptrs.push_back(vals[i].data()); + counts.push_back(static_cast(vals[i].size())); + } + FrameView f; + f.counter = counter; + f.hrt = hrt; + f.recvTime = recvTime; + f.numSamples = numSamples; + f.numSignals = static_cast(vals.size()); + f.values = ptrs.data(); + f.counts = counts.data(); + return f; + } +}; + +SignalMeta scalar(const std::string& name) { + SignalMeta m; + m.name = name; + m.typeCode = 8; /* float32 */ + m.numRows = 1; + m.numCols = 1; + m.timeMode = kTimePacket; + return m; +} + +// A uint64 nanosecond time signal, as TimeArrayGAM emits. +SignalMeta timeSignal(const std::string& name, uint32_t nElems) { + SignalMeta m; + m.name = name; + m.typeCode = 6; /* uint64 -> 1e-9 scale */ + m.numRows = 1; + m.numCols = nElems; + m.timeMode = kTimeFullArray; + return m; +} + +// A burst signal anchored on its first sample, paired with time signal 0. +SignalMeta burst(const std::string& name, uint32_t nElems, double rate) { + SignalMeta m; + m.name = name; + m.typeCode = 8; + m.numRows = 1; + m.numCols = nElems; + m.timeMode = kTimeFirstSample; + m.samplingRate = rate; + m.timeSignalIdx = 0u; + return m; +} + +} // namespace + +TEST(Receiver, ConfigCreatesTheStoreSignals) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({scalar("a"), scalar("b")}); + EXPECT_EQ(store.signals().size(), 2u); + EXPECT_EQ(store.capacity("a"), SignalStore::kMinRingPoints); +} + +TEST(Receiver, ScalarFramesLandInTheRingAtArrivalTime) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({scalar("a")}); + + for (int i = 0; i < 3; ++i) { + Synth s; + s.add({static_cast(i)}); + FrameView f = s.view(static_cast(i), 0u, 1.0 + 0.01 * i, 1u); + rx.handleFrame(f); + } + + Series out; + ASSERT_EQ(store.readLast("a", 10, out), 3u); + EXPECT_NEAR(out.t[0], 1.00, 1e-9); + EXPECT_NEAR(out.t[2], 1.02, 1e-9); + EXPECT_DOUBLE_EQ(out.v[2], 2.0); +} + +// A burst arrives as one packet but must be unrolled onto the time axis, or +// the scope draws a staircase at the packet rate instead of the waveform. +TEST(Receiver, BurstsAreUnrolledOntoTheTimeAxis) { + SignalStore store; + Receiver rx(store); + const uint32_t N = 8u; + rx.handleConfig({timeSignal("t", N), burst("a", N, 10000.0)}); + + Synth s; + std::vector ts(N), vs(N); + for (uint32_t e = 0; e < N; ++e) { + ts[e] = 1.0e9 + static_cast(e) * 1.0e5; /* 1 s + e * 0.1 ms, in ns */ + vs[e] = static_cast(e); + } + s.add(ts); + s.add(vs); + FrameView f = s.view(0u, 0u, 50.0, N); + rx.handleFrame(f); + + Series out; + ASSERT_EQ(store.readLast("a", 100, out), N); + // Rule 2 anchors on the time signal and spreads by 1/10 kHz = 0.1 ms. + for (uint32_t e = 1; e < N; ++e) { + EXPECT_NEAR(out.t[e] - out.t[e - 1u], 1.0e-4, 1e-9) << "element " << e; + } + EXPECT_DOUBLE_EQ(out.v[N - 1u], static_cast(N - 1u)); +} + +TEST(Receiver, VectorProfilesGoToTheSnapshotSlotNotTheRing) { + SignalStore store; + Receiver rx(store); + SignalMeta m = scalar("vec"); + m.numCols = 4u; + m.timeMode = kTimePacket; + m.profileOverride = true; + ASSERT_TRUE(m.isVectorProfile()); + rx.handleConfig({m}); + + Synth s; + s.add({1.0, 2.0, 3.0, 4.0}); + FrameView f = s.view(0u, 0u, 7.0, 1u); + rx.handleFrame(f); + + Series ring; + EXPECT_EQ(store.readLast("vec", 10, ring), 0u) << "profile leaked into the ring"; + Profile p; + ASSERT_TRUE(store.readProfile("vec", p)); + EXPECT_DOUBLE_EQ(p.time, 7.0); + ASSERT_EQ(p.v.size(), 4u); + EXPECT_DOUBLE_EQ(p.v[3], 4.0); +} + +TEST(Receiver, TriggerCommandsTakeEffectOnTheNextFrame) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({scalar("a")}); + + TrigConfig c; + c.signalName = "a"; + c.threshold = 0.5; + c.windowSec = 0.02; + c.prePercent = 25.0; + rx.setTrigConfig(c); + EXPECT_EQ(rx.trigStatus().state, TrigState::Idle); + + rx.arm(); + EXPECT_EQ(rx.trigStatus().state, TrigState::Idle) << "applied without a frame"; + + Synth s; + s.add({0.0}); + FrameView f = s.view(0u, 0u, 1.0, 1u); + rx.handleFrame(f); + EXPECT_EQ(rx.trigStatus().state, TrigState::Armed); + + EXPECT_DOUBLE_EQ(store.windowSec(), 0.02) << "window not forwarded to the store"; +} + +// The whole trigger path, end to end: a step on the trigger signal produces a +// capture holding every scalar signal over the configured window. +TEST(Receiver, AReadyCaptureIsPublishedWithEverySignal) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({scalar("a"), scalar("b")}); + + TrigConfig c; + c.signalName = "a"; + c.edge = Edge::Rising; + c.threshold = 0.5; + c.windowSec = 0.020; /* pre 0.005, post 0.015 */ + c.prePercent = 25.0; + c.mode = TrigMode::Normal; + rx.setTrigConfig(c); + rx.arm(); + + // 1 kHz of scalar frames; "a" steps high at t = 1.100. + double t = 1.000; + for (int i = 0; i < 300; ++i, t += 0.001) { + Synth s; + s.add({(t >= 1.100) ? 1.0 : 0.0}); + s.add({static_cast(i)}); + FrameView f = s.view(static_cast(i), 0u, t, 1u); + rx.handleFrame(f); + } + + EXPECT_EQ(rx.trigStatus().captures, 1u); + Capture cap; + ASSERT_TRUE(store.readCapture(cap)); + EXPECT_NEAR(cap.trigTime, 1.0995, 1e-6); + ASSERT_EQ(cap.names.size(), 2u); + ASSERT_EQ(cap.series.size(), 2u); + EXPECT_GT(cap.series[0].t.size(), 10u); + EXPECT_LE(cap.series[0].t.front(), cap.trigTime); + EXPECT_GE(cap.series[0].t.back(), cap.trigTime); + EXPECT_NEAR(cap.t0, cap.trigTime - 0.005, 1e-9); + EXPECT_NEAR(cap.t1, cap.trigTime + 0.015, 1e-9); +} + +TEST(Receiver, NormalModeKeepsCapturingAndSingleModeStops) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({scalar("a")}); + + TrigConfig c; + c.signalName = "a"; + c.threshold = 0.5; + c.windowSec = 0.020; + c.prePercent = 25.0; + c.mode = TrigMode::Normal; + rx.setTrigConfig(c); + rx.arm(); + + // Square wave: 50 ms high, 50 ms low, sampled at 1 kHz for 1 s. + double t = 1.000; + for (int i = 0; i < 1000; ++i, t += 0.001) { + Synth s; + s.add({((i / 50) % 2 == 0) ? 0.0 : 1.0}); + FrameView f = s.view(static_cast(i), 0u, t, 1u); + rx.handleFrame(f); + } + const uint64_t normalCaptures = rx.trigStatus().captures; + EXPECT_GE(normalCaptures, 5u) << "normal mode stopped re-arming"; + EXPECT_EQ(rx.trigStatus().state, TrigState::Armed); + + c.mode = TrigMode::Single; + rx.setTrigConfig(c); /* resets the FSM to Idle */ + rx.arm(); + for (int i = 0; i < 1000; ++i, t += 0.001) { + Synth s; + s.add({((i / 50) % 2 == 0) ? 0.0 : 1.0}); + FrameView f = s.view(static_cast(i), 0u, t, 1u); + rx.handleFrame(f); + } + EXPECT_EQ(rx.trigStatus().state, TrigState::Held); + EXPECT_EQ(rx.trigStatus().captures, normalCaptures + 1u); +} + +// A streamer restart re-sends CONFIG. Old samples are on the old time base and +// old signal set, so they must not survive. +TEST(Receiver, AMidStreamReconfigureDropsTheOldSamples) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({scalar("a")}); + Synth s; + s.add({1.0}); + FrameView f = s.view(0u, 0u, 1.0, 1u); + rx.handleFrame(f); + Series out; + ASSERT_EQ(store.readLast("a", 10, out), 1u); + + rx.handleConfig({scalar("a"), scalar("b")}); + EXPECT_EQ(store.readLast("a", 10, out), 0u); + EXPECT_EQ(store.signals().size(), 2u); + EXPECT_EQ(rx.trigStatus().state, TrigState::Idle) << "trigger kept across CONFIG"; +} + +// packetBurst() cannot stamp the first packet of a stream — there is no +// previous arrival to span from. Those samples must be dropped, not stored at +// invented times. +TEST(Receiver, UnstampableSamplesAreDroppedRatherThanInvented) { + SignalStore store; + Receiver rx(store); + SignalMeta m = scalar("a"); + m.numCols = 4u; /* PACKET burst, no time signal, no rate */ + rx.handleConfig({m}); + + Synth s1; + s1.add({1.0, 2.0, 3.0, 4.0}); + FrameView f1 = s1.view(0u, 0u, 1.0, 4u); + rx.handleFrame(f1); + Series out; + EXPECT_EQ(store.readLast("a", 10, out), 0u) << "first packet stamped anyway"; + + Synth s2; + s2.add({5.0, 6.0, 7.0, 8.0}); + FrameView f2 = s2.view(1u, 0u, 1.01, 4u); + rx.handleFrame(f2); + ASSERT_EQ(store.readLast("a", 10, out), 4u); + EXPECT_GT(out.t.front(), 1.0); + EXPECT_NEAR(out.t.back(), 1.01, 1e-9); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `Receiver.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +Create `Client/udpscope/Receiver.h`: + +```cpp +/** + * @file Receiver.h + * @brief Frame-path logic for the UDPS receiver. + * + * handleConfig()/handleFrame() run on the receiver thread only. The GUI thread + * uses the trigger control methods, which queue their effect under ctlMu_ and + * are applied at the top of the next frame. ctlMu_ is never held across a + * SignalStore call. + */ +#ifndef UDPSCOPE_RECEIVER_H +#define UDPSCOPE_RECEIVER_H + +#include "FrameDecoder.h" +#include "SignalStore.h" +#include "Trigger.h" +#include "Types.h" + +#include +#include +#include +#include + +namespace udpscope { + +/** Snapshot of the trigger FSM, published for the GUI once per frame. */ +struct TrigStatus { + TrigState state = TrigState::Idle; + double fill = 0.0; + double trigTime = 0.0; + uint64_t captures = 0u; +}; + +class Receiver { +public: + explicit Receiver(SignalStore& store) : store_(store) {} + + /* --- receiver thread ------------------------------------------------ */ + + /** Apply a new signal set: resets the decoder, store and trigger. */ + void handleConfig(const std::vector& metas); + /** Decode one frame into the store, feed the trigger, harvest a capture. */ + void handleFrame(const FrameView& f); + + /* --- GUI thread ----------------------------------------------------- */ + + /** Also forwards windowSec to the store so the rings are resized. */ + void setTrigConfig(const TrigConfig& c); + TrigConfig trigConfig() const; + void arm(); + void disarm(); + void rearm(); + TrigStatus trigStatus() const; + +private: + enum class Cmd { None, Arm, Disarm, Rearm }; + + void applyPending(); + void harvestCapture(); + void publishStatus(); + + SignalStore& store_; + FrameDecoder decoder_; + Trigger trig_; + std::vector ts_; /**< scratch, receiver thread only */ + + mutable std::mutex ctlMu_; + TrigConfig cfg_; + bool cfgDirty_ = false; + Cmd cmd_ = Cmd::None; + TrigStatus status_; +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_RECEIVER_H */ +``` + +- [ ] **Step 4: Write the implementation** + +Create `Client/udpscope/Receiver.cpp`: + +```cpp +#include "Receiver.h" + +namespace udpscope { + +void Receiver::handleConfig(const std::vector& metas) { + decoder_.reset(); + decoder_.setSignals(metas); + store_.setSignals(metas); + /* Samples from before the reconfigure are on a different time base and a + different signal set; a capture spanning both would be nonsense. */ + trig_.disarm(); + publishStatus(); +} + +void Receiver::applyPending() { + TrigConfig cfg; + bool dirty = false; + Cmd cmd = Cmd::None; + { + std::lock_guard lk(ctlMu_); + dirty = cfgDirty_; + cfg = cfg_; + cfgDirty_ = false; + cmd = cmd_; + cmd_ = Cmd::None; + } + if (dirty) { + trig_.setConfig(cfg); + } + switch (cmd) { + case Cmd::Arm: trig_.arm(); break; + case Cmd::Disarm: trig_.disarm(); break; + case Cmd::Rearm: trig_.rearm(); break; + case Cmd::None: break; + } +} + +void Receiver::handleFrame(const FrameView& f) { + applyPending(); + decoder_.beginFrame(f); + + const std::vector& metas = decoder_.signals(); + const std::string trigName = trig_.config().signalName; + + for (uint32_t i = 0u; i < metas.size() && i < f.numSignals; i++) { + const SignalMeta& m = metas[i]; + const uint32_t count = (f.counts != nullptr) ? f.counts[i] : 0u; + const double* vals = (f.values != nullptr) ? f.values[i] : nullptr; + if (count == 0u || vals == nullptr) { + continue; + } + + if (m.isVectorProfile()) { + /* Plot against element index; only the newest snapshot matters. */ + const uint32_t n = m.numElements(); + const uint32_t take = (count >= n) ? n : count; + store_.pushProfile(m.name, f.recvTime, vals + (count - take), take); + continue; + } + + if (!decoder_.timestamps(f, i, ts_) || ts_.size() != count) { + continue; /* unstampable: drop rather than invent times */ + } + store_.push(m.name, ts_.data(), vals, count); + + if (!trigName.empty() && m.name == trigName) { + double oldest = ts_[0]; + double newest = 0.0; + (void) store_.span(m.name, oldest, newest); + trig_.feed(ts_.data(), vals, count, oldest); + } + } + + if (trig_.captureReady()) { + harvestCapture(); + } + publishStatus(); +} + +void Receiver::harvestCapture() { + const TrigConfig& c = trig_.config(); + Capture cap; + cap.trigTime = trig_.trigTime(); + cap.t0 = cap.trigTime - c.preSec(); + cap.t1 = cap.trigTime + c.postSec(); + + const std::vector& metas = decoder_.signals(); + for (size_t i = 0u; i < metas.size(); i++) { + if (metas[i].isVectorProfile()) { + continue; + } + Series s; + store_.readRange(metas[i].name, cap.t0, cap.t1, s); + cap.names.push_back(metas[i].name); + cap.series.push_back(s); + } + store_.publishCapture(std::move(cap)); + trig_.captureTaken(); +} + +void Receiver::publishStatus() { + std::lock_guard lk(ctlMu_); + status_.state = trig_.state(); + status_.fill = trig_.fillFraction(); + status_.trigTime = trig_.trigTime(); + status_.captures = store_.captureSeq(); +} + +void Receiver::setTrigConfig(const TrigConfig& c) { + { + std::lock_guard lk(ctlMu_); + cfg_ = c; + cfgDirty_ = true; + /* The FSM will reset on apply; reflect that immediately so the GUI does + not show a stale Armed badge for a frame. */ + status_.state = TrigState::Idle; + status_.fill = 0.0; + } + /* Outside the lock: the store has its own mutex. */ + store_.setWindowSec(c.windowSec); +} + +TrigConfig Receiver::trigConfig() const { + std::lock_guard lk(ctlMu_); + return cfg_; +} + +void Receiver::arm() { + std::lock_guard lk(ctlMu_); + cmd_ = Cmd::Arm; +} + +void Receiver::disarm() { + std::lock_guard lk(ctlMu_); + cmd_ = Cmd::Disarm; +} + +void Receiver::rearm() { + std::lock_guard lk(ctlMu_); + cmd_ = Cmd::Rearm; +} + +TrigStatus Receiver::trigStatus() const { + std::lock_guard lk(ctlMu_); + return status_; +} + +} /* namespace udpscope */ +``` + +`publishStatus()` reads `store_.captureSeq()` while holding `ctlMu_`. That is +the one place where the store lock is taken under `ctlMu_`; it is safe only +because no `SignalStore` method ever calls back into `Receiver`. Keep it that +way. + +- [ ] **Step 5: Register the source with CMake** + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp + TimeBase.cpp + FrameDecoder.cpp + Trigger.cpp + SignalStore.cpp + Receiver.cpp +) +``` + +- [ ] **Step 6: Run the tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Receiver*' +``` + +Expected: PASS, 9 tests. + +If `AReadyCaptureIsPublishedWithEverySignal` reports zero captures, check +that the fill gate is being fed the ring's oldest time and not the frame's +first timestamp — the trigger cannot fire until `preSec` of history exists. + +- [ ] **Step 7: Run the whole suite** + +```bash +cd Client/udpscope && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–7. + +- [ ] **Step 8: Commit** + +```bash +git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \ + Client/udpscope/tests/ReceiverTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): receiver frame path with trigger control and capture harvest" +``` + +--- + +### Task 8: Receiver — thread, C client wiring and profile overrides + +Bolt the socket and the thread onto `Receiver`, translate the C structs into +the framework-free ones, and add the per-signal "this is a vector, not a +burst" override the spec requires for ambiguous `PACKET` signals. The task +ships `udpscope_rxprobe`, a console tool that runs the receiver against a +real streamer and prints the link counters — that is how the socket path is +verified, and it stays in the tree as a diagnostic. + +**Files:** +- Modify: `Client/udpscope/Receiver.h` (thread, options, link status, overrides) +- Modify: `Client/udpscope/Receiver.cpp` (same) +- Create: `Client/udpscope/tools/rxprobe.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` (add the `udpscope_rxprobe` target) +- Test: `Client/udpscope/tests/ReceiverLinkTest.cpp` + +**Interfaces:** +- Consumes: everything from Task 7, plus the C API from + `Common/Client/c/udps_client.h` (`udps_client_config_init`, + `udps_client_create`, `udps_client_set_callbacks`, `udps_client_poll`, + `udps_client_destroy`, `udps_client_is_connected`, `udps_client_stats`, + `udps_client_last_error`). +- Produces: + +```cpp +struct ReceiverOptions { + std::string host = "127.0.0.1"; + uint16_t port = 44500u; + std::string multicastGroup; // empty = unicast + std::string interfaceAddr; + uint16_t dataPort = 0u; + double silenceTimeoutSec = 2.0; +}; + +struct LinkStatus { + bool running = false, connected = false, haveConfig = false; + uint64_t packets = 0u, frames = 0u, configUpdates = 0u; + uint64_t counterGaps = 0u, fragmentsDropped = 0u, reconnects = 0u; + double lastFrameWall = 0.0; + std::string lastEvent; +}; + +// added to Receiver: +bool start(const ReceiverOptions& opt, std::string& err); +void stop(); +bool running() const; +LinkStatus link() const; +void setProfileOverride(const std::string& signalName, bool isProfile); +bool profileOverride(const std::string& signalName) const; +``` + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/ReceiverLinkTest.cpp`: + +```cpp +#include "Receiver.h" +#include +#include +#include + +using namespace udpscope; + +namespace { + +// A 4-element PACKET signal with no time signal and no declared rate: the one +// case the protocol leaves ambiguous between a time burst and a true vector. +SignalMeta ambiguous(const std::string& name) { + SignalMeta m; + m.name = name; + m.typeCode = 8; + m.numRows = 1; + m.numCols = 4; + m.timeMode = kTimePacket; + return m; +} + +struct Synth { + std::vector > vals; + std::vector ptrs; + std::vector counts; + FrameView view(double recvTime) { + ptrs.clear(); + counts.clear(); + for (size_t i = 0; i < vals.size(); ++i) { + ptrs.push_back(vals[i].data()); + counts.push_back(static_cast(vals[i].size())); + } + FrameView f; + f.recvTime = recvTime; + f.numSamples = 4u; + f.numSignals = static_cast(vals.size()); + f.values = ptrs.data(); + f.counts = counts.data(); + return f; + } +}; + +} // namespace + +TEST(ReceiverLink, DefaultsToTreatingAnAmbiguousSignalAsABurst) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({ambiguous("a")}); + EXPECT_FALSE(store.signals()[0].isVectorProfile()); + EXPECT_FALSE(rx.profileOverride("a")); +} + +TEST(ReceiverLink, ProfileOverrideIsAppliedToTheNextFrame) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({ambiguous("a")}); + rx.setProfileOverride("a", true); + EXPECT_TRUE(rx.profileOverride("a")); + + Synth s; + s.vals.push_back({1.0, 2.0, 3.0, 4.0}); + FrameView f1 = s.view(1.0); + rx.handleFrame(f1); /* applies the pending override, reconfigures */ + + Synth s2; + s2.vals.push_back({5.0, 6.0, 7.0, 8.0}); + FrameView f2 = s2.view(1.01); + rx.handleFrame(f2); + + ASSERT_EQ(store.signals().size(), 1u); + EXPECT_TRUE(store.signals()[0].isVectorProfile()); + Profile p; + ASSERT_TRUE(store.readProfile("a", p)); + EXPECT_DOUBLE_EQ(p.v[0], 5.0); + Series ring; + EXPECT_EQ(store.readLast("a", 10, ring), 0u) << "still filling the ring"; +} + +// The streamer re-sends CONFIG on reconnect. The user's override is a UI +// choice and must outlive that. +TEST(ReceiverLink, ProfileOverrideSurvivesAReconfigure) { + SignalStore store; + Receiver rx(store); + rx.handleConfig({ambiguous("a")}); + rx.setProfileOverride("a", true); + Synth s; + s.vals.push_back({1.0, 2.0, 3.0, 4.0}); + FrameView f = s.view(1.0); + rx.handleFrame(f); + ASSERT_TRUE(store.signals()[0].isVectorProfile()); + + rx.handleConfig({ambiguous("a"), ambiguous("b")}); + ASSERT_EQ(store.signals().size(), 2u); + EXPECT_TRUE(store.signals()[0].isVectorProfile()) << "override lost on CONFIG"; + EXPECT_FALSE(store.signals()[1].isVectorProfile()); +} + +TEST(ReceiverLink, StartsAndStopsCleanlyWithNoServerPresent) { + SignalStore store; + Receiver rx(store); + ReceiverOptions opt; + opt.host = "127.0.0.1"; + opt.port = 45999; /* nothing is listening here */ + std::string err; + ASSERT_TRUE(rx.start(opt, err)) << err; + EXPECT_TRUE(rx.running()); + EXPECT_TRUE(rx.link().running); + rx.stop(); + EXPECT_FALSE(rx.running()); + EXPECT_EQ(store.captureSeq(), 0u); +} + +TEST(ReceiverLink, StartIsRejectedWhileAlreadyRunning) { + SignalStore store; + Receiver rx(store); + ReceiverOptions opt; + opt.port = 45999; + std::string err; + ASSERT_TRUE(rx.start(opt, err)) << err; + EXPECT_FALSE(rx.start(opt, err)); + EXPECT_FALSE(err.empty()); + rx.stop(); +} + +TEST(ReceiverLink, StopIsSafeWhenNeverStarted) { + SignalStore store; + Receiver rx(store); + rx.stop(); + EXPECT_FALSE(rx.running()); + SUCCEED(); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `no member named 'start' in 'udpscope::Receiver'`. + +- [ ] **Step 3: Extend the header** + +In `Client/udpscope/Receiver.h`, add above the class: + +```cpp +#include +#include +#include + +struct udps_client; /* opaque, see Common/Client/c/udps_client.h */ +``` + +```cpp +/** Where to attach. Mirrors the fields of udps_client_config_t we expose. */ +struct ReceiverOptions { + std::string host = "127.0.0.1"; + uint16_t port = 44500u; + std::string multicastGroup; /**< empty = unicast */ + std::string interfaceAddr; /**< local interface IP, not a name */ + uint16_t dataPort = 0u; /**< 0 = server-chosen */ + double silenceTimeoutSec = 2.0; +}; + +/** Link health, published once per poll for the status bar. */ +struct LinkStatus { + bool running = false; + bool connected = false; + bool haveConfig = false; + uint64_t packets = 0u; + uint64_t frames = 0u; + uint64_t configUpdates = 0u; + uint64_t counterGaps = 0u; + uint64_t fragmentsDropped = 0u; + uint64_t reconnects = 0u; + double lastFrameWall = 0.0; + std::string lastEvent; +}; +``` + +Add to the public section of `Receiver`: + +```cpp + ~Receiver(); + + /** Spawns the receiver thread. @return false and fills err on failure. */ + bool start(const ReceiverOptions& opt, std::string& err); + /** Joins the thread. Safe to call when not running. */ + void stop(); + bool running() const { return running_.load(); } + LinkStatus link() const; + + /** + * Force a PACKET signal with more than one element to be read as a vector + * profile (index axis) rather than a time burst. Applied on the next frame + * and remembered across reconfigures. + */ + void setProfileOverride(const std::string& signalName, bool isProfile); + bool profileOverride(const std::string& signalName) const; +``` + +Add to the private section: + +```cpp + static void onConfigC(const udps_signal_t* sigs, uint32_t n, + uint8_t publishMode, void* user); + static void onDataC(const udps_frame_t* frame, void* user); + static void onEventC(udps_event_t ev, const char* detail, void* user); + + void threadMain(); + void updateLink(); + std::vector withOverrides(const std::vector& in) const; + + udps_client_t* client_ = nullptr; + std::thread thread_; + std::atomic running_{false}; + ReceiverOptions opt_; + std::string hostStr_, groupStr_, ifaceStr_; /**< own the C strings */ + + std::vector wireMetas_; /**< last CONFIG, before overrides */ + std::vector valPtrs_; /**< per-frame scratch */ + std::vector valCounts_; + + mutable std::mutex linkMu_; + LinkStatus link_; +``` + +`ctlMu_` also gains: + +```cpp + std::map overrides_; + bool overridesDirty_ = false; +``` + +The `#include ` and `` already present cover the rest. +`Receiver.cpp` must additionally `#include "udps_client.h"`, which resolves +through the `udpsclient` target's PUBLIC include directory (`${CCLIENT_DIR}`, +set up in Task 1). + +- [ ] **Step 4: Rewrite `handleConfig` and `applyPending` for overrides** + +Replace those two functions in `Client/udpscope/Receiver.cpp`: + +```cpp +std::vector Receiver::withOverrides( + const std::vector& in) const { + std::lock_guard lk(ctlMu_); + std::vector out = in; + for (size_t i = 0u; i < out.size(); i++) { + std::map::const_iterator it = overrides_.find(out[i].name); + out[i].profileOverride = (it != overrides_.end()) && it->second; + } + return out; +} + +void Receiver::handleConfig(const std::vector& metas) { + wireMetas_ = metas; + const std::vector effective = withOverrides(metas); + decoder_.reset(); + decoder_.setSignals(effective); + store_.setSignals(effective); + /* Samples from before the reconfigure are on a different time base and a + different signal set; a capture spanning both would be nonsense. */ + trig_.disarm(); + publishStatus(); +} + +void Receiver::applyPending() { + TrigConfig cfg; + bool dirty = false, ovDirty = false; + Cmd cmd = Cmd::None; + { + std::lock_guard lk(ctlMu_); + dirty = cfgDirty_; + cfg = cfg_; + cfgDirty_ = false; + cmd = cmd_; + cmd_ = Cmd::None; + ovDirty = overridesDirty_; + overridesDirty_ = false; + } + /* Overrides first: re-reading CONFIG resets the FSM, so an arm command in + the same batch must be applied after it, not before. */ + if (ovDirty && !wireMetas_.empty()) { + handleConfig(wireMetas_); + } + if (dirty) { + trig_.setConfig(cfg); + } + switch (cmd) { + case Cmd::Arm: trig_.arm(); break; + case Cmd::Disarm: trig_.disarm(); break; + case Cmd::Rearm: trig_.rearm(); break; + case Cmd::None: break; + } +} + +void Receiver::setProfileOverride(const std::string& signalName, bool isProfile) { + std::lock_guard lk(ctlMu_); + overrides_[signalName] = isProfile; + overridesDirty_ = true; +} + +bool Receiver::profileOverride(const std::string& signalName) const { + std::lock_guard lk(ctlMu_); + std::map::const_iterator it = overrides_.find(signalName); + return (it != overrides_.end()) && it->second; +} +``` + +`withOverrides()` takes `ctlMu_` and is called from `handleConfig()`, which is +called from `applyPending()` *after* it has released `ctlMu_`. Do not move the +call inside the lock scope. + +- [ ] **Step 5: Add the thread and the C callbacks** + +Append to `Client/udpscope/Receiver.cpp` (and add +`#include "udps_client.h"`, `#include ` at the top): + +```cpp +Receiver::~Receiver() { + stop(); +} + +void Receiver::onConfigC(const udps_signal_t* sigs, uint32_t n, + uint8_t publishMode, void* user) { + (void) publishMode; + Receiver* self = static_cast(user); + std::vector metas; + metas.reserve(n); + for (uint32_t i = 0u; i < n; i++) { + SignalMeta m; + m.name = sigs[i].name; + m.typeCode = sigs[i].type_code; + m.quantType = sigs[i].quant_type; + m.numRows = (sigs[i].num_rows > 0u) ? sigs[i].num_rows : 1u; + m.numCols = (sigs[i].num_cols > 0u) ? sigs[i].num_cols : 1u; + m.rangeMin = sigs[i].range_min; + m.rangeMax = sigs[i].range_max; + m.timeMode = sigs[i].time_mode; + m.samplingRate = sigs[i].sampling_rate; + m.timeSignalIdx = sigs[i].time_signal_idx; + m.unit = sigs[i].unit; + metas.push_back(m); + } + self->handleConfig(metas); + { + std::lock_guard lk(self->linkMu_); + self->link_.haveConfig = true; + } +} + +void Receiver::onDataC(const udps_frame_t* frame, void* user) { + Receiver* self = static_cast(user); + /* udps_frame_t stores an array of {ptr,count} structs; FrameView wants two + parallel arrays. Reuse the scratch vectors instead of allocating per + frame — this runs at the packet rate. */ + self->valPtrs_.resize(frame->num_signals); + self->valCounts_.resize(frame->num_signals); + for (uint32_t i = 0u; i < frame->num_signals; i++) { + self->valPtrs_[i] = frame->values[i].values; + self->valCounts_[i] = frame->values[i].count; + } + FrameView f; + f.counter = frame->counter; + f.hrt = frame->hrt; + f.recvTime = frame->recv_time; + f.numSamples = frame->num_samples; + f.numSignals = frame->num_signals; + f.values = self->valPtrs_.data(); + f.counts = self->valCounts_.data(); + self->handleFrame(f); + + std::lock_guard lk(self->linkMu_); + self->link_.lastFrameWall = frame->recv_time; +} + +void Receiver::onEventC(udps_event_t ev, const char* detail, void* user) { + Receiver* self = static_cast(user); + std::lock_guard lk(self->linkMu_); + switch (ev) { + case UDPS_EVENT_CONNECTED: self->link_.lastEvent = "connected"; break; + case UDPS_EVENT_DISCONNECTED: self->link_.lastEvent = "disconnected"; break; + case UDPS_EVENT_ERROR: self->link_.lastEvent = "error"; break; + } + if (detail != nullptr && detail[0] != '\0') { + self->link_.lastEvent += ": "; + self->link_.lastEvent += detail; + } +} + +bool Receiver::start(const ReceiverOptions& opt, std::string& err) { + if (running_.load()) { + err = "receiver already running"; + return false; + } + opt_ = opt; + /* udps_client_config_t holds borrowed const char*, so keep the storage + alive for as long as the client. */ + hostStr_ = opt.host; + groupStr_ = opt.multicastGroup; + ifaceStr_ = opt.interfaceAddr; + + udps_client_config_t cfg; + udps_client_config_init(&cfg); + cfg.server_addr = hostStr_.c_str(); + cfg.server_port = opt.port; + cfg.multicast_group = groupStr_.empty() ? nullptr : groupStr_.c_str(); + cfg.interface_addr = ifaceStr_.empty() ? nullptr : ifaceStr_.c_str(); + cfg.data_port = opt.dataPort; + cfg.silence_timeout_s = opt.silenceTimeoutSec; + + client_ = udps_client_create(&cfg); + if (client_ == nullptr) { + err = "udps_client_create failed (bad address or out of memory)"; + return false; + } + udps_client_set_callbacks(client_, &Receiver::onConfigC, &Receiver::onDataC, + &Receiver::onEventC, this); + running_.store(true); + { + std::lock_guard lk(linkMu_); + link_ = LinkStatus(); + link_.running = true; + } + thread_ = std::thread(&Receiver::threadMain, this); + return true; +} + +void Receiver::stop() { + if (!running_.exchange(false)) { + return; + } + if (thread_.joinable()) { + thread_.join(); + } + udps_client_destroy(client_); + client_ = nullptr; + std::lock_guard lk(linkMu_); + link_.running = false; + link_.connected = false; +} + +void Receiver::threadMain() { + while (running_.load()) { + /* 100 ms keeps stop() responsive; the poll returns as soon as a packet + lands, so this is not a latency floor. */ + (void) udps_client_poll(client_, 100); + store_.maintain(); + updateLink(); + } +} + +void Receiver::updateLink() { + udps_stats_t st; + udps_client_stats(client_, &st); + const int connected = udps_client_is_connected(client_); + std::lock_guard lk(linkMu_); + link_.connected = (connected != 0); + link_.packets = st.packets_received; + link_.frames = st.frames_delivered; + link_.configUpdates = st.config_updates; + link_.counterGaps = st.counter_gaps; + link_.fragmentsDropped = st.fragments_dropped; + link_.reconnects = st.reconnects; +} + +LinkStatus Receiver::link() const { + std::lock_guard lk(linkMu_); + return link_; +} +``` + +- [ ] **Step 6: Write the probe tool** + +Create `Client/udpscope/tools/rxprobe.cpp`: + +```cpp +/** + * @file rxprobe.cpp + * @brief Headless smoke test for the UDPScope receiver. + * + * Attaches to a UDPStreamer, runs the real receiver thread and prints the link + * counters and the per-signal ring state once a second. Use it to tell a + * receiver problem from a rendering problem. + */ +#include "Receiver.h" +#include "SignalStore.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +volatile sig_atomic_t g_stop = 0; +void onSignal(int) { g_stop = 1; } +} + +int main(int argc, char** argv) { + udpscope::ReceiverOptions opt; + int seconds = 0; /* 0 = until Ctrl-C */ + + for (int i = 1; i < argc; i++) { + const bool hasNext = (i + 1 < argc); + if (std::strcmp(argv[i], "--host") == 0 && hasNext) { + opt.host = argv[++i]; + } else if (std::strcmp(argv[i], "--port") == 0 && hasNext) { + opt.port = static_cast(std::atoi(argv[++i])); + } else if (std::strcmp(argv[i], "--multicast") == 0 && hasNext) { + opt.multicastGroup = argv[++i]; + } else if (std::strcmp(argv[i], "--iface") == 0 && hasNext) { + opt.interfaceAddr = argv[++i]; + } else if (std::strcmp(argv[i], "--data-port") == 0 && hasNext) { + opt.dataPort = static_cast(std::atoi(argv[++i])); + } else if (std::strcmp(argv[i], "--seconds") == 0 && hasNext) { + seconds = std::atoi(argv[++i]); + } else { + std::printf("usage: %s [--host H] [--port P] [--multicast G] " + "[--iface IP] [--data-port P] [--seconds N]\n", argv[0]); + return (std::strcmp(argv[i], "--help") == 0) ? 0 : 2; + } + } + + std::signal(SIGINT, onSignal); + std::signal(SIGTERM, onSignal); + + udpscope::SignalStore store; + udpscope::Receiver rx(store); + std::string err; + if (!rx.start(opt, err)) { + std::fprintf(stderr, "start failed: %s\n", err.c_str()); + return 1; + } + std::printf("attached to %s:%u\n", opt.host.c_str(), opt.port); + + for (int elapsed = 0; g_stop == 0 && (seconds == 0 || elapsed < seconds); + elapsed++) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + const udpscope::LinkStatus l = rx.link(); + std::printf("[%3ds] %s pkts=%llu frames=%llu cfg=%llu gaps=%llu " + "frag=%llu recon=%llu %s\n", + elapsed + 1, l.connected ? "UP " : "DOWN", + (unsigned long long) l.packets, + (unsigned long long) l.frames, + (unsigned long long) l.configUpdates, + (unsigned long long) l.counterGaps, + (unsigned long long) l.fragmentsDropped, + (unsigned long long) l.reconnects, + l.lastEvent.c_str()); + const std::vector sigs = store.signals(); + for (size_t i = 0; i < sigs.size(); i++) { + double t0 = 0.0, t1 = 0.0; + const bool have = store.span(sigs[i].name, t0, t1); + std::printf(" %-24s rate=%9.1f Hz cap=%8zu span=%.3f s\n", + sigs[i].name.c_str(), store.rate(sigs[i].name), + store.capacity(sigs[i].name), have ? (t1 - t0) : 0.0); + } + } + + rx.stop(); + return 0; +} +``` + +- [ ] **Step 7: Add the probe target to CMake** + +```cmake +add_executable(udpscope_rxprobe tools/rxprobe.cpp) +target_link_libraries(udpscope_rxprobe PRIVATE udpscope_core) +``` + +- [ ] **Step 8: Run the tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ReceiverLink*:Receiver*' +``` + +Expected: PASS, 15 tests (9 from Task 7, 6 new). + +- [ ] **Step 9: Verify the socket path against a real streamer** + +In one terminal: + +```bash +cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components +source env.sh +"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \ + -l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \ + -s Running -m StateMachine:START +``` + +In another: + +```bash +cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components/Client/udpscope +./build/udpscope_rxprobe --host 127.0.0.1 --port 44501 --seconds 5 +``` + +Expected: `UP`, `pkts` and `frames` climbing, one line per signal with a +plausible rate and a span that stops growing once the ring is full. `gaps` +and `frag` should stay at 0 on loopback. + +Cross-check the same source with the reference dumper, which must report the +same signal names and rates: + +```bash +cd ../../Common/Client/c && make && \ + ./build/udps_dump --host 127.0.0.1 --port 44501 --frames 20 +``` + +- [ ] **Step 10: Commit** + +```bash +git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \ + Client/udpscope/tools/rxprobe.cpp \ + Client/udpscope/tests/ReceiverLinkTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): receiver thread, UDPS client wiring and rxprobe tool" +``` + +--- + +### Task 9: Command line, window bootstrap and the app shell + +First runnable binary: parses the command line, opens an SDL2/OpenGL window, +starts the receiver, and draws the menu bar, the signal list and the status +bar. The plot area is an empty placeholder that Task 10 fills in. + +**Files:** +- Create: `Client/udpscope/Cli.h` +- Create: `Client/udpscope/Cli.cpp` +- Create: `Client/udpscope/App.h` +- Create: `Client/udpscope/App.cpp` +- Create: `Client/udpscope/SignalList.cpp` +- Create: `Client/udpscope/main.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` +- Test: `Client/udpscope/tests/CliTest.cpp` + +**Interfaces:** +- Consumes: `ReceiverOptions`, `Receiver`, `LinkStatus` (Task 8); + `SignalStore` (Task 6); `PaneTree` (Task 2). +- Produces: + +```cpp +struct CliOptions { + ReceiverOptions source; + std::string configPath; // empty = DefaultConfigPath() + size_t maxPlotPoints = 4000u; + bool setHost = false, setPort = false, setMulticast = false; + bool setIface = false, setDataPort = false, setSilence = false; + bool setConfigPath = false, setMaxPlotPoints = false; +}; +enum class CliResult { Ok, Help, Error }; +CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err); +std::string CliUsage(); +std::string DefaultConfigPath(); + +class App { +public: + explicit App(const CliOptions& opt); + ~App(); + void draw(); // one ImGui frame + bool wantsQuit() const; + void requestQuit(); +}; +``` + +**Why the `set*` flags exist:** spec §11 requires that an explicit command-line +option beats the settings file, while an omitted one falls back to it. Without +per-field "was it given" flags there is no way to tell `--port 44500` from the +default, and connecting from the command line would silently override a saved +session (or vice versa). Task 15 consumes these flags. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/CliTest.cpp`: + +```cpp +#include "Cli.h" +#include +#include +#include +#include + +using namespace udpscope; + +namespace { + +// ParseCli takes char**, as main() does. +CliResult parse(const std::vector& args, CliOptions& out, + std::string& err) { + std::vector argv; + argv.push_back(const_cast("udpscope")); + for (size_t i = 0; i < args.size(); ++i) { + argv.push_back(const_cast(args[i].c_str())); + } + return ParseCli(static_cast(argv.size()), argv.data(), out, err); +} + +} // namespace + +TEST(Cli, DefaultsMatchUdpsDump) { + CliOptions o; + std::string err; + ASSERT_EQ(parse({}, o, err), CliResult::Ok) << err; + EXPECT_EQ(o.source.host, "127.0.0.1"); + EXPECT_EQ(o.source.port, 44500u); + EXPECT_TRUE(o.source.multicastGroup.empty()); + EXPECT_EQ(o.source.dataPort, 0u); + EXPECT_FALSE(o.setHost); + EXPECT_FALSE(o.setPort); +} + +TEST(Cli, ParsesEveryOptionAndMarksItAsGiven) { + CliOptions o; + std::string err; + ASSERT_EQ(parse({"--host", "10.0.0.5", "--port", "44501", + "--multicast", "239.0.0.1", "--iface", "192.168.1.2", + "--data-port", "44503", "--silence", "3.5", + "--config", "/tmp/s.conf", "--max-mpts", "8000"}, + o, err), CliResult::Ok) << err; + EXPECT_EQ(o.source.host, "10.0.0.5"); + EXPECT_EQ(o.source.port, 44501u); + EXPECT_EQ(o.source.multicastGroup, "239.0.0.1"); + EXPECT_EQ(o.source.interfaceAddr, "192.168.1.2"); + EXPECT_EQ(o.source.dataPort, 44503u); + EXPECT_DOUBLE_EQ(o.source.silenceTimeoutSec, 3.5); + EXPECT_EQ(o.configPath, "/tmp/s.conf"); + EXPECT_EQ(o.maxPlotPoints, 8000u); + EXPECT_TRUE(o.setHost && o.setPort && o.setMulticast && o.setIface && + o.setDataPort && o.setSilence && o.setConfigPath && + o.setMaxPlotPoints); +} + +TEST(Cli, HelpIsNotAnError) { + CliOptions o; + std::string err; + EXPECT_EQ(parse({"--help"}, o, err), CliResult::Help); + EXPECT_FALSE(CliUsage().empty()); +} + +TEST(Cli, RejectsAnUnknownOption) { + CliOptions o; + std::string err; + EXPECT_EQ(parse({"--colour", "red"}, o, err), CliResult::Error); + EXPECT_NE(err.find("--colour"), std::string::npos); +} + +TEST(Cli, RejectsAMissingValue) { + CliOptions o; + std::string err; + EXPECT_EQ(parse({"--port"}, o, err), CliResult::Error); + EXPECT_NE(err.find("--port"), std::string::npos); +} + +// Single-dash clustering is the trap the Qt client documents; reject it loudly +// rather than misparse it. +TEST(Cli, RejectsSingleDashOptions) { + CliOptions o; + std::string err; + EXPECT_EQ(parse({"-host", "10.0.0.5"}, o, err), CliResult::Error); + EXPECT_NE(err.find("-host"), std::string::npos); +} + +TEST(Cli, RejectsAnOutOfRangePort) { + CliOptions o; + std::string err; + EXPECT_EQ(parse({"--port", "70000"}, o, err), CliResult::Error); + EXPECT_EQ(parse({"--port", "abc"}, o, err), CliResult::Error); +} + +TEST(Cli, DefaultConfigPathFollowsXdg) { + const char* old = std::getenv("XDG_CONFIG_HOME"); + const std::string saved = (old != nullptr) ? old : ""; + setenv("XDG_CONFIG_HOME", "/tmp/xdg-test", 1); + EXPECT_EQ(DefaultConfigPath(), "/tmp/xdg-test/udpscope/session.conf"); + + unsetenv("XDG_CONFIG_HOME"); + const std::string home = DefaultConfigPath(); + EXPECT_NE(home.find("/.config/udpscope/session.conf"), std::string::npos); + + if (!saved.empty()) { + setenv("XDG_CONFIG_HOME", saved.c_str(), 1); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `Cli.h: No such file or directory`. + +- [ ] **Step 3: Write `Cli.h` and `Cli.cpp`** + +Create `Client/udpscope/Cli.h`: + +```cpp +/** + * @file Cli.h + * @brief Command-line parsing, mirroring Common/Client/c/example/udps_dump.c. + * + * Every field records whether it was given explicitly, because an explicit + * option must beat the settings file while an omitted one must not. + */ +#ifndef UDPSCOPE_CLI_H +#define UDPSCOPE_CLI_H + +#include "Receiver.h" + +#include +#include + +namespace udpscope { + +struct CliOptions { + ReceiverOptions source; + std::string configPath; /**< empty = DefaultConfigPath() */ + size_t maxPlotPoints = 4000u; /**< decimation budget per trace */ + + bool setHost = false; + bool setPort = false; + bool setMulticast = false; + bool setIface = false; + bool setDataPort = false; + bool setSilence = false; + bool setConfigPath = false; + bool setMaxPlotPoints = false; +}; + +enum class CliResult { Ok, Help, Error }; + +CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err); +std::string CliUsage(); +/** $XDG_CONFIG_HOME/udpscope/session.conf, else ~/.config/... */ +std::string DefaultConfigPath(); + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_CLI_H */ +``` + +Create `Client/udpscope/Cli.cpp`: + +```cpp +#include "Cli.h" + +#include +#include +#include + +namespace udpscope { + +namespace { + +bool parseUInt(const char* s, unsigned long& out) { + if (s == nullptr || s[0] == '\0') { + return false; + } + char* end = nullptr; + const unsigned long v = std::strtoul(s, &end, 10); + if (end == s || *end != '\0') { + return false; + } + out = v; + return true; +} + +bool parseDouble(const char* s, double& out) { + if (s == nullptr || s[0] == '\0') { + return false; + } + char* end = nullptr; + const double v = std::strtod(s, &end); + if (end == s || *end != '\0') { + return false; + } + out = v; + return true; +} + +} /* namespace */ + +std::string CliUsage() { + return "usage: udpscope [--host ADDR] [--port N] [--multicast GROUP]\n" + " [--iface ADDR] [--data-port N] [--silence SEC]\n" + " [--config PATH] [--max-mpts N] [--help]\n" + "\n" + " --host ADDR streamer address (default 127.0.0.1)\n" + " --port N streamer control port (default 44500)\n" + " --multicast GROUP join this multicast group for data\n" + " --iface ADDR local interface IP (an address, not a name)\n" + " --data-port N local data port (default: server-chosen)\n" + " --silence SEC reconnect after this much silence (default 2)\n" + " --config PATH settings file (default XDG session.conf)\n" + " --max-mpts N max plotted points per trace (default 4000)\n"; +} + +std::string DefaultConfigPath() { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + if (xdg != nullptr && xdg[0] != '\0') { + return std::string(xdg) + "/udpscope/session.conf"; + } + const char* home = std::getenv("HOME"); + const std::string base = (home != nullptr && home[0] != '\0') ? home : "."; + return base + "/.config/udpscope/session.conf"; +} + +CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err) { + for (int i = 1; i < argc; i++) { + const char* a = argv[i]; + const bool hasNext = (i + 1 < argc); + const char* next = hasNext ? argv[i + 1] : nullptr; + + if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { + return CliResult::Help; + } + /* Long options only. A single dash is silently reinterpreted as + clustered short flags by some parsers; refuse it instead. */ + if (a[0] != '-' || a[1] != '-') { + err = std::string("unexpected argument '") + a + + "' (long -- options only)"; + return CliResult::Error; + } + + unsigned long u = 0u; + double d = 0.0; + + if (std::strcmp(a, "--host") == 0) { + if (!hasNext) { err = "--host needs an address"; return CliResult::Error; } + out.source.host = next; + out.setHost = true; + i++; + } else if (std::strcmp(a, "--port") == 0) { + if (!hasNext || !parseUInt(next, u) || u == 0u || u > 65535u) { + err = "--port needs a number in 1..65535"; + return CliResult::Error; + } + out.source.port = static_cast(u); + out.setPort = true; + i++; + } else if (std::strcmp(a, "--multicast") == 0) { + if (!hasNext) { err = "--multicast needs a group"; return CliResult::Error; } + out.source.multicastGroup = next; + out.setMulticast = true; + i++; + } else if (std::strcmp(a, "--iface") == 0) { + if (!hasNext) { err = "--iface needs an address"; return CliResult::Error; } + out.source.interfaceAddr = next; + out.setIface = true; + i++; + } else if (std::strcmp(a, "--data-port") == 0) { + if (!hasNext || !parseUInt(next, u) || u > 65535u) { + err = "--data-port needs a number in 0..65535"; + return CliResult::Error; + } + out.source.dataPort = static_cast(u); + out.setDataPort = true; + i++; + } else if (std::strcmp(a, "--silence") == 0) { + if (!hasNext || !parseDouble(next, d) || d <= 0.0) { + err = "--silence needs a positive number of seconds"; + return CliResult::Error; + } + out.source.silenceTimeoutSec = d; + out.setSilence = true; + i++; + } else if (std::strcmp(a, "--config") == 0) { + if (!hasNext) { err = "--config needs a path"; return CliResult::Error; } + out.configPath = next; + out.setConfigPath = true; + i++; + } else if (std::strcmp(a, "--max-mpts") == 0) { + if (!hasNext || !parseUInt(next, u) || u < 100u) { + err = "--max-mpts needs a number of at least 100"; + return CliResult::Error; + } + out.maxPlotPoints = static_cast(u); + out.setMaxPlotPoints = true; + i++; + } else { + err = std::string("unknown option '") + a + "'"; + return CliResult::Error; + } + } + return CliResult::Ok; +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 4: Run the CLI tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Cli*' +``` + +Expected: PASS, 8 tests. Add `Cli.cpp` to `CORE_SOURCES` first: + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp + TimeBase.cpp + FrameDecoder.cpp + Trigger.cpp + SignalStore.cpp + Receiver.cpp + Cli.cpp +) +``` + +- [ ] **Step 5: Write the app shell header** + +Create `Client/udpscope/App.h`: + +```cpp +/** + * @file App.h + * @brief GUI-thread application state and per-frame drawing. + * + * Owns the store, the receiver and the pane tree. Everything here runs on the + * GUI thread; the only cross-thread access is through SignalStore and the + * Receiver's control methods. + */ +#ifndef UDPSCOPE_APP_H +#define UDPSCOPE_APP_H + +#include "Cli.h" +#include "PaneTree.h" +#include "Receiver.h" +#include "SignalStore.h" + +#include +#include + +namespace udpscope { + +class App { +public: + explicit App(const CliOptions& opt); + ~App(); + + /** Draw one frame. Call between ImGui::NewFrame() and ImGui::Render(). */ + void draw(); + bool wantsQuit() const { return quit_; } + void requestQuit() { quit_ = true; } + +private: + void drawMenuBar(); + void drawSignalList(); + void drawPlotArea(); + void drawStatusBar(); + /** Refresh the cached signal list when the store's generation changes. */ + void syncSignals(); + + CliOptions opt_; + SignalStore store_; + Receiver rx_; + PaneTree tree_; + + std::vector sigs_; + uint64_t sigGeneration_ = 0u; + std::string status_; + bool quit_ = false; +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_APP_H */ +``` + +- [ ] **Step 6: Write the app shell implementation** + +Create `Client/udpscope/App.cpp`: + +```cpp +#include "App.h" + +#include "imgui.h" +#include "implot.h" + +#include +#include + +namespace udpscope { + +App::App(const CliOptions& opt) : opt_(opt), rx_(store_) { + std::string err; + if (!rx_.start(opt_.source, err)) { + status_ = "receiver failed to start: " + err; + } else { + char buf[128]; + std::snprintf(buf, sizeof(buf), "attaching to %s:%u", + opt_.source.host.c_str(), + static_cast(opt_.source.port)); + status_ = buf; + } +} + +App::~App() { + rx_.stop(); +} + +void App::syncSignals() { + const uint64_t gen = store_.generation(); + if (gen != sigGeneration_) { + sigGeneration_ = gen; + sigs_ = store_.signals(); + /* Assignments naming a signal that is gone are pruned by the pane + drawing code in Task 10; nothing to do here yet. */ + } +} + +void App::drawMenuBar() { + if (!ImGui::BeginMainMenuBar()) { + return; + } + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Quit", "Ctrl+Q")) { + requestQuit(); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) { + ImGui::MenuItem("(cursors land in Task 13)", nullptr, false, false); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Help")) { + ImGui::MenuItem("UDPScope — direct UDPS oscilloscope", nullptr, false, false); + ImGui::EndMenu(); + } + + /* Connection badge, right-aligned. */ + const LinkStatus l = rx_.link(); + const char* text = l.connected ? "CONNECTED" : (l.running ? "connecting..." : "stopped"); + const float w = ImGui::CalcTextSize(text).x; + ImGui::SameLine(ImGui::GetWindowWidth() - w - 16.0f); + ImGui::TextColored(l.connected ? ImVec4(0.65f, 0.89f, 0.63f, 1.0f) + : ImVec4(0.98f, 0.70f, 0.53f, 1.0f), + "%s", text); + ImGui::EndMainMenuBar(); +} + +void App::drawPlotArea() { + /* Task 10 replaces this with the pane tree. */ + ImGui::TextDisabled("drag a signal here (panes land in Task 10)"); +} + +void App::drawStatusBar() { + const LinkStatus l = rx_.link(); + ImGui::Text("pkts %" PRIu64 " frames %" PRIu64 " cfg %" PRIu64, + l.packets, l.frames, l.configUpdates); + ImGui::SameLine(); + /* Gaps and dropped fragments are the honest signal that the scope is not + seeing everything, so they are highlighted rather than buried. */ + const ImVec4 bad(0.95f, 0.55f, 0.66f, 1.0f); + const ImVec4 ok(0.65f, 0.68f, 0.75f, 1.0f); + ImGui::TextColored(l.counterGaps > 0u ? bad : ok, "gaps %" PRIu64, l.counterGaps); + ImGui::SameLine(); + ImGui::TextColored(l.fragmentsDropped > 0u ? bad : ok, + "frag %" PRIu64, l.fragmentsDropped); + ImGui::SameLine(); + ImGui::TextColored(l.reconnects > 0u ? bad : ok, + "reconn %" PRIu64, l.reconnects); + if (!status_.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("| %s", status_.c_str()); + } +} + +void App::draw() { + syncSignals(); + drawMenuBar(); + + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(vp->WorkPos); + ImGui::SetNextWindowSize(vp->WorkSize); + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus | + ImGuiWindowFlags_NoSavedSettings; + ImGui::Begin("##root", nullptr, flags); + + const float statusH = ImGui::GetTextLineHeightWithSpacing() + 8.0f; + const float bodyH = ImGui::GetContentRegionAvail().y - statusH; + + ImGui::BeginChild("##list", ImVec2(220.0f, bodyH), true); + drawSignalList(); + ImGui::EndChild(); + + ImGui::SameLine(); + ImGui::BeginChild("##panes", ImVec2(0.0f, bodyH), true); + drawPlotArea(); + ImGui::EndChild(); + + ImGui::Separator(); + drawStatusBar(); + ImGui::End(); + + if (ImGui::IsKeyDown(ImGuiKey_ModCtrl) && ImGui::IsKeyPressed(ImGuiKey_Q)) { + requestQuit(); + } +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 6b: Write `SignalList.cpp`** + +The side panel gets its own translation unit, as spec §3.4 lays out. It is +still an `App` method, so it needs no accessors for the state it reads. + +Create `Client/udpscope/SignalList.cpp`: + +```cpp +#include "App.h" + +#include "imgui.h" + +namespace udpscope { + +void App::drawSignalList() { + ImGui::TextUnformatted("Signals"); + ImGui::Separator(); + if (sigs_.empty()) { + ImGui::TextDisabled("waiting for CONFIG"); + return; + } + for (size_t i = 0u; i < sigs_.size(); i++) { + const SignalMeta& m = sigs_[i]; + ImGui::PushID(static_cast(i)); + ImGui::Selectable(m.name.c_str()); + if (ImGui::IsItemHovered()) { + double t0 = 0.0, t1 = 0.0; + const bool have = store_.span(m.name, t0, t1); + ImGui::SetTooltip("%s\n%u element(s), %s\n%.1f Hz, %.2f s buffered", + m.name.c_str(), m.numElements(), + m.unit.empty() ? "no unit" : m.unit.c_str(), + store_.rate(m.name), have ? (t1 - t0) : 0.0); + } + ImGui::PopID(); + } +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 7: Write `main.cpp`** + +Create `Client/udpscope/main.cpp`: + +```cpp +/** + * @file main.cpp + * @brief SDL2 + OpenGL 3.3 + Dear ImGui bootstrap for UDPScope. + */ +#include "App.h" +#include "Cli.h" + +#include "imgui.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_sdl2.h" +#include "implot.h" + +#include +#include + +#include +#include +#include +#include + +namespace { + +/** Directory holding the running binary, or "." if it cannot be determined. */ +std::string exeDir() { + char buf[4096]; + const ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n <= 0) { + return "."; + } + buf[n] = '\0'; + std::string p(buf); + const size_t slash = p.rfind('/'); + return (slash == std::string::npos) ? std::string(".") : p.substr(0, slash); +} + +bool fileExists(const std::string& p) { + struct stat st; + return stat(p.c_str(), &st) == 0; +} + +/** First existing of: next to the binary, the build tree, the source tree. */ +std::string findFont(const char* name) { + const std::string dir = exeDir(); + const std::string candidates[] = { + dir + "/resources/fonts/" + name, + dir + "/../share/udpscope/fonts/" + name, + std::string(APP_RESOURCE_DIR) + "/fonts/" + name, + }; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { + if (fileExists(candidates[i])) { + return candidates[i]; + } + } + return std::string(); +} + +void applyStyle() { + ImGui::StyleColorsDark(); + ImGuiStyle& s = ImGui::GetStyle(); + s.WindowRounding = 4.0f; + s.FrameRounding = 4.0f; + s.GrabRounding = 4.0f; + s.ScrollbarRounding = 4.0f; + s.WindowBorderSize = 1.0f; + /* Catppuccin Mocha, matching the StreamHub ImGui client. */ + ImVec4* c = s.Colors; + c[ImGuiCol_WindowBg] = ImVec4(0.12f, 0.12f, 0.18f, 1.00f); + c[ImGuiCol_ChildBg] = ImVec4(0.14f, 0.14f, 0.20f, 1.00f); + c[ImGuiCol_PopupBg] = ImVec4(0.10f, 0.10f, 0.15f, 0.98f); + c[ImGuiCol_Border] = ImVec4(0.27f, 0.28f, 0.35f, 1.00f); + c[ImGuiCol_FrameBg] = ImVec4(0.19f, 0.20f, 0.27f, 1.00f); + c[ImGuiCol_FrameBgHovered] = ImVec4(0.24f, 0.25f, 0.33f, 1.00f); + c[ImGuiCol_TitleBgActive] = ImVec4(0.17f, 0.18f, 0.25f, 1.00f); + c[ImGuiCol_MenuBarBg] = ImVec4(0.15f, 0.15f, 0.22f, 1.00f); + c[ImGuiCol_Header] = ImVec4(0.24f, 0.25f, 0.33f, 1.00f); + c[ImGuiCol_Button] = ImVec4(0.22f, 0.23f, 0.31f, 1.00f); + c[ImGuiCol_ButtonHovered] = ImVec4(0.29f, 0.31f, 0.41f, 1.00f); + c[ImGuiCol_Text] = ImVec4(0.80f, 0.84f, 0.96f, 1.00f); + c[ImGuiCol_TextDisabled] = ImVec4(0.43f, 0.45f, 0.55f, 1.00f); +} + +} /* namespace */ + +int main(int argc, char** argv) { + udpscope::CliOptions opt; + std::string err; + const udpscope::CliResult r = udpscope::ParseCli(argc, argv, opt, err); + if (r == udpscope::CliResult::Help) { + std::printf("%s", udpscope::CliUsage().c_str()); + return 0; + } + if (r == udpscope::CliResult::Error) { + std::fprintf(stderr, "%s\n\n%s", err.c_str(), udpscope::CliUsage().c_str()); + return 2; + } + + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) { + std::fprintf(stderr, "SDL_Init: %s\n", SDL_GetError()); + return 1; + } + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); + + SDL_Window* win = SDL_CreateWindow( + "UDPScope", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1600, 1000, + SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); + if (win == nullptr) { + std::fprintf(stderr, "SDL_CreateWindow: %s\n", SDL_GetError()); + SDL_Quit(); + return 1; + } + SDL_GLContext gl = SDL_GL_CreateContext(win); + SDL_GL_MakeCurrent(win, gl); + SDL_GL_SetSwapInterval(1); /* vsync: the scope repaints at the refresh rate */ + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + /* The layout lives in our own settings file (Task 14), not imgui.ini. */ + io.IniFilename = nullptr; + applyStyle(); + + const std::string font = findFont("FiraSans-Regular.ttf"); + if (!font.empty()) { + io.Fonts->AddFontFromFileTTF(font.c_str(), 16.0f); + } + + ImGui_ImplSDL2_InitForOpenGL(win, gl); + ImGui_ImplOpenGL3_Init("#version 330"); + + { + udpscope::App app(opt); + bool done = false; + while (!done && !app.wantsQuit()) { + SDL_Event e; + while (SDL_PollEvent(&e) != 0) { + ImGui_ImplSDL2_ProcessEvent(&e); + if (e.type == SDL_QUIT) { + done = true; + } + if (e.type == SDL_WINDOWEVENT && + e.window.event == SDL_WINDOWEVENT_CLOSE && + e.window.windowID == SDL_GetWindowID(win)) { + done = true; + } + } + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplSDL2_NewFrame(); + ImGui::NewFrame(); + app.draw(); + ImGui::Render(); + + int w = 0, h = 0; + SDL_GetWindowSize(win, &w, &h); + glViewport(0, 0, w, h); + glClearColor(0.09f, 0.09f, 0.13f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + SDL_GL_SwapWindow(win); + } + } /* App destroyed here, stopping the receiver before SDL shuts down. */ + + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplSDL2_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + SDL_GL_DeleteContext(gl); + SDL_DestroyWindow(win); + SDL_Quit(); + return 0; +} +``` + +- [ ] **Step 8: Update CMake for the app sources** + +Replace the application block in `Client/udpscope/CMakeLists.txt` — the +`if(EXISTS main.cpp)` guard from Task 1 has done its job and goes away: + +```cmake +# ── Application ─────────────────────────────────────────────────────────────── +set(APP_SOURCES + main.cpp + App.cpp + SignalList.cpp +) + +add_executable(UDPScope ${APP_SOURCES}) +target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL) +target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}") +if(HAVE_FONT_AWESOME) + target_include_directories(UDPScope PRIVATE ${FONT_DIR}) + target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME) +endif() +target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter) + +install(TARGETS UDPScope DESTINATION bin) +install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope) +``` + +- [ ] **Step 9: Build and run against a real streamer** + +```bash +cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j +./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–9. + +```bash +# terminal 1 +source env.sh && "${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \ + -l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \ + -s Running -m StateMachine:START +# terminal 2 +./Client/udpscope/build/UDPScope --host 127.0.0.1 --port 44501 +``` + +Expected: a window opens, the badge reads `CONNECTED`, the signal list fills +with the demo signals, hovering one shows a plausible rate and buffered span, +and the status bar counters climb with `gaps 0`. Ctrl+Q closes it. + +```bash +./Client/udpscope/build/UDPScope --help # prints usage, exits 0 +./Client/udpscope/build/UDPScope -host 1.2.3.4 # rejects, exits 2 +``` + +- [ ] **Step 10: Commit** + +```bash +git add Client/udpscope/Cli.h Client/udpscope/Cli.cpp Client/udpscope/App.h \ + Client/udpscope/App.cpp Client/udpscope/SignalList.cpp \ + Client/udpscope/main.cpp \ + Client/udpscope/tests/CliTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): CLI, SDL2/ImGui bootstrap and application shell" +``` + +--- + +### Task 10: Trace fetching and the first real plot + +Split the plotting work in two: `PlotData` decides *what* points to draw and +is fully unit-tested against the store; `PaneView` is thin ImPlot glue that +draws them. This task draws the tree's single root leaf across the whole plot +area and lets the user drag signals onto it. Splitting arrives in Task 11. + +**Files:** +- Create: `Client/udpscope/PlotData.h` +- Create: `Client/udpscope/PlotData.cpp` +- Create: `Client/udpscope/PaneView.h` +- Create: `Client/udpscope/PaneView.cpp` +- Modify: `Client/udpscope/App.h`, `Client/udpscope/App.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` +- Test: `Client/udpscope/tests/PlotDataTest.cpp` + +**Interfaces:** +- Consumes: `SignalStore`, `Capture`, `Profile` (Task 6); `MinMaxDecimate`, + `Series`, `Color` (Task 1); `PaneNode`, `Assignment` (Task 2). +- Produces: + +```cpp +struct TraceData { + Series raw; // every sample in range — statistics use this + Series draw; // decimated to the plot budget + bool found = false; +}; + +bool FetchLiveTrace(const SignalStore& store, const std::string& name, + double t0, double t1, size_t maxPoints, TraceData& out); +bool FetchCaptureTrace(const Capture& cap, const std::string& name, + double t0, double t1, size_t maxPoints, TraceData& out); +Color PaletteColor(size_t index); + +struct PaneContext { + SignalStore* store = nullptr; + const Capture* capture = nullptr; // nullptr => live + double x0 = 0.0, x1 = 1.0; + size_t maxPoints = 4000u; +}; +class PaneView { +public: + void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size, + PaneContext& ctx); +}; +``` + +**Why `raw` is kept alongside `draw`:** spec §8.4 requires min/max/pp/mean/RMS +to be computed from undecimated data. Computing them from the decimated +series would report the extremes of the drawn envelope but a mean and RMS +weighted by bucket rather than by sample. Task 14 reads `TraceData::raw`. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/PlotDataTest.cpp`: + +```cpp +#include "PlotData.h" +#include +#include +#include +#include +#include + +using namespace udpscope; + +namespace { + +SignalMeta scalar(const std::string& name) { + SignalMeta m; + m.name = name; + m.typeCode = 8; + m.numRows = 1; + m.numCols = 1; + return m; +} + +// Fills "a" with a 10 kHz ramp over [0, 1) s, with a one-sample spike at 0.5 s. +void fill(SignalStore& s) { + s.setSignals({scalar("a")}); + std::vector t(10000), v(10000); + for (size_t i = 0; i < t.size(); ++i) { + t[i] = static_cast(i) * 1e-4; + v[i] = static_cast(i); + } + v[5000] = 1.0e6; + s.setWindowSec(1.0); + s.maintain(); + s.push("a", t.data(), v.data(), t.size()); +} + +} // namespace + +TEST(PlotData, LiveFetchClipsToTheRequestedRange) { + SignalStore s; + fill(s); + TraceData d; + ASSERT_TRUE(FetchLiveTrace(s, "a", 0.2, 0.3, 4000, d)); + EXPECT_TRUE(d.found); + EXPECT_NEAR(d.raw.t.front(), 0.2, 1e-9); + EXPECT_NEAR(d.raw.t.back(), 0.3, 1e-9); + EXPECT_EQ(d.raw.size(), 1001u); +} + +TEST(PlotData, DrawSeriesRespectsTheBudgetAndRawDoesNot) { + SignalStore s; + fill(s); + TraceData d; + ASSERT_TRUE(FetchLiveTrace(s, "a", 0.0, 1.0, 500, d)); + EXPECT_EQ(d.raw.size(), 10000u) << "raw must stay undecimated for statistics"; + EXPECT_LE(d.draw.size(), 500u); + EXPECT_GT(d.draw.size(), 2u); +} + +// The reason for min/max decimation: the spike must still be on screen. +TEST(PlotData, DecimationKeepsTheSpike) { + SignalStore s; + fill(s); + TraceData d; + ASSERT_TRUE(FetchLiveTrace(s, "a", 0.0, 1.0, 500, d)); + EXPECT_DOUBLE_EQ(*std::max_element(d.draw.v.begin(), d.draw.v.end()), 1.0e6); +} + +TEST(PlotData, AMissingSignalIsReportedNotFabricated) { + SignalStore s; + fill(s); + TraceData d; + EXPECT_FALSE(FetchLiveTrace(s, "ghost", 0.0, 1.0, 500, d)); + EXPECT_FALSE(d.found); + EXPECT_TRUE(d.raw.empty()); + EXPECT_TRUE(d.draw.empty()); +} + +TEST(PlotData, AnEmptyRangeYieldsAnEmptyTraceWithoutFailing) { + SignalStore s; + fill(s); + TraceData d; + EXPECT_TRUE(FetchLiveTrace(s, "a", 50.0, 51.0, 500, d)); + EXPECT_TRUE(d.found); + EXPECT_TRUE(d.raw.empty()); + EXPECT_TRUE(d.draw.empty()); +} + +TEST(PlotData, CaptureFetchReadsTheFrozenSnapshotNotTheRing) { + Capture cap; + cap.trigTime = 5.0; + cap.t0 = 4.9; + cap.t1 = 5.1; + cap.names.push_back("a"); + cap.series.emplace_back(); + for (int i = 0; i < 201; ++i) { + cap.series[0].t.push_back(4.9 + i * 1e-3); + cap.series[0].v.push_back(static_cast(i)); + } + + TraceData d; + ASSERT_TRUE(FetchCaptureTrace(cap, "a", 4.95, 5.05, 4000, d)); + EXPECT_EQ(d.raw.size(), 101u); + EXPECT_NEAR(d.raw.t.front(), 4.95, 1e-9); + + TraceData miss; + EXPECT_FALSE(FetchCaptureTrace(cap, "b", 4.95, 5.05, 4000, miss)); +} + +TEST(PlotData, PaletteColoursAreDistinctAndWrap) { + const Color c0 = PaletteColor(0); + const Color c1 = PaletteColor(1); + EXPECT_FALSE(c0.r == c1.r && c0.g == c1.g && c0.b == c1.b); + const Color wrapped = PaletteColor(0 + 8); + EXPECT_FLOAT_EQ(wrapped.r, c0.r); + EXPECT_FLOAT_EQ(wrapped.a, 1.0f); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `PlotData.h: No such file or directory`. + +- [ ] **Step 3: Write `PlotData.h`** + +Create `Client/udpscope/PlotData.h`: + +```cpp +/** + * @file PlotData.h + * @brief Turns store or capture contents into a drawable trace. + * + * Framework-free so it can be tested without a GUI. Every fetch returns both + * the undecimated samples (for statistics) and the decimated ones (for the + * screen). + */ +#ifndef UDPSCOPE_PLOTDATA_H +#define UDPSCOPE_PLOTDATA_H + +#include "SignalStore.h" +#include "Types.h" + +#include +#include + +namespace udpscope { + +struct TraceData { + Series raw; /**< every sample in [t0,t1]; statistics use this */ + Series draw; /**< min/max-decimated to the plot budget */ + bool found = false; /**< the signal exists, even if the range is empty */ +}; + +/** @return false when the signal is not in the store. */ +bool FetchLiveTrace(const SignalStore& store, const std::string& name, + double t0, double t1, size_t maxPoints, TraceData& out); +/** @return false when the signal is not in the capture. */ +bool FetchCaptureTrace(const Capture& cap, const std::string& name, + double t0, double t1, size_t maxPoints, TraceData& out); + +/** Deterministic 8-colour palette, wrapping on overflow. */ +Color PaletteColor(size_t index); + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_PLOTDATA_H */ +``` + +- [ ] **Step 4: Write `PlotData.cpp`** + +Create `Client/udpscope/PlotData.cpp`: + +```cpp +#include "PlotData.h" + +#include "Decimate.h" + +#include + +namespace udpscope { + +namespace { + +/* Catppuccin Mocha accents, in the order the StreamHub clients use them. */ +const Color kPalette[8] = { + {0.537f, 0.706f, 0.980f, 1.0f}, /* blue */ + {0.980f, 0.702f, 0.529f, 1.0f}, /* peach */ + {0.651f, 0.890f, 0.631f, 1.0f}, /* green */ + {0.949f, 0.545f, 0.659f, 1.0f}, /* pink */ + {0.976f, 0.886f, 0.686f, 1.0f}, /* yellow */ + {0.796f, 0.651f, 0.969f, 1.0f}, /* mauve */ + {0.584f, 0.890f, 0.839f, 1.0f}, /* teal */ + {0.937f, 0.604f, 0.604f, 1.0f}, /* red */ +}; + +void decimateInto(TraceData& d, size_t maxPoints) { + if (d.raw.empty()) { + d.draw.clear(); + return; + } + MinMaxDecimate(d.raw.t.data(), d.raw.v.data(), d.raw.size(), maxPoints, d.draw); +} + +} /* namespace */ + +Color PaletteColor(size_t index) { + return kPalette[index % 8u]; +} + +bool FetchLiveTrace(const SignalStore& store, const std::string& name, + double t0, double t1, size_t maxPoints, TraceData& out) { + out.raw.clear(); + out.draw.clear(); + out.found = false; + + /* capacity() is 0 only for a name the store does not know. */ + if (store.capacity(name) == 0u) { + return false; + } + out.found = true; + store.readRange(name, t0, t1, out.raw); + decimateInto(out, maxPoints); + return true; +} + +bool FetchCaptureTrace(const Capture& cap, const std::string& name, + double t0, double t1, size_t maxPoints, TraceData& out) { + out.raw.clear(); + out.draw.clear(); + out.found = false; + + for (size_t i = 0u; i < cap.names.size() && i < cap.series.size(); i++) { + if (cap.names[i] != name) { + continue; + } + out.found = true; + const Series& s = cap.series[i]; + /* The capture is already sorted by time; a linear scan is fine because + it is bounded by the window, not by the ring. */ + for (size_t k = 0u; k < s.t.size(); k++) { + if (s.t[k] >= t0 && s.t[k] <= t1) { + out.raw.t.push_back(s.t[k]); + out.raw.v.push_back(s.v[k]); + } + } + decimateInto(out, maxPoints); + return true; + } + return false; +} + +} /* namespace udpscope */ +``` + +Add `PlotData.cpp` to `CORE_SOURCES`. + +- [ ] **Step 5: Run the PlotData tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PlotData*' +``` + +Expected: PASS, 7 tests. + +- [ ] **Step 6: Write the pane view** + +Create `Client/udpscope/PaneView.h`: + +```cpp +/** + * @file PaneView.h + * @brief ImPlot rendering of one pane-tree leaf. GUI thread only. + */ +#ifndef UDPSCOPE_PANEVIEW_H +#define UDPSCOPE_PANEVIEW_H + +#include "PaneTree.h" +#include "PlotData.h" +#include "SignalStore.h" + +#include "imgui.h" + +namespace udpscope { + +/** Everything a leaf needs to draw itself, rebuilt each frame by App. */ +struct PaneContext { + SignalStore* store = nullptr; + const Capture* capture = nullptr; /**< nullptr => draw live data */ + double x0 = 0.0; + double x1 = 1.0; + size_t maxPoints = 4000u; +}; + +/** The ImGui drag-and-drop payload carrying a signal name from the list. */ +extern const char* const kSignalPayload; + +class PaneView { +public: + /** Draws one leaf, including its drop target and legend context menu. */ + void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size, + PaneContext& ctx); +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_PANEVIEW_H */ +``` + +Create `Client/udpscope/PaneView.cpp`: + +```cpp +#include "PaneView.h" + +#include "implot.h" + +#include +#include + +namespace udpscope { + +const char* const kSignalPayload = "UDPSCOPE_SIG"; + +namespace { + +ImVec4 toImVec4(const Color& c) { return ImVec4(c.r, c.g, c.b, c.a); } + +/** Appends a signal to a pane, colouring it by its position in the pane. */ +void assign(PaneNode& leaf, const char* name) { + for (size_t i = 0u; i < leaf.signals.size(); i++) { + if (leaf.signals[i].signalName == name) { + return; /* already shown here */ + } + } + Assignment a; + a.signalName = name; + a.color = PaletteColor(leaf.signals.size()); + leaf.signals.push_back(a); +} + +} /* namespace */ + +void PaneView::drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size, + PaneContext& ctx) { + if (!ImPlot::BeginPlot(id, size, ImPlotFlags_NoTitle)) { + return; + } + ImPlot::SetupAxes("t [s]", nullptr); + /* The X range is owned by App so every pane shares it; Task 12 makes the + user's pan and zoom write back into it. */ + ImPlot::SetupAxisLimits(ImAxis_X1, ctx.x0, ctx.x1, ImPlotCond_Always); + + TraceData d; + for (size_t i = 0u; i < leaf.signals.size(); i++) { + Assignment& a = leaf.signals[i]; + const bool ok = (ctx.capture != nullptr) + ? FetchCaptureTrace(*ctx.capture, a.signalName, + ctx.x0, ctx.x1, ctx.maxPoints, d) + : FetchLiveTrace(*ctx.store, a.signalName, + ctx.x0, ctx.x1, ctx.maxPoints, d); + if (!ok || d.draw.empty()) { + continue; + } + ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth); + ImPlot::PlotLine(a.signalName.c_str(), d.draw.t.data(), d.draw.v.data(), + static_cast(d.draw.size())); + + if (ImPlot::BeginLegendPopup(a.signalName.c_str())) { + ImGui::ColorEdit3("colour", &a.color.r); + ImGui::SliderFloat("width", &a.lineWidth, 0.5f, 4.0f, "%.1f px"); + if (ImGui::Button("remove")) { + leaf.signals.erase(leaf.signals.begin() + + static_cast(i)); + ImGui::CloseCurrentPopup(); + ImPlot::EndLegendPopup(); + break; + } + ImPlot::EndLegendPopup(); + } + } + + /* Dropping anywhere on the plot assigns the signal to this pane. */ + if (ImPlot::BeginDragDropTargetPlot()) { + const ImGuiPayload* p = ImGui::AcceptDragDropPayload(kSignalPayload); + if (p != nullptr && p->Data != nullptr) { + assign(leaf, static_cast(p->Data)); + } + ImPlot::EndDragDropTarget(); + } + + ImPlot::EndPlot(); +} + +} /* namespace udpscope */ +``` + +- [ ] **Step 7: Wire the view into App** + +In `Client/udpscope/App.h`, add `#include "PaneView.h"` and the members: + +```cpp + PaneView paneView_; + double xSpanSec_ = 1.0; /**< live window width, Task 12 makes it settable */ +``` + +In `Client/udpscope/App.cpp`, make the signal list a drag source by replacing +the `ImGui::Selectable(m.name.c_str());` line with: + +```cpp + ImGui::Selectable(m.name.c_str()); + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + /* Payload is the name including its terminator, so the drop side + can use it as a C string directly. */ + ImGui::SetDragDropPayload(kSignalPayload, m.name.c_str(), + m.name.size() + 1u); + ImGui::TextUnformatted(m.name.c_str()); + ImGui::EndDragDropSource(); + } +``` + +and replace `drawPlotArea()` with: + +```cpp +void App::drawPlotArea() { + PaneContext ctx; + ctx.store = &store_; + ctx.capture = nullptr; + ctx.maxPoints = opt_.maxPlotPoints; + + /* Live: the window ends at the newest sample of any assigned signal. The + shared, user-controllable X range arrives in Task 12. */ + double newest = 0.0; + bool any = false; + for (size_t i = 0u; i < sigs_.size(); i++) { + double o = 0.0, n = 0.0; + if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) { + newest = n; + any = true; + } + } + ctx.x1 = any ? newest : 1.0; + ctx.x0 = ctx.x1 - xSpanSec_; + + PaneNode* root = tree_.root(); + paneView_.drawLeaf(*root, "##pane0", ImGui::GetContentRegionAvail(), ctx); +} +``` + +- [ ] **Step 8: Update CMake** + +```cmake +set(CORE_SOURCES + Decimate.cpp + PaneTree.cpp + TimeBase.cpp + FrameDecoder.cpp + Trigger.cpp + SignalStore.cpp + Receiver.cpp + Cli.cpp + PlotData.cpp +) + +set(APP_SOURCES + main.cpp + App.cpp + PaneView.cpp +) +``` + +`PaneView.cpp` needs ImGui headers, so it belongs to the executable, not the +core library — that is what keeps `udpscope_tests` free of a GUI dependency. + +- [ ] **Step 9: Build and check it plots** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–10. + +Run it against the demo streamer as in Task 9, then drag `Sine1` from the +list onto the plot. Expected: a 1 Hz sine scrolling right to left, smooth +(not a staircase), with the legend entry right-clickable for colour, width +and remove. Drag a second signal on and both draw with different colours. + +- [ ] **Step 10: Commit** + +```bash +git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \ + Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \ + Client/udpscope/App.h Client/udpscope/App.cpp \ + Client/udpscope/tests/PlotDataTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): trace fetching, min/max decimation and the first live plot" +``` + +--- + +### Task 11: Splittable pane grid + +Draw the whole tree instead of just the root leaf: every leaf gets its rect +from `PaneTree::layout()`, hovering one reveals four inset split handles and +a close ✕, and the shared borders drag to re-proportion the split. + +**Files:** +- Modify: `Client/udpscope/PaneTree.h`, `Client/udpscope/PaneTree.cpp` + (deferred command application) +- Modify: `Client/udpscope/PaneView.h`, `Client/udpscope/PaneView.cpp` + (tree drawing and interaction) +- Modify: `Client/udpscope/App.cpp` +- Test: `Client/udpscope/tests/PaneCommandTest.cpp` + +**Interfaces:** +- Consumes: `PaneTree`, `PaneNode`, `Rect`, `Orient`, `Handle`, + `kMinPaneSize`, `kSplitterGrab`, `kHandleSize` (Task 2); `PaneView`, + `PaneContext` (Task 10). +- Produces: + +```cpp +// PaneTree.h +struct PaneCommand { + enum class Kind { None, Split, Close, SetRatio }; + Kind kind = Kind::None; + PaneNode* target = nullptr; + Orient orient = Orient::Columns; // Split only + double ratio = 0.5; // SetRatio only +}; +void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd); + +// PaneView.h +void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx); +``` + +**Why commands are deferred:** `layout()` hands out raw `PaneNode*`. Splitting +or closing during the walk re-parents nodes and invalidates those pointers +mid-frame. Collect at most one command per frame and apply it after the walk. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/PaneCommandTest.cpp`: + +```cpp +#include "PaneTree.h" +#include + +using namespace udpscope; + +TEST(PaneCommand, NoneLeavesTheTreeAlone) { + PaneTree tree; + PaneCommand cmd; + ApplyPaneCommand(tree, cmd); + EXPECT_EQ(tree.leafCount(), 1u); +} + +TEST(PaneCommand, SplitAddsALeaf) { + PaneTree tree; + PaneCommand cmd; + cmd.kind = PaneCommand::Kind::Split; + cmd.target = tree.root(); + cmd.orient = Orient::Rows; + ApplyPaneCommand(tree, cmd); + EXPECT_EQ(tree.leafCount(), 2u); + EXPECT_FALSE(tree.root()->leaf); + EXPECT_EQ(tree.root()->orient, Orient::Rows); +} + +TEST(PaneCommand, CloseRemovesALeaf) { + PaneTree tree; + PaneCommand split; + split.kind = PaneCommand::Kind::Split; + split.target = tree.root(); + ApplyPaneCommand(tree, split); + ASSERT_EQ(tree.leafCount(), 2u); + + PaneCommand close; + close.kind = PaneCommand::Kind::Close; + close.target = tree.root()->a.get(); + ApplyPaneCommand(tree, close); + EXPECT_EQ(tree.leafCount(), 1u); +} + +// Closing the only pane would leave nothing to draw and no way to get a pane +// back, so it is refused. +TEST(PaneCommand, ClosingTheLastLeafIsRefused) { + PaneTree tree; + PaneNode* only = tree.root(); + PaneCommand close; + close.kind = PaneCommand::Kind::Close; + close.target = only; + ApplyPaneCommand(tree, close); + EXPECT_EQ(tree.leafCount(), 1u); + EXPECT_TRUE(tree.root()->leaf); +} + +TEST(PaneCommand, SetRatioClampsIntoTheLegalRange) { + PaneTree tree; + PaneCommand split; + split.kind = PaneCommand::Kind::Split; + split.target = tree.root(); + ApplyPaneCommand(tree, split); + + PaneCommand r; + r.kind = PaneCommand::Kind::SetRatio; + r.target = tree.root(); + r.ratio = 5.0; + ApplyPaneCommand(tree, r); + EXPECT_LE(tree.root()->ratio, 1.0); + EXPECT_GT(tree.root()->ratio, 0.0); +} + +TEST(PaneCommand, ANullTargetIsIgnored) { + PaneTree tree; + PaneCommand cmd; + cmd.kind = PaneCommand::Kind::Split; + cmd.target = nullptr; + ApplyPaneCommand(tree, cmd); + EXPECT_EQ(tree.leafCount(), 1u); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `no type named 'PaneCommand' in namespace 'udpscope'`. + +- [ ] **Step 3: Add `PaneCommand` to `PaneTree.h`** + +Append inside `namespace udpscope`, after the `PaneTree` class: + +```cpp +/** + * A pending mutation of the tree. + * + * layout() hands out raw PaneNode*, which splitting or closing invalidates. + * The view therefore records at most one command per frame and applies it + * after the layout walk has finished. + */ +struct PaneCommand { + enum class Kind { None, Split, Close, SetRatio }; + Kind kind = Kind::None; + PaneNode* target = nullptr; + Orient orient = Orient::Columns; + double ratio = 0.5; +}; + +void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd); +``` + +- [ ] **Step 4: Implement it in `PaneTree.cpp`** + +```cpp +void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd) { + if (cmd.kind == PaneCommand::Kind::None || cmd.target == nullptr) { + return; + } + switch (cmd.kind) { + case PaneCommand::Kind::Split: + tree.splitLeaf(cmd.target, cmd.orient); + break; + case PaneCommand::Kind::Close: + /* Refuse the last one: an empty tree has nothing to draw and no way + back. */ + if (tree.leafCount() > 1u) { + tree.closeLeaf(cmd.target); + } + break; + case PaneCommand::Kind::SetRatio: + tree.setRatio(cmd.target, cmd.ratio); /* clamps internally */ + break; + case PaneCommand::Kind::None: + break; + } +} +``` + +- [ ] **Step 5: Run the command tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneCommand*:PaneTree*' +``` + +Expected: PASS, 18 tests (12 from Task 2, 6 new). + +- [ ] **Step 6: Draw the tree** + +In `Client/udpscope/PaneView.h`, add to the class: + +```cpp + void drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx); + +private: + void drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd); + + PaneCommand pending_; + PaneNode* dragSplitter_ = nullptr; +``` + +In `Client/udpscope/PaneView.cpp`, add: + +```cpp +namespace { + +/* Panes are drawn inset so the shared border stays free for the splitter. */ +const float kInset = 3.0f; + +ImVec2 topLeft(const Rect& r) { return ImVec2((float) r.x, (float) r.y); } +ImVec2 sizeOf(const Rect& r) { return ImVec2((float) r.w, (float) r.h); } + +} /* namespace */ + +void PaneView::drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + const float h = (float) kHandleSize; + const ImU32 bg = IM_COL32(60, 62, 84, 220); + const ImU32 fg = IM_COL32(205, 214, 244, 255); + + struct Spot { Handle which; ImVec2 pos; const char* glyph; }; + const Spot spots[5] = { + {Handle::Left, ImVec2((float) (r.x + kHandleSize), + (float) (r.y + r.h * 0.5)), "|"}, + {Handle::Right, ImVec2((float) (r.x + r.w - kHandleSize), + (float) (r.y + r.h * 0.5)), "|"}, + {Handle::Top, ImVec2((float) (r.x + r.w * 0.5), + (float) (r.y + kHandleSize)), "-"}, + {Handle::Bottom, ImVec2((float) (r.x + r.w * 0.5), + (float) (r.y + r.h - kHandleSize)), "-"}, + {Handle::Close, ImVec2((float) (r.x + r.w - kHandleSize), + (float) (r.y + kHandleSize)), "x"}, + }; + + for (int i = 0; i < 5; i++) { + const ImVec2 c = spots[i].pos; + ImGui::SetCursorScreenPos(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f)); + ImGui::PushID(i); + /* Submitted after the plot, so it wins the hit test over it. */ + const bool clicked = ImGui::InvisibleButton("##handle", ImVec2(h, h)); + const bool hovered = ImGui::IsItemHovered(); + ImGui::PopID(); + + dl->AddRectFilled(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f), + ImVec2(c.x + h * 0.5f, c.y + h * 0.5f), + hovered ? IM_COL32(88, 91, 112, 255) : bg, 3.0f); + dl->AddText(ImVec2(c.x - 3.0f, c.y - 7.0f), fg, spots[i].glyph); + + if (!clicked) { + continue; + } + switch (spots[i].which) { + case Handle::Left: + case Handle::Right: + cmd.kind = PaneCommand::Kind::Split; + cmd.target = &leaf; + cmd.orient = Orient::Columns; + break; + case Handle::Top: + case Handle::Bottom: + cmd.kind = PaneCommand::Kind::Split; + cmd.target = &leaf; + cmd.orient = Orient::Rows; + break; + case Handle::Close: + cmd.kind = PaneCommand::Kind::Close; + cmd.target = &leaf; + break; + case Handle::None: + break; + } + } +} + +void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx) { + std::vector placed; + std::vector splitters; + tree.layout(area, placed, splitters); + + PaneCommand cmd; + + for (size_t i = 0u; i < placed.size(); i++) { + Rect r = placed[i].rect; + r.x += kInset; + r.y += kInset; + r.w -= 2.0 * kInset; + r.h -= 2.0 * kInset; + if (r.w <= 1.0 || r.h <= 1.0) { + continue; + } + + char id[32]; + std::snprintf(id, sizeof(id), "##pane%zu", i); + ImGui::SetCursorScreenPos(topLeft(r)); + drawLeaf(*placed[i].leaf, id, sizeOf(r), ctx); + + const ImVec2 m = ImGui::GetIO().MousePos; + if (r.contains(m.x, m.y)) { + drawHandles(*placed[i].leaf, r, cmd); + } + } + + /* Splitter drags. The grab zone is the gap the inset left behind. */ + for (size_t i = 0u; i < splitters.size(); i++) { + const Rect& s = splitters[i].rect; + ImGui::SetCursorScreenPos(topLeft(s)); + ImGui::PushID(static_cast(1000 + i)); + ImGui::InvisibleButton("##split", sizeOf(s)); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + ImGui::PopID(); + + if (hovered || active) { + ImGui::SetMouseCursor(splitters[i].orient == Orient::Columns + ? ImGuiMouseCursor_ResizeEW + : ImGuiMouseCursor_ResizeNS); + } + if (!active) { + continue; + } + /* Convert the drag into a ratio on the parent's own extent, so the + pointer stays glued to the border regardless of nesting depth. The + Splitter struct does not carry the parent's size, so recover it by + summing the child rects that this splitter separates. */ + const PaneNode* node = splitters[i].node; + const ImVec2 d = ImGui::GetIO().MouseDelta; + const double delta = (splitters[i].orient == Orient::Columns) ? d.x : d.y; + double parentExtent = 0.0; + for (size_t k = 0u; k < placed.size(); k++) { + const Rect& pr = placed[k].rect; + if (splitters[i].orient == Orient::Columns) { + if (pr.y <= s.y + 1.0 && pr.y + pr.h >= s.y + s.h - 1.0) { + parentExtent += pr.w; + } + } else { + if (pr.x <= s.x + 1.0 && pr.x + pr.w >= s.x + s.w - 1.0) { + parentExtent += pr.h; + } + } + } + if (parentExtent < kMinPaneSize) { + continue; + } + cmd.kind = PaneCommand::Kind::SetRatio; + cmd.target = const_cast(node); + cmd.ratio = node->ratio + delta / parentExtent; + } + + ApplyPaneCommand(tree, cmd); +} +``` + +`drawTree()` needs `#include ` for `snprintf` and ``; both are +already pulled in by `PaneView.cpp`'s existing includes. + +- [ ] **Step 7: Call it from App** + +Replace the last two lines of `App::drawPlotArea()`: + +```cpp + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + Rect area; + area.x = origin.x; + area.y = origin.y; + area.w = avail.x; + area.h = avail.y; + paneView_.drawTree(tree_, area, ctx); +``` + +- [ ] **Step 8: Build and check the interaction by hand** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–11. + +Run against the demo streamer and walk this checklist: + +1. Hover a pane: five handles appear (four edge midpoints, ✕ top-right). +2. Click the right handle: two side-by-side panes, the new one empty. +3. Drag a signal onto the new pane: it plots there and not in the first. +4. Click the bottom handle of the right pane: it splits into two rows. +5. Drag the border between the columns: both resize, the pointer stays on the + border, and neither pane goes below roughly 80 px. +6. Click ✕ on the bottom-right pane: it closes and its sibling takes the space. +7. Close panes until one is left, then click its ✕: nothing happens. + +- [ ] **Step 9: Commit** + +```bash +git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \ + Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \ + Client/udpscope/App.cpp Client/udpscope/tests/PaneCommandTest.cpp +git commit -m "feat(udpscope): splittable pane grid with inset handles and draggable splitters" +``` + +--- + +### Task 12: Shared X axis and per-trace vertical scale + +Two pieces of scope behaviour that are pure arithmetic and therefore fully +testable: the X axis that every pane shares and that follows the newest +sample until the user pans, and the per-trace vertical scale in the three +modes the spec calls for. + +Every pane's Y axis is a fixed ±4 divisions, exactly like a bench scope, and +each trace is mapped into division space by its own volts-per-division and +offset. `Auto` and `Range` compute those two numbers; `Manual` takes them from +the user. This is what makes traces with different units share a pane without +either being squashed. + +**Files:** +- Create: `Client/udpscope/Axes.h` +- Create: `Client/udpscope/Axes.cpp` +- Modify: `Client/udpscope/App.h`, `Client/udpscope/App.cpp` +- Modify: `Client/udpscope/PaneView.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` +- Test: `Client/udpscope/tests/AxesTest.cpp` + +**Interfaces:** +- Consumes: `VScale`, `VMode` (Task 2); `SignalMeta`, `Series` (Task 1). +- Produces: + +```cpp +constexpr double kDivisionsY = 8.0; // full pane height, ±4 +constexpr double kUsableDivsY = 6.0; // data fills 6 of the 8 + +void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs); +double ToDivisions(double value, const VScale& vs); + +class XAxisController { +public: + void setSpan(double sec); + double span() const; + void setLive(bool live); + bool live() const; + void followNewest(double newest); + void userRange(double x0, double x1); + double x0() const; + double x1() const; +}; +``` + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/AxesTest.cpp`: + +```cpp +#include "Axes.h" +#include +#include + +using namespace udpscope; + +namespace { + +SignalMeta meta(double lo, double hi) { + SignalMeta m; + m.name = "a"; + m.typeCode = 8; + m.rangeMin = lo; + m.rangeMax = hi; + return m; +} + +Series ramp(double lo, double hi, size_t n) { + Series s; + for (size_t i = 0; i < n; ++i) { + const double f = static_cast(i) / static_cast(n - 1); + s.t.push_back(static_cast(i)); + s.v.push_back(lo + f * (hi - lo)); + } + return s; +} + +} // namespace + +TEST(VScale, AutoCentresTheDataAndFillsTheUsableDivisions) { + VScale vs; + vs.mode = VMode::Auto; + const Series s = ramp(1.0, 3.0, 100); + ComputeVScale(meta(-10.0, 10.0), s, vs); + + EXPECT_NEAR(vs.offset, 2.0, 1e-9) << "midpoint should be screen centre"; + EXPECT_NEAR(vs.div, 2.0 / kUsableDivsY, 1e-9); + EXPECT_NEAR(ToDivisions(3.0, vs), kUsableDivsY / 2.0, 1e-9); + EXPECT_NEAR(ToDivisions(1.0, vs), -kUsableDivsY / 2.0, 1e-9); + EXPECT_NEAR(ToDivisions(2.0, vs), 0.0, 1e-9); +} + +// range_min/range_max are already in the CONFIG packet and are the physically +// meaningful full scale, so Range must not peek at the data. +TEST(VScale, RangeUsesTheConfigFullScaleNotTheData) { + VScale vs; + vs.mode = VMode::Range; + const Series s = ramp(1.0, 1.001, 100); + ComputeVScale(meta(-10.0, 10.0), s, vs); + + EXPECT_NEAR(vs.offset, 0.0, 1e-9); + EXPECT_NEAR(vs.div, 20.0 / kUsableDivsY, 1e-9); + EXPECT_NEAR(ToDivisions(10.0, vs), kUsableDivsY / 2.0, 1e-9); +} + +TEST(VScale, ManualIsLeftExactlyAsTheUserSetIt) { + VScale vs; + vs.mode = VMode::Manual; + vs.div = 0.25; + vs.offset = 1.5; + ComputeVScale(meta(-10.0, 10.0), ramp(0.0, 100.0, 10), vs); + + EXPECT_DOUBLE_EQ(vs.div, 0.25); + EXPECT_DOUBLE_EQ(vs.offset, 1.5); + EXPECT_DOUBLE_EQ(ToDivisions(1.75, vs), 1.0); +} + +// A flat signal has zero peak-to-peak; dividing by it would put the trace at +// infinity instead of on the centre line. +TEST(VScale, AConstantSignalGetsAUsableScale) { + VScale vs; + vs.mode = VMode::Auto; + Series s; + for (int i = 0; i < 10; ++i) { s.t.push_back(i); s.v.push_back(7.0); } + ComputeVScale(meta(0.0, 0.0), s, vs); + + EXPECT_GT(vs.div, 0.0); + EXPECT_TRUE(std::isfinite(ToDivisions(7.0, vs))); + EXPECT_NEAR(ToDivisions(7.0, vs), 0.0, 1e-9); +} + +TEST(VScale, AutoOnAnEmptySeriesLeavesTheScaleUsable) { + VScale vs; + vs.mode = VMode::Auto; + const Series empty; + ComputeVScale(meta(0.0, 0.0), empty, vs); + EXPECT_GT(vs.div, 0.0); +} + +TEST(XAxis, LiveFollowsTheNewestSample) { + XAxisController x; + x.setSpan(2.0); + x.followNewest(100.0); + EXPECT_DOUBLE_EQ(x.x1(), 100.0); + EXPECT_DOUBLE_EQ(x.x0(), 98.0); + x.followNewest(101.0); + EXPECT_DOUBLE_EQ(x.x1(), 101.0); +} + +TEST(XAxis, AUserRangeDetachesFromLive) { + XAxisController x; + x.setSpan(2.0); + x.followNewest(100.0); + x.userRange(10.0, 12.5); + EXPECT_FALSE(x.live()); + EXPECT_DOUBLE_EQ(x.x0(), 10.0); + EXPECT_DOUBLE_EQ(x.span(), 2.5) << "a zoom must redefine the span"; + + x.followNewest(200.0); + EXPECT_DOUBLE_EQ(x.x0(), 10.0) << "detached axis must not be dragged along"; +} + +TEST(XAxis, ReattachingSnapsBackToTheNewestSample) { + XAxisController x; + x.setSpan(2.0); + x.userRange(10.0, 12.0); + ASSERT_FALSE(x.live()); + x.setLive(true); + x.followNewest(300.0); + EXPECT_TRUE(x.live()); + EXPECT_DOUBLE_EQ(x.x1(), 300.0); + EXPECT_DOUBLE_EQ(x.x0(), 298.0); +} + +TEST(XAxis, ChangingTheSpanKeepsTheRightEdgePinned) { + XAxisController x; + x.setSpan(2.0); + x.followNewest(100.0); + x.setSpan(0.5); + EXPECT_DOUBLE_EQ(x.x1(), 100.0); + EXPECT_DOUBLE_EQ(x.x0(), 99.5); +} + +TEST(XAxis, ADegenerateRangeIsRejected) { + XAxisController x; + x.setSpan(2.0); + x.userRange(5.0, 5.0); + EXPECT_GT(x.span(), 0.0); + x.setSpan(0.0); + EXPECT_GT(x.span(), 0.0); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `Axes.h: No such file or directory`. + +- [ ] **Step 3: Write `Axes.h`** + +Create `Client/udpscope/Axes.h`: + +```cpp +/** + * @file Axes.h + * @brief Shared X axis behaviour and per-trace vertical scaling. + * + * Panes always show ±4 divisions vertically, as a bench scope does. Each trace + * carries its own volts-per-division and offset, so traces with different units + * can share a pane without one flattening the other. + */ +#ifndef UDPSCOPE_AXES_H +#define UDPSCOPE_AXES_H + +#include "PaneTree.h" +#include "Types.h" + +namespace udpscope { + +/** Full pane height in divisions. */ +constexpr double kDivisionsY = 8.0; +/** Divisions the data is scaled to fill, leaving one at the top and bottom. */ +constexpr double kUsableDivsY = 6.0; + +/** + * Fill vs.div and vs.offset for Auto and Range. Manual is left untouched. + * Never leaves div at zero, whatever the data does. + */ +void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs); + +/** Map a value into division space: 0 is the centre line. */ +double ToDivisions(double value, const VScale& vs); + +/** + * The one X range every pane shares. + * + * Live mode pins the right edge to the newest sample. Any pan or zoom detaches + * it — silently re-attaching would fight the user every frame — and the Live + * button re-attaches. + */ +class XAxisController { +public: + void setSpan(double sec); + double span() const { return span_; } + void setLive(bool live) { live_ = live; } + bool live() const { return live_; } + /** Called once a frame while live. Ignored when detached. */ + void followNewest(double newest); + /** Records a user pan or zoom and detaches. */ + void userRange(double x0, double x1); + double x0() const { return x1_ - span_; } + double x1() const { return x1_; } + +private: + double span_ = 1.0; + double x1_ = 1.0; + bool live_ = true; +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_AXES_H */ +``` + +- [ ] **Step 4: Write `Axes.cpp`** + +Create `Client/udpscope/Axes.cpp`: + +```cpp +#include "Axes.h" + +#include +#include + +namespace udpscope { + +namespace { + +/** Turn a full-scale span into a per-division value, never zero. */ +double divFromSpan(double span) { + if (!(span > 0.0) || !std::isfinite(span)) { + return 1.0; + } + return span / kUsableDivsY; +} + +} /* namespace */ + +void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs) { + if (vs.mode == VMode::Manual) { + if (!(vs.div > 0.0) || !std::isfinite(vs.div)) { + vs.div = 1.0; + } + return; + } + + if (vs.mode == VMode::Range) { + /* The CONFIG full scale, whatever the data happens to be doing. */ + vs.offset = 0.5 * (meta.rangeMin + meta.rangeMax); + vs.div = divFromSpan(meta.rangeMax - meta.rangeMin); + return; + } + + /* Auto: fit the samples actually on screen. */ + if (raw.empty()) { + vs.offset = 0.0; + vs.div = 1.0; + return; + } + double lo = raw.v[0]; + double hi = raw.v[0]; + for (size_t i = 1u; i < raw.v.size(); i++) { + lo = std::min(lo, raw.v[i]); + hi = std::max(hi, raw.v[i]); + } + vs.offset = 0.5 * (lo + hi); + vs.div = divFromSpan(hi - lo); /* a flat trace lands on the centre */ +} + +double ToDivisions(double value, const VScale& vs) { + const double d = (vs.div > 0.0 && std::isfinite(vs.div)) ? vs.div : 1.0; + return (value - vs.offset) / d; +} + +void XAxisController::setSpan(double sec) { + if (sec > 0.0 && std::isfinite(sec)) { + span_ = sec; /* x1_ is unchanged, so the right edge stays pinned */ + } +} + +void XAxisController::followNewest(double newest) { + if (live_ && std::isfinite(newest)) { + x1_ = newest; + } +} + +void XAxisController::userRange(double x0, double x1) { + if (!(x1 > x0) || !std::isfinite(x0) || !std::isfinite(x1)) { + return; + } + span_ = x1 - x0; + x1_ = x1; + live_ = false; +} + +} /* namespace udpscope */ +``` + +Add `Axes.cpp` to `CORE_SOURCES`. + +- [ ] **Step 5: Run the axis tests and verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='VScale*:XAxis*' +``` + +Expected: PASS, 10 tests. + +- [ ] **Step 6: Draw traces in division space** + +In `Client/udpscope/PaneView.cpp`, add `#include "Axes.h"` and replace the +plotting body of `drawLeaf()`'s loop (between the `FetchLiveTrace` call and +`BeginLegendPopup`) with: + +```cpp + if (!ok || d.draw.empty()) { + continue; + } + ComputeVScale(ctx.metaFor(a.signalName), d.raw, a.vs); + + /* Reuse one scratch buffer per pane rather than allocating per trace + at 60 Hz. */ + divScratch_.resize(d.draw.size()); + for (size_t k = 0u; k < d.draw.size(); k++) { + divScratch_[k] = ToDivisions(d.draw.v[k], a.vs); + } + + char label[128]; + std::snprintf(label, sizeof(label), "%s %.4g %s/div", a.signalName.c_str(), + a.vs.div, ctx.metaFor(a.signalName).unit.empty() + ? "u" : ctx.metaFor(a.signalName).unit.c_str()); + ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth); + ImPlot::PlotLine(label, d.draw.t.data(), divScratch_.data(), + static_cast(d.draw.size())); +``` + +The legend popup key must change with the label, so replace +`ImPlot::BeginLegendPopup(a.signalName.c_str())` with +`ImPlot::BeginLegendPopup(label)`, and add the vertical-scale controls to it: + +```cpp + const char* modes[] = {"auto", "range", "manual"}; + int mode = static_cast(a.vs.mode); + if (ImGui::Combo("v-scale", &mode, modes, 3)) { + a.vs.mode = static_cast(mode); + } + if (a.vs.mode == VMode::Manual) { + ImGui::InputDouble("per div", &a.vs.div, 0.0, 0.0, "%.6g"); + ImGui::InputDouble("offset", &a.vs.offset, 0.0, 0.0, "%.6g"); + } +``` + +Add to `PaneView`'s private section: + +```cpp + std::vector divScratch_; +``` + +and to `PaneContext`: + +```cpp + const std::vector* metas = nullptr; + /** Metadata for a signal, or a default-constructed one if it is gone. */ + const SignalMeta& metaFor(const std::string& name) const; +``` + +implemented in `PaneView.cpp`: + +```cpp +const SignalMeta& PaneContext::metaFor(const std::string& name) const { + static const SignalMeta kUnknown; + if (metas != nullptr) { + for (size_t i = 0u; i < metas->size(); i++) { + if ((*metas)[i].name == name) { + return (*metas)[i]; + } + } + } + return kUnknown; +} +``` + +Finally pin the Y axis and let ImPlot report the user's X range. Replace the +`SetupAxes`/`SetupAxisLimits` pair with: + +```cpp + ImPlot::SetupAxes("t [s]", "div"); + ImPlot::SetupAxisLimits(ImAxis_X1, ctx.x0, ctx.x1, + ctx.xLive ? ImPlotCond_Always : ImPlotCond_Once); + ImPlot::SetupAxisLimits(ImAxis_Y1, -kDivisionsY * 0.5, kDivisionsY * 0.5, + ImPlotCond_Always); +``` + +and just before `ImPlot::EndPlot()`: + +```cpp + /* A pan or zoom in any pane redefines the shared range for all of them. */ + if (ImPlot::IsPlotHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + const ImPlotRect lim = ImPlot::GetPlotLimits(); + ctx.userX0 = lim.X.Min; + ctx.userX1 = lim.X.Max; + ctx.userChanged = true; + } + if (ImPlot::IsPlotHovered() && ImGui::GetIO().MouseWheel != 0.0f) { + const ImPlotRect lim = ImPlot::GetPlotLimits(); + ctx.userX0 = lim.X.Min; + ctx.userX1 = lim.X.Max; + ctx.userChanged = true; + } +``` + +with the three new `PaneContext` fields: + +```cpp + bool xLive = true; + double userX0 = 0.0, userX1 = 0.0; + bool userChanged = false; +``` + +- [ ] **Step 7: Own the axis in App** + +In `App.h`, add `#include "Axes.h"` and replace `double xSpanSec_` with: + +```cpp + XAxisController xaxis_; +``` + +In `App.cpp`, replace the range computation in `drawPlotArea()` with: + +```cpp + double newest = 0.0; + bool any = false; + for (size_t i = 0u; i < sigs_.size(); i++) { + double o = 0.0, n = 0.0; + if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) { + newest = n; + any = true; + } + } + if (any) { + xaxis_.followNewest(newest); + } + ctx.x0 = xaxis_.x0(); + ctx.x1 = xaxis_.x1(); + ctx.xLive = xaxis_.live(); + ctx.metas = &sigs_; +``` + +and after `paneView_.drawTree(...)`: + +```cpp + if (ctx.userChanged) { + xaxis_.userRange(ctx.userX0, ctx.userX1); + } +``` + +Add the live control to the View menu in `drawMenuBar()`: + +```cpp + if (ImGui::BeginMenu("View")) { + bool live = xaxis_.live(); + if (ImGui::MenuItem("Live", "L", &live)) { + xaxis_.setLive(live); + } + double span = xaxis_.span(); + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("window [s]", &span, 0.0, 0.0, "%.4g")) { + xaxis_.setSpan(span); + } + ImGui::EndMenu(); + } +``` + +- [ ] **Step 8: Build and verify by hand** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–12. + +Against the demo streamer: + +1. Two panes, a signal in each: both scroll together. +2. Drag left in one pane: both stop following and pan together; View → Live + is now unchecked. +3. Tick View → Live: both snap back to the newest sample. +4. Set View → window to 0.05: both show 50 ms, right edge pinned. +5. Right-click a legend entry, set v-scale to `range`: the trace rescales to + the CONFIG full scale and the label shows the new value per division. +6. Set it to `manual` with a small per-div: the trace clips off the top and + bottom of its ±4 divisions, as a bench scope does. + +- [ ] **Step 9: Commit** + +```bash +git add Client/udpscope/Axes.h Client/udpscope/Axes.cpp \ + Client/udpscope/App.h Client/udpscope/App.cpp Client/udpscope/PaneView.h \ + Client/udpscope/PaneView.cpp Client/udpscope/tests/AxesTest.cpp \ + Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): shared X axis with live follow and per-trace division scaling" +``` + +--- + +### Task 13: Trigger bar and capture display + +**Files:** +- Create: `Client/udpscope/CaptureLatch.h` +- Create: `Client/udpscope/CaptureLatch.cpp` +- Create: `Client/udpscope/tests/CaptureLatchTest.cpp` +- Create: `Client/udpscope/TriggerBar.cpp` +- Modify: `Client/udpscope/App.h` +- Modify: `Client/udpscope/App.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` + +**Interfaces:** +- Consumes: `SignalStore::captureSeq()`, `SignalStore::readCapture()`, + `Capture` (Task 6); `Receiver::setTrigConfig/trigConfig/arm/disarm/rearm/trigStatus` + and `TrigStatus` (Task 7); `TrigConfig`, `Edge`, `TrigMode`, `TrigState` + (Task 5); `PaneContext::capture` (Task 10); `XAxisController` (Task 12). +- Produces: `class CaptureLatch` with `poll(const SignalStore&) -> bool`, + `showing()`, `setFollow(bool)`, `follow()`, `returnToLive()`, + `capture() -> const Capture&`, `x0()`, `x1()`, `seen() -> uint64_t`; and + `std::string TrigBadge(const TrigStatus&)`. Task 16 exports + `CaptureLatch::capture()` to CSV. + +**Why a latch rather than reading the store directly in the draw call:** +the pane tree draws many leaves per frame and each one needs the *same* +capture, so the copy out of the store must happen once per frame, not once +per pane. The latch also owns the freeze decision — a user studying one +capture must not have it swapped out from under them by the next trigger — +and that decision is pure state machine, so it is unit-testable without a +window. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/CaptureLatchTest.cpp`: + +```cpp +#include "CaptureLatch.h" + +#include +#include + +using namespace udpscope; + +namespace { + +/* Publish a capture whose bounds identify it, so the tests can tell which + one the latch is holding. */ +void publish(SignalStore& s, double t0, double t1) { + Capture c; + c.trigTime = 0.5 * (t0 + t1); + c.t0 = t0; + c.t1 = t1; + c.names.push_back("sig"); + Series ser; + ser.t.push_back(t0); + ser.v.push_back(1.0); + c.series.push_back(ser); + s.publishCapture(std::move(c)); +} + +TrigStatus status(TrigState st, double fill) { + TrigStatus s; + s.state = st; + s.fill = fill; + return s; +} + +} // namespace + +TEST(CaptureLatch, FreshLatchShowsLive) { + CaptureLatch latch; + SignalStore store; + EXPECT_FALSE(latch.showing()); + EXPECT_FALSE(latch.poll(store)); + EXPECT_FALSE(latch.showing()); + EXPECT_TRUE(latch.follow()); +} + +TEST(CaptureLatch, PollAdoptsTheFirstCapture) { + CaptureLatch latch; + SignalStore store; + publish(store, 1.0, 2.0); + + EXPECT_TRUE(latch.poll(store)); + ASSERT_TRUE(latch.showing()); + EXPECT_DOUBLE_EQ(latch.x0(), 1.0); + EXPECT_DOUBLE_EQ(latch.x1(), 2.0); + EXPECT_EQ(latch.capture().names.size(), 1u); +} + +// The draw loop polls every frame; only a genuinely new capture is news. +TEST(CaptureLatch, PollIsIdempotentWithoutANewCapture) { + CaptureLatch latch; + SignalStore store; + publish(store, 1.0, 2.0); + + ASSERT_TRUE(latch.poll(store)); + EXPECT_FALSE(latch.poll(store)); + EXPECT_FALSE(latch.poll(store)); + EXPECT_TRUE(latch.showing()); +} + +TEST(CaptureLatch, FollowingLatchAdoptsTheNewerCapture) { + CaptureLatch latch; + SignalStore store; + publish(store, 1.0, 2.0); + ASSERT_TRUE(latch.poll(store)); + + publish(store, 5.0, 6.0); + EXPECT_TRUE(latch.poll(store)); + EXPECT_DOUBLE_EQ(latch.x0(), 5.0); +} + +// Studying a waveform must not be interrupted by the next trigger. +TEST(CaptureLatch, FrozenLatchKeepsTheDisplayedCapture) { + CaptureLatch latch; + SignalStore store; + publish(store, 1.0, 2.0); + ASSERT_TRUE(latch.poll(store)); + + latch.setFollow(false); + publish(store, 5.0, 6.0); + EXPECT_FALSE(latch.poll(store)); + EXPECT_DOUBLE_EQ(latch.x0(), 1.0); +} + +// Un-freezing must not have to wait for yet another trigger: the capture that +// arrived while frozen is still the newest one, and it is adopted at once. +TEST(CaptureLatch, UnfreezingAdoptsTheCaptureThatArrivedWhileFrozen) { + CaptureLatch latch; + SignalStore store; + publish(store, 1.0, 2.0); + ASSERT_TRUE(latch.poll(store)); + latch.setFollow(false); + publish(store, 5.0, 6.0); + ASSERT_FALSE(latch.poll(store)); + + latch.setFollow(true); + EXPECT_TRUE(latch.poll(store)); + EXPECT_DOUBLE_EQ(latch.x0(), 5.0); +} + +TEST(CaptureLatch, ReturnToLiveDropsTheDisplayedCapture) { + CaptureLatch latch; + SignalStore store; + publish(store, 1.0, 2.0); + ASSERT_TRUE(latch.poll(store)); + + latch.returnToLive(); + EXPECT_FALSE(latch.showing()); + EXPECT_FALSE(latch.poll(store)); // the same capture is not re-adopted +} + +TEST(CaptureLatch, ReturnToLiveIsUndoneByTheNextTrigger) { + CaptureLatch latch; + SignalStore store; + publish(store, 1.0, 2.0); + ASSERT_TRUE(latch.poll(store)); + latch.returnToLive(); + + publish(store, 5.0, 6.0); + EXPECT_TRUE(latch.poll(store)); + EXPECT_TRUE(latch.showing()); + EXPECT_DOUBLE_EQ(latch.x0(), 5.0); +} + +TEST(TrigBadge, IdleReadsIdle) { + EXPECT_EQ(TrigBadge(status(TrigState::Idle, 0.0)), "IDLE"); +} + +TEST(TrigBadge, ArmedShowsTheFillPercentage) { + EXPECT_EQ(TrigBadge(status(TrigState::Armed, 0.625)), "ARMED 62%"); + EXPECT_EQ(TrigBadge(status(TrigState::Armed, 1.0)), "ARMED 100%"); +} + +// fill is a ratio computed from live timestamps and can overshoot slightly. +TEST(TrigBadge, ArmedPercentageIsClamped) { + EXPECT_EQ(TrigBadge(status(TrigState::Armed, 1.4)), "ARMED 100%"); + EXPECT_EQ(TrigBadge(status(TrigState::Armed, -0.2)), "ARMED 0%"); +} + +TEST(TrigBadge, CollectingAndHeldHaveTheirOwnLabels) { + EXPECT_EQ(TrigBadge(status(TrigState::Collecting, 1.0)), "TRIG'D"); + EXPECT_EQ(TrigBadge(status(TrigState::Held, 1.0)), "HELD"); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `CaptureLatch.h: No such file or directory`. + +- [ ] **Step 3: Write `CaptureLatch.h`** + +Create `Client/udpscope/CaptureLatch.h`: + +```cpp +/** + * @file CaptureLatch.h + * @brief Decides, once per frame, whether the panes draw live data or a + * harvested trigger capture. + */ +#ifndef UDPSCOPE_CAPTURELATCH_H +#define UDPSCOPE_CAPTURELATCH_H + +#include "Receiver.h" +#include "SignalStore.h" + +#include +#include + +namespace udpscope { + +/** Human-readable trigger state for the trigger bar badge. */ +std::string TrigBadge(const TrigStatus& st); + +/** + * Holds the capture the panes are drawing. The receiver thread publishes + * captures into the SignalStore; this pulls at most one copy per frame so + * that every pane in the tree draws the same waveform. + */ +class CaptureLatch { +public: + /** + * Adopts a newly published capture if there is one and the latch is + * following. Returns true only on the frame the capture changes, which + * is when the caller should re-range the X axis. + */ + bool poll(const SignalStore& store); + + /** True when capture() is valid and the panes should draw it. */ + bool showing() const { return showing_; } + + /** The capture being displayed; only meaningful while showing(). */ + const Capture& capture() const { return cap_; } + + double x0() const { return cap_.t0; } + double x1() const { return cap_.t1; } + + /** Sequence number of the capture last adopted; 0 before the first. */ + uint64_t seen() const { return seen_; } + + /** When false, new captures are ignored until following resumes. */ + void setFollow(bool on) { follow_ = on; } + bool follow() const { return follow_; } + + /** Drops the displayed capture and goes back to live data. */ + void returnToLive() { showing_ = false; } + +private: + Capture cap_; + uint64_t seen_ = 0u; + bool showing_ = false; + bool follow_ = true; +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_CAPTURELATCH_H */ +``` + +- [ ] **Step 4: Write `CaptureLatch.cpp`** + +Create `Client/udpscope/CaptureLatch.cpp`: + +```cpp +#include "CaptureLatch.h" + +#include +#include +#include + +namespace udpscope { + +std::string TrigBadge(const TrigStatus& st) { + switch (st.state) { + case TrigState::Armed: { + double f = st.fill; + if (!std::isfinite(f)) { + f = 0.0; + } + f = std::min(1.0, std::max(0.0, f)); + char buf[32]; + std::snprintf(buf, sizeof(buf), "ARMED %d%%", + static_cast(f * 100.0)); + return std::string(buf); + } + case TrigState::Collecting: + return "TRIG'D"; + case TrigState::Held: + return "HELD"; + case TrigState::Idle: + default: + return "IDLE"; + } +} + +bool CaptureLatch::poll(const SignalStore& store) { + if (!follow_) { + return false; + } + /* seen_ is only advanced when a capture is actually adopted, so a + capture published while frozen is picked up as soon as following + resumes. */ + if (store.captureSeq() == seen_) { + return false; + } + Capture fresh; + if (!store.readCapture(fresh)) { + return false; + } + cap_ = std::move(fresh); + seen_ = cap_.seq; + showing_ = true; + return true; +} + +} /* namespace udpscope */ +``` + +Add `CaptureLatch.cpp` to `CORE_SOURCES` in `Client/udpscope/CMakeLists.txt`. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='CaptureLatch*:TrigBadge*' +``` + +Expected: PASS, 12 tests. + +- [ ] **Step 6: Commit the latch** + +```bash +git add Client/udpscope/CaptureLatch.h Client/udpscope/CaptureLatch.cpp \ + Client/udpscope/tests/CaptureLatchTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): capture latch and trigger state badge" +``` + +- [ ] **Step 7: Add the trigger bar to App.h** + +In `Client/udpscope/App.h`, add the include and members: + +```cpp +#include "CaptureLatch.h" +``` + +Add to the private method list, next to `drawMenuBar()`: + +```cpp + void drawTriggerBar(); + /** Pushes the edited config into the receiver and re-ranges the panes. */ + void applyTrigConfig(); +``` + +and to the private data, after `PaneTree tree_;`: + +```cpp + CaptureLatch latch_; + TrigConfig trig_; /**< the bar's editable copy */ + int trigSignal_ = -1; /**< index into sigs_, -1 = none */ +``` + +- [ ] **Step 8: Draw the trigger bar** + +The bar gets its own translation unit, as spec §3.4 lays out; the functions +are still `App` methods so they reach the receiver and the latch directly. + +Create `Client/udpscope/TriggerBar.cpp`: + +```cpp +#include "App.h" + +#include "imgui.h" + +namespace udpscope { + +void App::applyTrigConfig() { + rx_.setTrigConfig(trig_); +} + +void App::drawTriggerBar() { + ImGui::BeginChild("trigbar", ImVec2(0.0f, ImGui::GetFrameHeightWithSpacing() + 6.0f), + false, ImGuiWindowFlags_NoScrollbar); + + /* Signal. The list can change under us on a CONFIG re-send, so the + selection is re-resolved by name every frame. */ + trigSignal_ = -1; + for (size_t i = 0u; i < sigs_.size(); i++) { + if (sigs_[i].name == trig_.signalName) { + trigSignal_ = static_cast(i); + break; + } + } + const char* preview = (trigSignal_ >= 0) ? trig_.signalName.c_str() : "(none)"; + ImGui::SetNextItemWidth(160.0f); + if (ImGui::BeginCombo("##trigsig", preview)) { + for (size_t i = 0u; i < sigs_.size(); i++) { + const bool sel = (static_cast(i) == trigSignal_); + if (ImGui::Selectable(sigs_[i].name.c_str(), sel)) { + trig_.signalName = sigs_[i].name; + applyTrigConfig(); + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + + const char* edges[] = {"rising", "falling", "both"}; + int edge = static_cast(trig_.edge); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::Combo("##trigedge", &edge, edges, 3)) { + trig_.edge = static_cast(edge); + applyTrigConfig(); + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("thr", &trig_.threshold, 0.0, 0.0, "%.6g", + ImGuiInputTextFlags_EnterReturnsTrue)) { + applyTrigConfig(); + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("hyst", &trig_.hysteresis, 0.0, 0.0, "%.6g", + ImGuiInputTextFlags_EnterReturnsTrue)) { + if (trig_.hysteresis < 0.0) { + trig_.hysteresis = 0.0; + } + applyTrigConfig(); + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("win [s]", &trig_.windowSec, 0.0, 0.0, "%.6g", + ImGuiInputTextFlags_EnterReturnsTrue)) { + if (!(trig_.windowSec > 0.0)) { + trig_.windowSec = 0.1; + } + applyTrigConfig(); + } + ImGui::SameLine(); + + float pre = static_cast(trig_.prePercent); + ImGui::SetNextItemWidth(120.0f); + if (ImGui::SliderFloat("pre %", &pre, 0.0f, 90.0f, "%.0f")) { + trig_.prePercent = static_cast(pre); + applyTrigConfig(); + } + ImGui::SameLine(); + + int mode = (trig_.mode == TrigMode::Single) ? 1 : 0; + if (ImGui::RadioButton("Norm", mode == 0)) { + trig_.mode = TrigMode::Normal; + applyTrigConfig(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("1x", mode == 1)) { + trig_.mode = TrigMode::Single; + applyTrigConfig(); + } + ImGui::SameLine(); + + /* Badge. Amber while waiting for the pre-window to fill, green once the + capture is on screen. */ + const TrigStatus ts = rx_.trigStatus(); + ImVec4 badge(0.68f, 0.71f, 0.75f, 1.0f); /* overlay1 */ + if (ts.state == TrigState::Armed) { + badge = ImVec4(0.98f, 0.70f, 0.53f, 1.0f); /* peach */ + } else if (ts.state == TrigState::Collecting || ts.state == TrigState::Held) { + badge = ImVec4(0.65f, 0.89f, 0.63f, 1.0f); /* green */ + } + ImGui::TextColored(badge, "%s", TrigBadge(ts).c_str()); + ImGui::SameLine(); + + if (ImGui::Button("Arm")) { + applyTrigConfig(); + rx_.arm(); + } + ImGui::SameLine(); + if (ImGui::Button("Disarm")) { + rx_.disarm(); + } + ImGui::SameLine(); + if (ImGui::Button("Re-arm")) { + rx_.rearm(); + } + ImGui::SameLine(); + + bool follow = latch_.follow(); + if (ImGui::Checkbox("follow", &follow)) { + latch_.setFollow(follow); + } + ImGui::SameLine(); + + ImGui::BeginDisabled(!latch_.showing()); + if (ImGui::Button("Live")) { + latch_.returnToLive(); + xaxis_.setLive(true); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::TextDisabled("captures: %llu", + static_cast(ts.captures)); + + ImGui::EndChild(); + ImGui::Separator(); +} + +} /* namespace udpscope */ +``` + +Add `TriggerBar.cpp` to `APP_SOURCES`. + +**Why `EnterReturnsTrue` on the numeric fields:** without it every keystroke +mid-edit is pushed to the receiver, so typing `0.05` momentarily configures a +window of `0`, then `0.0`, resetting the FSM three times and throwing away a +half-filled pre-window. The combos and slider have no such intermediate +states and apply immediately. + +- [ ] **Step 9: Wire the latch into the frame** + +In `App::draw()`, immediately after `syncSignals()` and before the panes are +drawn, add: + +```cpp + if (latch_.poll(store_)) { + /* A capture just arrived: pin the shared axis to its window. This + detaches the axis from live follow, which is what we want — the + user can then zoom inside the capture with the normal controls. */ + xaxis_.userRange(latch_.x0(), latch_.x1()); + } +``` + +and add `drawTriggerBar();` to the frame between `drawMenuBar()` and the +signal list, matching spec §8.1's ordering. + +In `App::drawPlotArea()`, replace `ctx.capture = nullptr;` with: + +```cpp + ctx.capture = latch_.showing() ? &latch_.capture() : nullptr; +``` + +- [ ] **Step 10: Build and verify by hand** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–13. + +Against the demo streamer: + +```bash +source env.sh +"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" -l RealTimeLoader \ + -f Test/Configurations/streamhub_demo.cfg -s Running -m StateMachine:START & +./Client/udpscope/build/UDPScope --port 44501 +``` + +1. Drop a sine into a pane; the badge reads `IDLE`. +2. Pick that signal in the trigger bar, threshold `0`, window `0.02`, + pre `20`, `Norm`, press **Arm**: the badge goes `ARMED 0%` → `ARMED 100%` + within a fraction of a second, then flashes `TRIG'D` on each trigger. +3. The pane freezes onto a stable waveform whose rising edge sits 20 % in + from the left, and the capture counter climbs. +4. Untick **follow**: the waveform stops updating while the counter keeps + climbing. Tick it again: it jumps straight to the newest capture. +5. Press **Live**: the pane scrolls again and the **Live** button greys out. +6. Switch to `1x` and press **Arm**: exactly one capture appears, the badge + settles on `HELD`, and **Re-arm** produces the next one. +7. Set the threshold above the sine's amplitude: the badge stays + `ARMED 100%` and no new captures arrive. + +- [ ] **Step 11: Commit** + +```bash +git add Client/udpscope/App.h Client/udpscope/App.cpp \ + Client/udpscope/TriggerBar.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): trigger bar with arm/disarm and capture display" +``` + +--- + +### Task 14: Cursors and measurements + +**Files:** +- Create: `Client/udpscope/Measure.h` +- Create: `Client/udpscope/Measure.cpp` +- Create: `Client/udpscope/tests/MeasureTest.cpp` +- Modify: `Client/udpscope/PaneView.h` +- Modify: `Client/udpscope/PaneView.cpp` +- Modify: `Client/udpscope/App.h` +- Modify: `Client/udpscope/App.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` + +**Interfaces:** +- Consumes: `Series` (Task 1), `TraceData::raw` (Task 10), `PaneContext` + (Tasks 10/12), `VScale`/`ToDivisions` (Task 12). +- Produces: `struct Stats`, `ComputeStats(const Series&, double t0, double t1)`, + `bool SampleAt(const Series&, double t, double& v)`, `struct Cursors` + (`enabled`, `tA`, `tB`, `dt()`, `freq()`). Task 15 persists `Cursors`. + +**Why statistics come from `TraceData::raw`:** the drawn series is a min/max +envelope, two points per bucket. Its extremes are correct, but a mean or RMS +over it weights each bucket equally instead of each sample, so a burst of +1000 samples in one bucket would count the same as a bucket holding two. +Spec §8.4 requires true statistics, so `ComputeStats` reads the undecimated +series. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/MeasureTest.cpp`: + +```cpp +#include "Measure.h" + +#include +#include + +using namespace udpscope; + +namespace { + +Series ramp() { + /* t = 0,1,2,3,4 v = 0,1,2,3,4 */ + Series s; + for (int i = 0; i < 5; ++i) { + s.t.push_back(static_cast(i)); + s.v.push_back(static_cast(i)); + } + return s; +} + +} // namespace + +TEST(ComputeStats, EmptySeriesIsInvalid) { + Series s; + const Stats st = ComputeStats(s, 0.0, 1.0); + EXPECT_FALSE(st.valid); + EXPECT_EQ(st.count, 0u); +} + +TEST(ComputeStats, ReportsMinMaxPeakToPeakMeanAndRms) { + Series s; + s.t = {0.0, 1.0, 2.0, 3.0}; + s.v = {-2.0, 0.0, 0.0, 2.0}; + + const Stats st = ComputeStats(s, -1.0, 10.0); + ASSERT_TRUE(st.valid); + EXPECT_EQ(st.count, 4u); + EXPECT_DOUBLE_EQ(st.min, -2.0); + EXPECT_DOUBLE_EQ(st.max, 2.0); + EXPECT_DOUBLE_EQ(st.pp, 4.0); + EXPECT_DOUBLE_EQ(st.mean, 0.0); + EXPECT_NEAR(st.rms, std::sqrt(8.0 / 4.0), 1e-12); +} + +// With cursors on, the statistics describe the span between them, not the +// whole visible trace. +TEST(ComputeStats, RestrictsItselfToTheGivenWindow) { + const Series s = ramp(); + const Stats st = ComputeStats(s, 1.0, 3.0); + ASSERT_TRUE(st.valid); + EXPECT_EQ(st.count, 3u); + EXPECT_DOUBLE_EQ(st.min, 1.0); + EXPECT_DOUBLE_EQ(st.max, 3.0); + EXPECT_DOUBLE_EQ(st.mean, 2.0); +} + +TEST(ComputeStats, ReversedWindowIsAcceptedAsIs) { + const Series s = ramp(); + const Stats st = ComputeStats(s, 3.0, 1.0); + ASSERT_TRUE(st.valid); + EXPECT_EQ(st.count, 3u); +} + +TEST(ComputeStats, WindowWithNoSamplesIsInvalid) { + const Series s = ramp(); + const Stats st = ComputeStats(s, 10.0, 11.0); + EXPECT_FALSE(st.valid); +} + +TEST(ComputeStats, IgnoresNonFiniteSamples) { + Series s; + s.t = {0.0, 1.0, 2.0}; + s.v = {1.0, std::nan(""), 3.0}; + + const Stats st = ComputeStats(s, 0.0, 2.0); + ASSERT_TRUE(st.valid); + EXPECT_EQ(st.count, 2u); + EXPECT_DOUBLE_EQ(st.mean, 2.0); +} + +TEST(SampleAt, InterpolatesBetweenSamples) { + const Series s = ramp(); + double v = 0.0; + ASSERT_TRUE(SampleAt(s, 2.25, v)); + EXPECT_DOUBLE_EQ(v, 2.25); +} + +TEST(SampleAt, ReturnsTheEndpointsExactly) { + const Series s = ramp(); + double v = 0.0; + ASSERT_TRUE(SampleAt(s, 0.0, v)); + EXPECT_DOUBLE_EQ(v, 0.0); + ASSERT_TRUE(SampleAt(s, 4.0, v)); + EXPECT_DOUBLE_EQ(v, 4.0); +} + +// A cursor dragged off the end of the data has no value to report; it must +// not clamp, or the readout would silently lie. +TEST(SampleAt, FailsOutsideTheSeries) { + const Series s = ramp(); + double v = 0.0; + EXPECT_FALSE(SampleAt(s, -0.5, v)); + EXPECT_FALSE(SampleAt(s, 4.5, v)); + Series empty; + EXPECT_FALSE(SampleAt(empty, 0.0, v)); +} + +TEST(SampleAt, HandlesRepeatedTimestamps) { + /* A min/max envelope stores two points at the same time. */ + Series s; + s.t = {0.0, 1.0, 1.0, 2.0}; + s.v = {0.0, -5.0, 5.0, 0.0}; + double v = 0.0; + ASSERT_TRUE(SampleAt(s, 1.0, v)); + EXPECT_TRUE(v == -5.0 || v == 5.0); +} + +TEST(Cursors, DeltaAndFrequency) { + Cursors c; + c.tA = 1.0; + c.tB = 1.004; + EXPECT_NEAR(c.dt(), 0.004, 1e-15); + EXPECT_NEAR(c.freq(), 250.0, 1e-9); +} + +// Both cursors on the same sample would divide by zero. +TEST(Cursors, ZeroSpanHasNoFrequency) { + Cursors c; + c.tA = 2.0; + c.tB = 2.0; + EXPECT_DOUBLE_EQ(c.dt(), 0.0); + EXPECT_DOUBLE_EQ(c.freq(), 0.0); +} + +TEST(Cursors, DeltaIsSignedFromAToB) { + Cursors c; + c.tA = 3.0; + c.tB = 1.0; + EXPECT_DOUBLE_EQ(c.dt(), -2.0); + EXPECT_NEAR(c.freq(), 0.5, 1e-12); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `Measure.h: No such file or directory`. + +- [ ] **Step 3: Write `Measure.h`** + +Create `Client/udpscope/Measure.h`: + +```cpp +/** + * @file Measure.h + * @brief Cursor readouts and per-trace statistics (spec §8.4). + */ +#ifndef UDPSCOPE_MEASURE_H +#define UDPSCOPE_MEASURE_H + +#include "Types.h" + +#include + +namespace udpscope { + +/** Statistics over the undecimated samples inside a time window. */ +struct Stats { + bool valid = false; + size_t count = 0u; + double min = 0.0; + double max = 0.0; + double pp = 0.0; /**< max - min */ + double mean = 0.0; + double rms = 0.0; +}; + +/** + * @param raw undecimated samples, time-ordered. + * @param t0,t1 window bounds, in either order; both ends inclusive. + */ +Stats ComputeStats(const Series& raw, double t0, double t1); + +/** + * Linearly interpolates the trace at @a t. + * @return false when @a t is outside the series or the series is empty. + */ +bool SampleAt(const Series& raw, double t, double& v); + +/** The two global time cursors. */ +struct Cursors { + bool enabled = false; + double tA = 0.0; + double tB = 0.0; + + double dt() const { return tB - tA; } + /** 1/|dt|, or 0 when the cursors coincide. */ + double freq() const; +}; + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_MEASURE_H */ +``` + +- [ ] **Step 4: Write `Measure.cpp`** + +Create `Client/udpscope/Measure.cpp`: + +```cpp +#include "Measure.h" + +#include +#include + +namespace udpscope { + +Stats ComputeStats(const Series& raw, double t0, double t1) { + Stats st; + if (t1 < t0) { + std::swap(t0, t1); + } + double sum = 0.0; + double sq = 0.0; + for (size_t i = 0u; i < raw.size(); i++) { + const double t = raw.t[i]; + if (t < t0 || t > t1) { + continue; + } + const double v = raw.v[i]; + if (!std::isfinite(v)) { + continue; /* a quantised NaN must not poison mean and RMS */ + } + if (st.count == 0u) { + st.min = v; + st.max = v; + } else { + st.min = std::min(st.min, v); + st.max = std::max(st.max, v); + } + sum += v; + sq += v * v; + st.count++; + } + if (st.count == 0u) { + return st; + } + const double n = static_cast(st.count); + st.pp = st.max - st.min; + st.mean = sum / n; + st.rms = std::sqrt(sq / n); + st.valid = true; + return st; +} + +bool SampleAt(const Series& raw, double t, double& v) { + const size_t n = raw.size(); + if (n == 0u || !std::isfinite(t)) { + return false; + } + if (t < raw.t[0] || t > raw.t[n - 1u]) { + return false; + } + /* First sample at or after t. Timestamps repeat in a min/max envelope, + so lower_bound may land on either member of a pair; both are equally + valid readings at that instant. */ + const size_t hi = static_cast( + std::lower_bound(raw.t.begin(), raw.t.end(), t) - raw.t.begin()); + if (hi == 0u || raw.t[hi] == t) { + v = raw.v[hi]; + return true; + } + const double t0 = raw.t[hi - 1u]; + const double t1 = raw.t[hi]; + const double dt = t1 - t0; + if (!(dt > 0.0)) { + v = raw.v[hi]; + return true; + } + v = raw.v[hi - 1u] + (raw.v[hi] - raw.v[hi - 1u]) * (t - t0) / dt; + return true; +} + +double Cursors::freq() const { + const double d = std::fabs(dt()); + if (!(d > 0.0) || !std::isfinite(d)) { + return 0.0; + } + return 1.0 / d; +} + +} /* namespace udpscope */ +``` + +Add `Measure.cpp` to `CORE_SOURCES` in `Client/udpscope/CMakeLists.txt`. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ComputeStats*:SampleAt*:Cursors*' +``` + +Expected: PASS, 13 tests. + +- [ ] **Step 6: Commit the measurement core** + +```bash +git add Client/udpscope/Measure.h Client/udpscope/Measure.cpp \ + Client/udpscope/tests/MeasureTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): cursor sampling and undecimated trace statistics" +``` + +- [ ] **Step 7: Draw the cursors and the statistics table** + +In `Client/udpscope/PaneView.h`, add `#include "Measure.h"` and two fields to +`PaneContext`: + +```cpp + Cursors* cursors = nullptr; /**< shared by every pane; may be null */ + bool showStats = false; +``` + +In `Client/udpscope/PaneView.cpp`, inside `drawLeaf()`'s plot, after the trace +loop and before the legend popup handling, add: + +```cpp + if (ctx.cursors != nullptr && ctx.cursors->enabled) { + /* Dragging in any pane moves the cursors in all of them, because + they are one shared pair of times (spec §8.4). */ + double a = ctx.cursors->tA; + double b = ctx.cursors->tB; + const ImVec4 amber(0.98f, 0.70f, 0.53f, 1.0f); + const ImVec4 blue(0.54f, 0.71f, 0.98f, 1.0f); + if (ImPlot::DragLineX(kCursorAId, &a, amber, 1.0f)) { + ctx.cursors->tA = a; + } + if (ImPlot::DragLineX(kCursorBId, &b, blue, 1.0f)) { + ctx.cursors->tB = b; + } + } +``` + +with the ids declared at file scope in `PaneView.cpp`: + +```cpp +namespace { +/* ImPlot drag-line ids must be unique within a plot but may repeat across + plots; A and B are the same logical cursors in every pane. */ +const int kCursorAId = 1001; +const int kCursorBId = 1002; +} /* namespace */ +``` + +Then, still inside the plot and after the cursor block, draw the readout as a +plot annotation-free overlay: + +```cpp + const bool cursorsUp = (ctx.cursors != nullptr) && ctx.cursors->enabled; + if ((ctx.showStats || cursorsUp) && !traces_.empty()) { + /* Statistics are taken between the cursors when they are up, over the + visible range otherwise. */ + const double s0 = cursorsUp ? ctx.cursors->tA : ctx.x0; + const double s1 = cursorsUp ? ctx.cursors->tB : ctx.x1; + + ImPlot::PushPlotClipRect(); + const ImVec2 org = ImPlot::GetPlotPos(); + ImDrawList* dl = ImPlot::GetPlotDrawList(); + float y = org.y + 4.0f; + for (size_t i = 0u; i < traces_.size(); i++) { + char line[288]; + int used = std::snprintf(line, sizeof(line), "%s", + traces_[i].name.c_str()); + + if (cursorsUp) { + /* Spec §8.4: the value at each cursor and their difference, + per displayed signal. */ + double va = 0.0, vb = 0.0; + const bool ha = SampleAt(traces_[i].raw, ctx.cursors->tA, va); + const bool hb = SampleAt(traces_[i].raw, ctx.cursors->tB, vb); + if (ha && hb) { + used += std::snprintf(line + used, + sizeof(line) - static_cast(used), + " A %.4g B %.4g dV %.4g", + va, vb, vb - va); + } else { + used += std::snprintf(line + used, + sizeof(line) - static_cast(used), + " A - B - dV -"); + } + } + + if (ctx.showStats) { + const Stats st = ComputeStats(traces_[i].raw, s0, s1); + if (st.valid) { + std::snprintf(line + used, + sizeof(line) - static_cast(used), + " min %.4g max %.4g pp %.4g avg %.4g rms %.4g", + st.min, st.max, st.pp, st.mean, st.rms); + } else { + std::snprintf(line + used, + sizeof(line) - static_cast(used), + " (no samples)"); + } + } + + dl->AddText(ImVec2(org.x + 6.0f, y), + ImGui::ColorConvertFloat4ToU32(toImVec4(traces_[i].color)), + line); + y += ImGui::GetTextLineHeight(); + } + ImPlot::PopPlotClipRect(); + } +``` + +The statistics need the raw series after the trace loop has finished, so +`drawLeaf()` must keep them. Add to `PaneView`'s private section: + +```cpp + struct TraceKeep { + std::string name; + Color color; + Series raw; + }; + std::vector traces_; +``` + +clear it at the top of the trace loop (`traces_.clear();`) and append inside +the loop, right after `ComputeVScale(...)`: + +```cpp + if (ctx.showStats || (ctx.cursors != nullptr && ctx.cursors->enabled)) { + TraceKeep keep; + keep.name = a.signalName; + keep.color = a.color; + keep.raw = d.raw; /* copied: d is reused by the next trace */ + traces_.push_back(keep); + } +``` + +- [ ] **Step 8: Own the cursors in App** + +In `App.h`, add `#include "Measure.h"` and, next to `latch_`: + +```cpp + Cursors cursors_; + bool showStats_ = false; +``` + +In `App.cpp`, extend the View menu built in Task 12: + +```cpp + ImGui::Separator(); + ImGui::MenuItem("Cursors", "C", &cursors_.enabled); + ImGui::MenuItem("Measurements", "M", &showStats_); +``` + +and pass them to the panes in `drawPlotArea()`, beside `ctx.metas = &sigs_;`: + +```cpp + ctx.cursors = &cursors_; + ctx.showStats = showStats_; +``` + +Place the cursors sensibly the first time they are switched on, otherwise +they sit at t=0, far off the left of a wall-clock axis. Immediately after the +View menu block in `drawMenuBar()`: + +```cpp + if (cursors_.enabled && !cursorsPlaced_) { + const double span = xaxis_.span(); + cursors_.tA = xaxis_.x0() + span * 0.25; + cursors_.tB = xaxis_.x0() + span * 0.75; + cursorsPlaced_ = true; + } + if (!cursors_.enabled) { + cursorsPlaced_ = false; + } +``` + +with `bool cursorsPlaced_ = false;` added next to `showStats_`. + +Finally show the readout in the status bar. In `drawStatusBar()`, before the +existing counters: + +```cpp + if (cursors_.enabled) { + ImGui::Text("A %.6g s B %.6g s dt %.6g s 1/dt %.6g Hz", + cursors_.tA, cursors_.tB, cursors_.dt(), cursors_.freq()); + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + } +``` + +- [ ] **Step 9: Build and verify by hand** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–14. + +Against the demo streamer: + +1. View → Cursors: two vertical lines appear a quarter and three quarters of + the way across every pane, and the status bar shows `A`, `B`, `dt`, `1/dt`. +2. Drag cursor A in one pane: it moves in every pane and the readout follows. +3. Put the cursors one sine period apart: `1/dt` reads the configured + frequency of the demo sine. +4. With cursors on, each pane already lists `A`, `B` and `dV` per trace in the + trace colour; drag a cursor past the end of the data and that trace's three + values become `-`. +5. View → Measurements: min/max/pp/avg/rms are appended to the same lines. +6. With cursors on, narrow them to the top half of a sine: `avg` rises and + `pp` shrinks, confirming the statistics track the cursor span rather than + the visible range. + +- [ ] **Step 10: Commit** + +```bash +git add Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \ + Client/udpscope/App.h Client/udpscope/App.cpp +git commit -m "feat(udpscope): shared time cursors and per-pane measurements" +``` + +--- + +### Task 15: Session persistence + +**Files:** +- Create: `Client/udpscope/Settings.h` +- Create: `Client/udpscope/Settings.cpp` +- Create: `Client/udpscope/tests/SettingsTest.cpp` +- Modify: `Client/udpscope/App.h` +- Modify: `Client/udpscope/App.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` + +**Interfaces:** +- Consumes: `PaneNode`, `Assignment`, `VScale`, `VMode`, `Orient`, + `PaneTree::setRoot` (Task 2); `TrigConfig`, `Edge`, `TrigMode` (Task 5); + `ReceiverOptions` (Task 7); `CliOptions` and its `set*` flags, + `DefaultConfigPath()` (Task 9); `Cursors` (Task 14). +- Produces: `struct Session { ReceiverOptions source; TrigConfig trigger; + Cursors cursors; std::unique_ptr tree; }`, + `std::string WriteSession(const Session&)`, + `bool ParseSession(const std::string&, Session&, std::string&)`, + `bool LoadSessionFile(const std::string&, Session&, std::string&)`, + `bool SaveSessionFile(const std::string&, const Session&, std::string&)`, + `void MergeCli(const CliOptions&, ReceiverOptions&)`. + +**Why indentation is written but not parsed:** the pane tree is written in +prefix order — a `split` line is always followed by exactly two subtrees, a +`leaf` line by its `sig` lines — so the structure is unambiguous from the +keywords alone. Indenting makes the file readable; requiring exact +indentation on the way back in would only add a way for a hand-edited file to +be rejected for no reason. + +**Deviation from the spec's example:** the `source` line also carries +`silence=`. `--silence` is a source field the command line can set, and a +settings file that cannot round-trip everything the CLI sets would silently +drop it on the next save. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/SettingsTest.cpp`: + +```cpp +#include "Settings.h" + +#include +#include + +using namespace udpscope; + +namespace { + +Session fullSession() { + Session s; + s.source.host = "10.0.0.5"; + s.source.port = 44501u; + s.source.multicastGroup = "239.0.0.1"; + s.source.interfaceAddr = "192.168.1.2"; + s.source.dataPort = 44503u; + s.source.silenceTimeoutSec = 3.5; + + s.trigger.signalName = "Voltage"; + s.trigger.edge = Edge::Falling; + s.trigger.threshold = 0.5; + s.trigger.hysteresis = 0.01; + s.trigger.windowSec = 0.02; + s.trigger.prePercent = 30.0; + s.trigger.mode = TrigMode::Single; + + s.cursors.enabled = true; + s.cursors.tA = 0.0123; + s.cursors.tB = 0.0456; + + auto root = std::unique_ptr(new PaneNode()); + root->leaf = false; + root->orient = Orient::Columns; + root->ratio = 0.4; + + auto left = std::unique_ptr(new PaneNode()); + Assignment v; + v.signalName = "Voltage"; + v.color = Color{0.54f, 0.71f, 0.98f, 1.0f}; + v.lineWidth = 1.5f; + v.vs.mode = VMode::Auto; + left->signals.push_back(v); + Assignment c; + c.signalName = "Current"; + c.color = Color{0.98f, 0.70f, 0.53f, 1.0f}; + c.lineWidth = 2.0f; + c.vs.mode = VMode::Manual; + c.vs.div = 0.2; + c.vs.offset = -1.0; + left->signals.push_back(c); + + auto right = std::unique_ptr(new PaneNode()); + Assignment t; + t.signalName = "Temp"; + t.color = Color{0.65f, 0.89f, 0.63f, 1.0f}; + t.vs.mode = VMode::Range; + right->signals.push_back(t); + + root->a = std::move(left); + root->b = std::move(right); + s.tree = std::move(root); + return s; +} + +} // namespace + +TEST(Settings, RoundTripsAFullSession) { + const Session in = fullSession(); + const std::string text = WriteSession(in); + + Session out; + std::string err; + ASSERT_TRUE(ParseSession(text, out, err)) << err; + + EXPECT_EQ(out.source.host, "10.0.0.5"); + EXPECT_EQ(out.source.port, 44501u); + EXPECT_EQ(out.source.multicastGroup, "239.0.0.1"); + EXPECT_EQ(out.source.interfaceAddr, "192.168.1.2"); + EXPECT_EQ(out.source.dataPort, 44503u); + EXPECT_DOUBLE_EQ(out.source.silenceTimeoutSec, 3.5); + + EXPECT_EQ(out.trigger.signalName, "Voltage"); + EXPECT_EQ(out.trigger.edge, Edge::Falling); + EXPECT_DOUBLE_EQ(out.trigger.threshold, 0.5); + EXPECT_DOUBLE_EQ(out.trigger.hysteresis, 0.01); + EXPECT_DOUBLE_EQ(out.trigger.windowSec, 0.02); + EXPECT_DOUBLE_EQ(out.trigger.prePercent, 30.0); + EXPECT_EQ(out.trigger.mode, TrigMode::Single); + + EXPECT_TRUE(out.cursors.enabled); + EXPECT_NEAR(out.cursors.tA, 0.0123, 1e-9); + EXPECT_NEAR(out.cursors.tB, 0.0456, 1e-9); + + ASSERT_TRUE(out.tree); + ASSERT_FALSE(out.tree->leaf); + EXPECT_EQ(out.tree->orient, Orient::Columns); + EXPECT_NEAR(out.tree->ratio, 0.4, 1e-9); + ASSERT_TRUE(out.tree->a && out.tree->b); + ASSERT_EQ(out.tree->a->signals.size(), 2u); + EXPECT_EQ(out.tree->a->signals[0].signalName, "Voltage"); + EXPECT_EQ(out.tree->a->signals[1].vs.mode, VMode::Manual); + EXPECT_NEAR(out.tree->a->signals[1].vs.div, 0.2, 1e-9); + EXPECT_NEAR(out.tree->a->signals[1].vs.offset, -1.0, 1e-9); + EXPECT_NEAR(out.tree->a->signals[1].lineWidth, 2.0f, 1e-6f); + ASSERT_EQ(out.tree->b->signals.size(), 1u); + EXPECT_EQ(out.tree->b->signals[0].vs.mode, VMode::Range); +} + +TEST(Settings, ColoursSurviveAsHex) { + const Session in = fullSession(); + Session out; + std::string err; + ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err; + + const Color got = out.tree->a->signals[0].color; + EXPECT_NEAR(got.r, 0.54f, 1.0f / 255.0f); + EXPECT_NEAR(got.g, 0.71f, 1.0f / 255.0f); + EXPECT_NEAR(got.b, 0.98f, 1.0f / 255.0f); +} + +TEST(Settings, TheWrittenFormMatchesTheDocumentedShape) { + const Session in = fullSession(); + const std::string text = WriteSession(in); + EXPECT_EQ(text.compare(0, 11, "udpscope 1\n"), 0); + EXPECT_NE(text.find("\nsource host=10.0.0.5 port=44501"), std::string::npos); + EXPECT_NE(text.find("\ncursors on "), std::string::npos); + EXPECT_NE(text.find("\ntree\n"), std::string::npos); + EXPECT_NE(text.find("split cols 0.4"), std::string::npos); + EXPECT_NE(text.find("sig Voltage color=#"), std::string::npos); +} + +TEST(Settings, ASessionWithoutATreeIsValid) { + Session in; + in.trigger.signalName = "Voltage"; + Session out; + std::string err; + ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err; + EXPECT_FALSE(out.tree); + EXPECT_EQ(out.trigger.signalName, "Voltage"); +} + +TEST(Settings, CursorsOffRoundTrips) { + Session in; + in.cursors.enabled = false; + Session out; + out.cursors.enabled = true; + std::string err; + ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err; + EXPECT_FALSE(out.cursors.enabled); +} + +TEST(Settings, EveryEdgeAndModeNameRoundTrips) { + const Edge edges[] = {Edge::Rising, Edge::Falling, Edge::Both}; + const TrigMode modes[] = {TrigMode::Normal, TrigMode::Single}; + for (int e = 0; e < 3; ++e) { + for (int m = 0; m < 2; ++m) { + Session in; + in.trigger.edge = edges[e]; + in.trigger.mode = modes[m]; + Session out; + std::string err; + ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err; + EXPECT_EQ(out.trigger.edge, edges[e]); + EXPECT_EQ(out.trigger.mode, modes[m]); + } + } +} + +// The file is meant to be hand-editable, so re-indenting it must not break it. +TEST(Settings, IndentationIsIgnored) { + const std::string text = + "udpscope 1\n" + "tree\n" + "split rows 0.25\n" + "leaf\n" + "sig A color=#ffffff width=1 vs=auto\n" + " leaf\n" + "\t\tsig B color=#000000 width=1 vs=auto\n"; + Session out; + std::string err; + ASSERT_TRUE(ParseSession(text, out, err)) << err; + ASSERT_TRUE(out.tree && !out.tree->leaf); + EXPECT_EQ(out.tree->orient, Orient::Rows); + EXPECT_EQ(out.tree->a->signals[0].signalName, "A"); + EXPECT_EQ(out.tree->b->signals[0].signalName, "B"); +} + +TEST(Settings, BlankLinesAndCommentsAreSkipped) { + const std::string text = + "udpscope 1\n" + "\n" + "# written by hand\n" + "tree\n" + " leaf\n"; + Session out; + std::string err; + ASSERT_TRUE(ParseSession(text, out, err)) << err; + ASSERT_TRUE(out.tree); + EXPECT_TRUE(out.tree->leaf); +} + +TEST(Settings, MissingHeaderIsRejected) { + Session out; + std::string err; + EXPECT_FALSE(ParseSession("tree\n leaf\n", out, err)); + EXPECT_FALSE(err.empty()); +} + +TEST(Settings, VersionMismatchIsRejected) { + Session out; + std::string err; + EXPECT_FALSE(ParseSession("udpscope 2\ntree\n leaf\n", out, err)); + EXPECT_NE(err.find("version"), std::string::npos); +} + +TEST(Settings, UnknownKeywordIsRejected) { + Session out; + std::string err; + EXPECT_FALSE(ParseSession("udpscope 1\nbananas 3\n", out, err)); +} + +TEST(Settings, UnknownKeyIsRejected) { + Session out; + std::string err; + EXPECT_FALSE(ParseSession("udpscope 1\nsource host=a bogus=1\n", out, err)); +} + +TEST(Settings, TruncatedTreeIsRejected) { + Session out; + std::string err; + /* A split promises two children and only delivers one. */ + EXPECT_FALSE(ParseSession("udpscope 1\ntree\n split cols 0.5\n leaf\n", + out, err)); +} + +// Spec §9: a bad file is reported and ignored, never partially applied. +TEST(Settings, AFailedParseLeavesTheOutputUntouched) { + Session out; + out.source.host = "keepme"; + out.trigger.signalName = "keepme-too"; + std::string err; + + ASSERT_FALSE(ParseSession("udpscope 1\n" + "source host=clobbered port=1\n" + "trigger signal=clobbered\n" + "bananas\n", + out, err)); + EXPECT_EQ(out.source.host, "keepme"); + EXPECT_EQ(out.trigger.signalName, "keepme-too"); +} + +// Signal names are bare tokens in the file format. +TEST(Settings, WhitespaceInASignalNameIsRefusedOnWrite) { + Session in; + in.tree = std::unique_ptr(new PaneNode()); + Assignment a; + a.signalName = "bad name"; + in.tree->signals.push_back(a); + + std::string err; + EXPECT_FALSE(SaveSessionFile("/tmp/udpscope_should_not_exist.conf", in, err)); + EXPECT_FALSE(err.empty()); +} + +TEST(Settings, SaveThenLoadAFile) { + const std::string path = "/tmp/udpscope_settings_test.conf"; + const Session in = fullSession(); + std::string err; + ASSERT_TRUE(SaveSessionFile(path, in, err)) << err; + + Session out; + ASSERT_TRUE(LoadSessionFile(path, out, err)) << err; + EXPECT_EQ(out.source.port, 44501u); + ASSERT_TRUE(out.tree); + std::remove(path.c_str()); +} + +TEST(Settings, LoadingAMissingFileFails) { + Session out; + std::string err; + EXPECT_FALSE(LoadSessionFile("/tmp/udpscope_definitely_absent.conf", out, err)); +} + +// Spec §11: an explicit option wins, an omitted one falls back to the file. +TEST(MergeCli, ExplicitOptionsWin) { + ReceiverOptions fromFile; + fromFile.host = "10.0.0.5"; + fromFile.port = 44501u; + + CliOptions cli; + cli.source.port = 44502u; + cli.setPort = true; + + MergeCli(cli, fromFile); + EXPECT_EQ(fromFile.host, "10.0.0.5"); // not given on the command line + EXPECT_EQ(fromFile.port, 44502u); // given, so it wins +} + +TEST(MergeCli, OmittedOptionsLeaveTheFileAlone) { + ReceiverOptions fromFile; + fromFile.host = "10.0.0.5"; + fromFile.multicastGroup = "239.0.0.1"; + fromFile.dataPort = 44503u; + + CliOptions cli; // nothing given + MergeCli(cli, fromFile); + EXPECT_EQ(fromFile.host, "10.0.0.5"); + EXPECT_EQ(fromFile.multicastGroup, "239.0.0.1"); + EXPECT_EQ(fromFile.dataPort, 44503u); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `Settings.h: No such file or directory`. + +- [ ] **Step 3: Write `Settings.h`** + +Create `Client/udpscope/Settings.h`: + +```cpp +/** + * @file Settings.h + * @brief The line-based session file described in spec §9. + */ +#ifndef UDPSCOPE_SETTINGS_H +#define UDPSCOPE_SETTINGS_H + +#include "Cli.h" +#include "Measure.h" +#include "PaneTree.h" +#include "Receiver.h" +#include "Trigger.h" + +#include +#include + +namespace udpscope { + +/** Everything that survives a restart. */ +struct Session { + ReceiverOptions source; + TrigConfig trigger; + Cursors cursors; + std::unique_ptr tree; /**< null when nothing was saved */ +}; + +/** Current file format version; anything else is refused. */ +constexpr int kSessionVersion = 1; + +/** @return the session as text, always ending in a newline. */ +std::string WriteSession(const Session& s); + +/** + * Parses @a text. On failure @a out is left exactly as it was, so a broken + * file can never half-apply. + */ +bool ParseSession(const std::string& text, Session& out, std::string& err); + +bool LoadSessionFile(const std::string& path, Session& out, std::string& err); + +/** Creates the parent directory if needed. */ +bool SaveSessionFile(const std::string& path, const Session& s, std::string& err); + +/** Applies the options that were actually given on the command line. */ +void MergeCli(const CliOptions& cli, ReceiverOptions& io); + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_SETTINGS_H */ +``` + +- [ ] **Step 4: Write `Settings.cpp`** + +Create `Client/udpscope/Settings.cpp`: + +```cpp +#include "Settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +namespace { + +std::vector tokens(const std::string& line) { + std::vector out; + std::istringstream is(line); + std::string t; + while (is >> t) { + out.push_back(t); + } + return out; +} + +bool splitKv(const std::string& tok, std::string& k, std::string& v) { + const size_t eq = tok.find('='); + if (eq == std::string::npos) { + return false; + } + k = tok.substr(0u, eq); + v = tok.substr(eq + 1u); + return true; +} + +double toD(const std::string& s) { return std::strtod(s.c_str(), NULL); } + +unsigned toU(const std::string& s) { + return static_cast(std::strtoul(s.c_str(), NULL, 10)); +} + +std::string hexOf(const Color& c) { + const int r = static_cast(c.r * 255.0f + 0.5f); + const int g = static_cast(c.g * 255.0f + 0.5f); + const int b = static_cast(c.b * 255.0f + 0.5f); + char buf[16]; + std::snprintf(buf, sizeof(buf), "#%02x%02x%02x", + r < 0 ? 0 : (r > 255 ? 255 : r), + g < 0 ? 0 : (g > 255 ? 255 : g), + b < 0 ? 0 : (b > 255 ? 255 : b)); + return std::string(buf); +} + +bool colorOf(const std::string& s, Color& c) { + if (s.size() != 7u || s[0] != '#') { + return false; + } + for (size_t i = 1u; i < 7u; i++) { + if (std::isxdigit(static_cast(s[i])) == 0) { + return false; + } + } + const unsigned long v = std::strtoul(s.c_str() + 1, NULL, 16); + c.r = static_cast((v >> 16) & 0xffu) / 255.0f; + c.g = static_cast((v >> 8) & 0xffu) / 255.0f; + c.b = static_cast(v & 0xffu) / 255.0f; + c.a = 1.0f; + return true; +} + +const char* edgeName(Edge e) { + switch (e) { + case Edge::Falling: return "falling"; + case Edge::Both: return "both"; + case Edge::Rising: + default: return "rising"; + } +} + +bool edgeOf(const std::string& s, Edge& e) { + if (s == "rising") { e = Edge::Rising; return true; } + if (s == "falling") { e = Edge::Falling; return true; } + if (s == "both") { e = Edge::Both; return true; } + return false; +} + +const char* vmodeName(VMode m) { + switch (m) { + case VMode::Range: return "range"; + case VMode::Manual: return "manual"; + case VMode::Auto: + default: return "auto"; + } +} + +bool vmodeOf(const std::string& s, VMode& m) { + if (s == "auto") { m = VMode::Auto; return true; } + if (s == "range") { m = VMode::Range; return true; } + if (s == "manual") { m = VMode::Manual; return true; } + return false; +} + +bool hasSpace(const std::string& s) { + for (size_t i = 0u; i < s.size(); i++) { + if (std::isspace(static_cast(s[i])) != 0) { + return true; + } + } + return s.empty(); +} + +void writeNode(const PaneNode& n, int depth, std::string& out) { + const std::string pad(static_cast(depth) * 2u, ' '); + char buf[256]; + if (!n.leaf) { + std::snprintf(buf, sizeof(buf), "%ssplit %s %.6g\n", pad.c_str(), + (n.orient == Orient::Rows) ? "rows" : "cols", n.ratio); + out += buf; + if (n.a) { writeNode(*n.a, depth + 1, out); } + if (n.b) { writeNode(*n.b, depth + 1, out); } + return; + } + out += pad + "leaf\n"; + for (size_t i = 0u; i < n.signals.size(); i++) { + const Assignment& a = n.signals[i]; + std::snprintf(buf, sizeof(buf), "%s sig %s color=%s width=%.4g vs=%s", + pad.c_str(), a.signalName.c_str(), hexOf(a.color).c_str(), + static_cast(a.lineWidth), vmodeName(a.vs.mode)); + out += buf; + if (a.vs.mode == VMode::Manual) { + std::snprintf(buf, sizeof(buf), " div=%.10g off=%.10g", + a.vs.div, a.vs.offset); + out += buf; + } + out += "\n"; + } +} + +/** True if every signal name in the tree is a bare token. */ +bool namesAreWritable(const PaneNode& n, std::string& bad) { + if (n.leaf) { + for (size_t i = 0u; i < n.signals.size(); i++) { + if (hasSpace(n.signals[i].signalName)) { + bad = n.signals[i].signalName; + return false; + } + } + return true; + } + if (n.a && !namesAreWritable(*n.a, bad)) { return false; } + if (n.b && !namesAreWritable(*n.b, bad)) { return false; } + return true; +} + +/** Recursive descent over the pre-tokenised lines; @a i is the cursor. */ +std::unique_ptr readNode(const std::vector >& L, + size_t& i, std::string& err) { + if (i >= L.size()) { + err = "tree ends early"; + return std::unique_ptr(); + } + const std::vector& t = L[i]; + std::unique_ptr n(new PaneNode()); + + if (t[0] == "split") { + if (t.size() != 3u) { + err = "split needs an orientation and a ratio"; + return std::unique_ptr(); + } + n->leaf = false; + if (t[1] == "cols") { + n->orient = Orient::Columns; + } else if (t[1] == "rows") { + n->orient = Orient::Rows; + } else { + err = "unknown split orientation '" + t[1] + "'"; + return std::unique_ptr(); + } + n->ratio = toD(t[2]); + if (!(n->ratio > 0.0) || !(n->ratio < 1.0)) { + err = "split ratio out of range"; + return std::unique_ptr(); + } + i++; + n->a = readNode(L, i, err); + if (!n->a) { return std::unique_ptr(); } + n->b = readNode(L, i, err); + if (!n->b) { return std::unique_ptr(); } + return n; + } + + if (t[0] != "leaf") { + err = "expected 'split' or 'leaf', got '" + t[0] + "'"; + return std::unique_ptr(); + } + i++; + while (i < L.size() && L[i][0] == "sig") { + const std::vector& s = L[i]; + if (s.size() < 2u) { + err = "sig needs a name"; + return std::unique_ptr(); + } + Assignment a; + a.signalName = s[1]; + for (size_t k = 2u; k < s.size(); k++) { + std::string key, val; + if (!splitKv(s[k], key, val)) { + err = "sig field '" + s[k] + "' is not key=value"; + return std::unique_ptr(); + } + if (key == "color") { + if (!colorOf(val, a.color)) { + err = "bad colour '" + val + "'"; + return std::unique_ptr(); + } + } else if (key == "width") { + a.lineWidth = static_cast(toD(val)); + } else if (key == "vs") { + if (!vmodeOf(val, a.vs.mode)) { + err = "bad vertical mode '" + val + "'"; + return std::unique_ptr(); + } + } else if (key == "div") { + a.vs.div = toD(val); + } else if (key == "off") { + a.vs.offset = toD(val); + } else { + err = "unknown sig key '" + key + "'"; + return std::unique_ptr(); + } + } + n->signals.push_back(a); + i++; + } + return n; +} + +} /* namespace */ + +std::string WriteSession(const Session& s) { + std::string out; + char buf[512]; + std::snprintf(buf, sizeof(buf), "udpscope %d\n", kSessionVersion); + out += buf; + + std::snprintf(buf, sizeof(buf), + "source host=%s port=%u multicast=%s iface=%s dataport=%u " + "silence=%.6g\n", + s.source.host.c_str(), static_cast(s.source.port), + s.source.multicastGroup.c_str(), s.source.interfaceAddr.c_str(), + static_cast(s.source.dataPort), + s.source.silenceTimeoutSec); + out += buf; + + std::snprintf(buf, sizeof(buf), + "trigger signal=%s edge=%s thr=%.10g hyst=%.10g win=%.10g " + "pre=%.6g mode=%s\n", + s.trigger.signalName.c_str(), edgeName(s.trigger.edge), + s.trigger.threshold, s.trigger.hysteresis, s.trigger.windowSec, + s.trigger.prePercent, + (s.trigger.mode == TrigMode::Single) ? "single" : "normal"); + out += buf; + + std::snprintf(buf, sizeof(buf), "cursors %s %.10g %.10g\n", + s.cursors.enabled ? "on" : "off", s.cursors.tA, s.cursors.tB); + out += buf; + + if (s.tree) { + out += "tree\n"; + writeNode(*s.tree, 1, out); + } + return out; +} + +bool ParseSession(const std::string& text, Session& out, std::string& err) { + /* Everything lands in a scratch session and is moved out only on success + (spec §9: never partially applied). */ + Session s; + std::vector > lines; + { + std::istringstream is(text); + std::string line; + while (std::getline(is, line)) { + std::vector t = tokens(line); + if (t.empty() || t[0][0] == '#') { + continue; + } + lines.push_back(t); + } + } + if (lines.empty() || lines[0].size() != 2u || lines[0][0] != "udpscope") { + err = "not a udpscope session file"; + return false; + } + if (std::atoi(lines[0][1].c_str()) != kSessionVersion) { + err = "unsupported session version '" + lines[0][1] + "'"; + return false; + } + + for (size_t i = 1u; i < lines.size(); ) { + const std::vector& t = lines[i]; + + if (t[0] == "source" || t[0] == "trigger") { + for (size_t k = 1u; k < t.size(); k++) { + std::string key, val; + if (!splitKv(t[k], key, val)) { + err = "field '" + t[k] + "' is not key=value"; + return false; + } + if (t[0] == "source") { + if (key == "host") { s.source.host = val; } + else if (key == "port") { s.source.port = static_cast(toU(val)); } + else if (key == "multicast") { s.source.multicastGroup = val; } + else if (key == "iface") { s.source.interfaceAddr = val; } + else if (key == "dataport") { s.source.dataPort = static_cast(toU(val)); } + else if (key == "silence") { s.source.silenceTimeoutSec = toD(val); } + else { err = "unknown source key '" + key + "'"; return false; } + } else { + if (key == "signal") { s.trigger.signalName = val; } + else if (key == "edge") { + if (!edgeOf(val, s.trigger.edge)) { + err = "unknown edge '" + val + "'"; + return false; + } + } + else if (key == "thr") { s.trigger.threshold = toD(val); } + else if (key == "hyst") { s.trigger.hysteresis = toD(val); } + else if (key == "win") { s.trigger.windowSec = toD(val); } + else if (key == "pre") { s.trigger.prePercent = toD(val); } + else if (key == "mode") { + if (val == "single") { s.trigger.mode = TrigMode::Single; } + else if (val == "normal") { s.trigger.mode = TrigMode::Normal; } + else { err = "unknown trigger mode '" + val + "'"; return false; } + } + else { err = "unknown trigger key '" + key + "'"; return false; } + } + } + i++; + continue; + } + + if (t[0] == "cursors") { + if (t.size() != 4u) { + err = "cursors needs on|off and two times"; + return false; + } + s.cursors.enabled = (t[1] == "on"); + s.cursors.tA = toD(t[2]); + s.cursors.tB = toD(t[3]); + i++; + continue; + } + + if (t[0] == "tree") { + i++; + s.tree = readNode(lines, i, err); + if (!s.tree) { + return false; + } + if (i != lines.size()) { + err = "trailing content after the tree: '" + lines[i][0] + "'"; + return false; + } + continue; + } + + err = "unknown keyword '" + t[0] + "'"; + return false; + } + + out.source = s.source; + out.trigger = s.trigger; + out.cursors = s.cursors; + out.tree = std::move(s.tree); + return true; +} + +bool LoadSessionFile(const std::string& path, Session& out, std::string& err) { + std::ifstream f(path.c_str()); + if (!f) { + err = "cannot open " + path; + return false; + } + std::ostringstream ss; + ss << f.rdbuf(); + return ParseSession(ss.str(), out, err); +} + +bool SaveSessionFile(const std::string& path, const Session& s, std::string& err) { + std::string bad; + if (s.tree && !namesAreWritable(*s.tree, bad)) { + err = "signal name '" + bad + "' cannot be written to the session file"; + return false; + } + const size_t slash = path.find_last_of('/'); + if (slash != std::string::npos && slash > 0u) { + const std::string dir = path.substr(0u, slash); + if (mkdir(dir.c_str(), 0755) != 0 && errno != EEXIST) { + err = "cannot create " + dir + ": " + std::strerror(errno); + return false; + } + } + std::ofstream f(path.c_str(), std::ios::trunc); + if (!f) { + err = "cannot write " + path; + return false; + } + f << WriteSession(s); + if (!f) { + err = "write failed for " + path; + return false; + } + return true; +} + +void MergeCli(const CliOptions& cli, ReceiverOptions& io) { + if (cli.setHost) { io.host = cli.source.host; } + if (cli.setPort) { io.port = cli.source.port; } + if (cli.setMulticast) { io.multicastGroup = cli.source.multicastGroup; } + if (cli.setIface) { io.interfaceAddr = cli.source.interfaceAddr; } + if (cli.setDataPort) { io.dataPort = cli.source.dataPort; } + if (cli.setSilence) { io.silenceTimeoutSec = cli.source.silenceTimeoutSec; } +} + +} /* namespace udpscope */ +``` + +Add `Settings.cpp` to `CORE_SOURCES`. `mkdir` only creates the last component, +which is enough for `~/.config/udpscope`; a missing `~/.config` is reported +rather than created. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Settings*:MergeCli*' +``` + +Expected: PASS, 18 tests. + +- [ ] **Step 6: Commit the session core** + +```bash +git add Client/udpscope/Settings.h Client/udpscope/Settings.cpp \ + Client/udpscope/tests/SettingsTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): session file writer, parser and CLI precedence" +``` + +- [ ] **Step 7: Load the session before the receiver starts** + +In `App.h` add `#include "Settings.h"` and these members next to `opt_`: + +```cpp + std::string configPath_; + bool dirty_ = false; /**< something worth saving has changed */ +``` + +and declare: + +```cpp + void loadSession(); + void saveSession(); + Session currentSession() const; +``` + +Replace the body of `App::App()` in `App.cpp` with: + +```cpp +App::App(const CliOptions& opt) : opt_(opt), rx_(store_) { + configPath_ = opt_.setConfigPath ? opt_.configPath : DefaultConfigPath(); + loadSession(); + + std::string err; + if (!rx_.start(opt_.source, err)) { + status_ = "receiver failed to start: " + err; + } else { + char buf[160]; + std::snprintf(buf, sizeof(buf), "attaching to %s:%u", + opt_.source.host.c_str(), + static_cast(opt_.source.port)); + status_ = buf; + } + rx_.setTrigConfig(trig_); +} + +void App::loadSession() { + Session s; + std::string err; + if (!LoadSessionFile(configPath_, s, err)) { + /* No session yet is the normal first run, not a failure worth + shouting about; a malformed one is (spec §9). */ + status_ = err; + return; /* opt_.source already holds defaults plus the command line */ + } + /* File first, command line on top. */ + ReceiverOptions merged = s.source; + MergeCli(opt_, merged); + opt_.source = merged; + + trig_ = s.trigger; + cursors_ = s.cursors; + if (s.tree) { + tree_.setRoot(std::move(s.tree)); + } +} + +Session App::currentSession() const { + Session s; + s.source = opt_.source; + s.trigger = trig_; + s.cursors = cursors_; + s.tree = ClonePane(tree_.root()); + return s; +} + +void App::saveSession() { + const Session s = currentSession(); + std::string err; + if (SaveSessionFile(configPath_, s, err)) { + status_ = "saved " + configPath_; + dirty_ = false; + } else { + status_ = err; + } +} +``` + +`currentSession()` needs a deep copy of the live tree, because `Session` owns +its nodes and the tree keeps drawing. Add to `PaneTree.h`: + +```cpp +/** Deep-copies a subtree; returns null for a null input. */ +std::unique_ptr ClonePane(const PaneNode* n); +``` + +and to `PaneTree.cpp`: + +```cpp +std::unique_ptr ClonePane(const PaneNode* n) { + if (n == NULL) { + return std::unique_ptr(); + } + std::unique_ptr c(new PaneNode()); + c->leaf = n->leaf; + c->signals = n->signals; + c->profilePane = n->profilePane; + c->orient = n->orient; + c->ratio = n->ratio; + c->a = ClonePane(n->a.get()); + c->b = ClonePane(n->b.get()); + return c; +} +``` + +Add a round-trip test to `Client/udpscope/tests/PaneTreeTest.cpp`: + +```cpp +TEST(PaneTree, CloneIsADeepCopy) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Rows); + Assignment a; + a.signalName = "Voltage"; + tree.root()->a->signals.push_back(a); + + std::unique_ptr copy = ClonePane(tree.root()); + ASSERT_TRUE(copy && !copy->leaf); + ASSERT_EQ(copy->a->signals.size(), 1u); + EXPECT_EQ(copy->a->signals[0].signalName, "Voltage"); + + copy->a->signals[0].signalName = "Other"; + EXPECT_EQ(tree.root()->a->signals[0].signalName, "Voltage"); +} +``` + +- [ ] **Step 8: Add the File menu and save on exit** + +In `App::drawMenuBar()`, before the View menu: + +```cpp + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Save Layout", "Ctrl+S")) { + saveSession(); + } + if (ImGui::MenuItem("Reload Layout")) { + loadSession(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Quit", "Ctrl+Q")) { + requestQuit(); + } + ImGui::EndMenu(); + } +``` + +and in `App::~App()`: + +```cpp +App::~App() { + saveSession(); /* spec §9: saved on clean exit */ + rx_.stop(); +} +``` + +- [ ] **Step 9: Build and verify by hand** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–15. + +Against the demo streamer: + +```bash +./Client/udpscope/build/UDPScope --port 44501 +``` + +1. Split into three panes, drop different signals in each, set one to manual + v-scale, turn cursors on, configure the trigger. Quit. +2. `cat ~/.config/udpscope/session.conf` shows the documented shape. +3. Relaunch with no arguments: the same three panes, signals, colours, + v-scales, cursors and trigger settings come back, and it reconnects to + port 44501 because the file recorded it. +4. Relaunch with `--port 44502`: the layout is restored but the source is the + command-line one; the status bar shows the new port. +5. Append `bananas` to the file and relaunch: the status bar reports + `unknown keyword 'bananas'` and the app opens with a single empty pane — + nothing half-applied. + +- [ ] **Step 10: Commit** + +```bash +git add Client/udpscope/App.h Client/udpscope/App.cpp \ + Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \ + Client/udpscope/tests/PaneTreeTest.cpp +git commit -m "feat(udpscope): restore and save the session layout" +``` + +--- + +### Task 16: CSV export + +**Files:** +- Create: `Client/udpscope/Export.h` +- Create: `Client/udpscope/Export.cpp` +- Create: `Client/udpscope/tests/ExportTest.cpp` +- Modify: `Client/udpscope/App.h` +- Modify: `Client/udpscope/App.cpp` +- Modify: `Client/udpscope/CMakeLists.txt` + +**Interfaces:** +- Consumes: `Series` (Task 1), `PaneNode` (Task 2), `SignalStore` and + `Capture` (Task 6), `FetchLiveTrace`/`FetchCaptureTrace` (Task 10), + `CaptureLatch` (Task 13). +- Produces: `struct CsvTrace { std::string name; Series data; }`, + `std::string BuildCsv(const std::vector&, double t0)`, + `bool ExportCsvFile(const std::string& path, const std::vector&, + double t0, std::string& err)`, `std::vector + SignalsInPane(const PaneNode*)`. + +**Column meanings:** `time_s` is relative to the start of the exported +window, so a capture reads from 0 regardless of when it was taken; +`wallclock_s` is the absolute Unix time the sample carries. Precision is +9 decimals on `time_s` (the spec's example shows 6, which is exactly one +microsecond and would collide at the 1 MSps rates this scope is aimed at), +6 decimals on `wallclock_s`, and `%.10g` on the value, which round-trips a +float32 and every 16-bit quantised value exactly. + +- [ ] **Step 1: Write the failing tests** + +Create `Client/udpscope/tests/ExportTest.cpp`: + +```cpp +#include "Export.h" + +#include +#include +#include +#include +#include + +using namespace udpscope; + +namespace { + +CsvTrace trace(const std::string& name, const std::vector& t, + const std::vector& v) { + CsvTrace c; + c.name = name; + c.data.t = t; + c.data.v = v; + return c; +} + +std::vector linesOf(const std::string& s) { + std::vector out; + std::istringstream is(s); + std::string line; + while (std::getline(is, line)) { + out.push_back(line); + } + return out; +} + +} // namespace + +TEST(BuildCsv, StartsWithTheDocumentedHeader) { + std::vector tr; + const std::vector l = linesOf(BuildCsv(tr, 0.0)); + ASSERT_EQ(l.size(), 1u); + EXPECT_EQ(l[0], "signal,time_s,wallclock_s,value"); +} + +TEST(BuildCsv, WritesOneRowPerSampleInLongFormat) { + std::vector tr; + tr.push_back(trace("Voltage", {1000.0, 1000.5}, {0.25, -0.5})); + tr.push_back(trace("Current", {1000.25}, {2.0})); + + const std::vector l = linesOf(BuildCsv(tr, 1000.0)); + ASSERT_EQ(l.size(), 4u); + EXPECT_EQ(l[1], "Voltage,0.000000000,1000.000000,0.25"); + EXPECT_EQ(l[2], "Voltage,0.500000000,1000.500000,-0.5"); + EXPECT_EQ(l[3], "Current,0.250000000,1000.250000,2"); +} + +// A capture starts at t0, so the relative column reads from zero whenever the +// export was taken. +TEST(BuildCsv, TimeIsRelativeToTheWindowStart) { + std::vector tr; + tr.push_back(trace("S", {1756291200.123456}, {1.0})); + const std::vector l = linesOf(BuildCsv(tr, 1756291200.0)); + ASSERT_EQ(l.size(), 2u); + EXPECT_EQ(l[1].compare(0, 14, "S,0.123456000"), 0) << l[1]; + EXPECT_NE(l[1].find(",1756291200.123456,"), std::string::npos) << l[1]; +} + +TEST(BuildCsv, AnEmptyTraceContributesNoRows) { + std::vector tr; + tr.push_back(trace("Empty", {}, {})); + tr.push_back(trace("S", {1.0}, {1.0})); + EXPECT_EQ(linesOf(BuildCsv(tr, 0.0)).size(), 2u); +} + +TEST(BuildCsv, MismatchedTimeAndValueCountsAreTruncated) { + CsvTrace c; + c.name = "S"; + c.data.t = {1.0, 2.0, 3.0}; + c.data.v = {1.0}; + std::vector tr; + tr.push_back(c); + EXPECT_EQ(linesOf(BuildCsv(tr, 0.0)).size(), 2u); +} + +TEST(ExportCsvFile, WritesWhatBuildCsvProduces) { + const std::string path = "/tmp/udpscope_export_test.csv"; + std::vector tr; + tr.push_back(trace("S", {1.0, 2.0}, {3.0, 4.0})); + + std::string err; + ASSERT_TRUE(ExportCsvFile(path, tr, 1.0, err)) << err; + + std::ifstream f(path.c_str()); + std::ostringstream ss; + ss << f.rdbuf(); + EXPECT_EQ(ss.str(), BuildCsv(tr, 1.0)); + std::remove(path.c_str()); +} + +TEST(ExportCsvFile, ReportsAnUnwritablePath) { + std::vector tr; + std::string err; + EXPECT_FALSE(ExportCsvFile("/proc/definitely/not/here.csv", tr, 0.0, err)); + EXPECT_FALSE(err.empty()); +} + +TEST(SignalsInPane, ListsALeafInOrder) { + PaneNode leaf; + Assignment a; + a.signalName = "B"; + leaf.signals.push_back(a); + a.signalName = "A"; + leaf.signals.push_back(a); + + const std::vector got = SignalsInPane(&leaf); + ASSERT_EQ(got.size(), 2u); + EXPECT_EQ(got[0], "B"); + EXPECT_EQ(got[1], "A"); +} + +// Exporting "all panes" must not write the same signal twice when it is +// dropped into two panes for comparison. +TEST(SignalsInPane, WalksTheTreeDepthFirstWithoutDuplicates) { + PaneNode root; + root.leaf = false; + root.a = std::unique_ptr(new PaneNode()); + root.b = std::unique_ptr(new PaneNode()); + Assignment a; + a.signalName = "X"; + root.a->signals.push_back(a); + a.signalName = "Y"; + root.a->signals.push_back(a); + a.signalName = "X"; + root.b->signals.push_back(a); + + const std::vector got = SignalsInPane(&root); + ASSERT_EQ(got.size(), 2u); + EXPECT_EQ(got[0], "X"); + EXPECT_EQ(got[1], "Y"); +} + +TEST(SignalsInPane, HandlesNullAndEmpty) { + EXPECT_TRUE(SignalsInPane(NULL).empty()); + PaneNode empty; + EXPECT_TRUE(SignalsInPane(&empty).empty()); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `Export.h: No such file or directory`. + +- [ ] **Step 3: Write `Export.h`** + +Create `Client/udpscope/Export.h`: + +```cpp +/** + * @file Export.h + * @brief Long-format CSV export of a capture or of the visible live window + * (spec §10). + */ +#ifndef UDPSCOPE_EXPORT_H +#define UDPSCOPE_EXPORT_H + +#include "PaneTree.h" +#include "Types.h" + +#include +#include + +namespace udpscope { + +/** One signal's samples, already restricted to the exported window. */ +struct CsvTrace { + std::string name; + Series data; +}; + +/** + * @param t0 the window start; `time_s` is measured from it. + * @return the whole file, header included, ending in a newline. + */ +std::string BuildCsv(const std::vector& traces, double t0); + +bool ExportCsvFile(const std::string& path, const std::vector& traces, + double t0, std::string& err); + +/** Every distinct signal assigned anywhere under @a n, first use first. */ +std::vector SignalsInPane(const PaneNode* n); + +} /* namespace udpscope */ + +#endif /* UDPSCOPE_EXPORT_H */ +``` + +- [ ] **Step 4: Write `Export.cpp`** + +Create `Client/udpscope/Export.cpp`: + +```cpp +#include "Export.h" + +#include +#include +#include + +namespace udpscope { + +namespace { + +void collect(const PaneNode* n, std::vector& out) { + if (n == NULL) { + return; + } + if (n->leaf) { + for (size_t i = 0u; i < n->signals.size(); i++) { + const std::string& name = n->signals[i].signalName; + if (std::find(out.begin(), out.end(), name) == out.end()) { + out.push_back(name); + } + } + return; + } + collect(n->a.get(), out); + collect(n->b.get(), out); +} + +} /* namespace */ + +std::vector SignalsInPane(const PaneNode* n) { + std::vector out; + collect(n, out); + return out; +} + +std::string BuildCsv(const std::vector& traces, double t0) { + std::string out = "signal,time_s,wallclock_s,value\n"; + char buf[320]; + for (size_t i = 0u; i < traces.size(); i++) { + const CsvTrace& c = traces[i]; + const size_t n = std::min(c.data.t.size(), c.data.v.size()); + for (size_t k = 0u; k < n; k++) { + std::snprintf(buf, sizeof(buf), "%s,%.9f,%.6f,%.10g\n", + c.name.c_str(), c.data.t[k] - t0, c.data.t[k], + c.data.v[k]); + out += buf; + } + } + return out; +} + +bool ExportCsvFile(const std::string& path, const std::vector& traces, + double t0, std::string& err) { + std::ofstream f(path.c_str(), std::ios::trunc); + if (!f) { + err = "cannot write " + path; + return false; + } + f << BuildCsv(traces, t0); + if (!f) { + err = "write failed for " + path; + return false; + } + return true; +} + +} /* namespace udpscope */ +``` + +Add `Export.cpp` to `CORE_SOURCES`. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='BuildCsv*:ExportCsvFile*:SignalsInPane*' +``` + +Expected: PASS, 10 tests. + +- [ ] **Step 6: Commit the export core** + +```bash +git add Client/udpscope/Export.h Client/udpscope/Export.cpp \ + Client/udpscope/tests/ExportTest.cpp Client/udpscope/CMakeLists.txt +git commit -m "feat(udpscope): long-format CSV export" +``` + +- [ ] **Step 7: Gather the traces and hook up the File menu** + +In `App.h`, add `#include "Export.h"` and: + +```cpp + /** + * @param node the subtree to export, or the whole tree when null. + * @return false with @a err set when there is nothing to export. + */ + bool gatherExport(const PaneNode* node, std::vector& out, + double& t0, std::string& err) const; + void exportCsv(const PaneNode* node); + + std::string exportDir_; /**< where the last export went */ + PaneNode* exportPane_ = NULL; /**< set by the pane context menu */ +``` + +In `App.cpp`: + +```cpp +bool App::gatherExport(const PaneNode* node, std::vector& out, + double& t0, std::string& err) const { + const std::vector names = + SignalsInPane(node != NULL ? node : tree_.root()); + if (names.empty()) { + err = "nothing to export: no signals are assigned"; + return false; + } + + const bool haveCapture = latch_.showing(); + /* A capture is exported whole; live data is exported over exactly the + range on screen, which is what the user is looking at. */ + const double x0 = haveCapture ? latch_.x0() : xaxis_.x0(); + const double x1 = haveCapture ? latch_.x1() : xaxis_.x1(); + t0 = x0; + + for (size_t i = 0u; i < names.size(); i++) { + TraceData d; + bool ok = false; + if (haveCapture) { + /* maxPoints is the raw count here: the export must not be + decimated, and FetchCaptureTrace fills raw regardless. */ + ok = FetchCaptureTrace(latch_.capture(), names[i], x0, x1, + opt_.maxPlotPoints, d); + } else { + ok = FetchLiveTrace(store_, names[i], x0, x1, opt_.maxPlotPoints, d); + } + if (!ok || d.raw.empty()) { + continue; + } + CsvTrace c; + c.name = names[i]; + c.data = d.raw; + out.push_back(c); + } + if (out.empty()) { + err = "nothing to export: no samples in the visible range"; + return false; + } + return true; +} + +void App::exportCsv(const PaneNode* node) { + std::vector traces; + double t0 = 0.0; + std::string err; + if (!gatherExport(node, traces, t0, err)) { + status_ = err; + return; + } + /* No file dialog: one fewer dependency, and a timestamped name in the + working directory is what a bench capture wants anyway. */ + char name[128]; + std::snprintf(name, sizeof(name), "udpscope-%lld.csv", + static_cast(std::time(NULL))); + const std::string path = exportDir_.empty() ? std::string(name) + : exportDir_ + "/" + name; + if (ExportCsvFile(path, traces, t0, err)) { + status_ = "exported " + path; + } else { + status_ = err; + } +} +``` + +`App.cpp` needs `#include ` for the file name stamp. + +Add to the File menu, above `Save Layout`: + +```cpp + if (ImGui::MenuItem("Export CSV (all panes)")) { + exportCsv(NULL); + } + if (ImGui::MenuItem("Export CSV (this pane)", NULL, false, + exportPane_ != NULL)) { + exportCsv(exportPane_); + } + ImGui::Separator(); +``` + +`exportPane_` is the pane the pointer was last over. In `drawPlotArea()`, +after `paneView_.drawTree(...)`, add: + +```cpp + exportPane_ = ctx.hoveredLeaf; +``` + +with `PaneNode* hoveredLeaf = nullptr;` added to `PaneContext`, set in +`PaneView::drawLeaf()` right after `ImPlot::BeginPlot()` succeeds: + +```cpp + if (ImPlot::IsPlotHovered()) { + ctx.hoveredLeaf = &leaf; + } +``` + +and cleared by `PaneView::drawTree()` before it walks the layout: + +```cpp + ctx.hoveredLeaf = nullptr; +``` + +- [ ] **Step 8: Build and verify by hand** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–16. + +Against the demo streamer: + +1. Two panes with one signal each, live. File → Export CSV (all panes) + writes `udpscope-.csv` in the working directory and the status bar + names it. +2. `head -3` shows the header and two rows whose `time_s` starts near 0 and + whose `wallclock_s` is the current Unix time. +3. `cut -d, -f1 file.csv | sort -u` lists exactly the two signals. +4. Arm the trigger, wait for a capture, export again: the row count matches + `window × rate` for each signal, and `time_s` spans the window. +5. Hover one pane, File → Export CSV (this pane): only that pane's signals + are in the file. + +- [ ] **Step 9: Commit** + +```bash +git add Client/udpscope/App.h Client/udpscope/App.cpp \ + Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp +git commit -m "feat(udpscope): export the capture or the live window to CSV" +``` + +--- + +### Task 17: Profile panes and the array-interpretation toggle + +Spec §4.2 says a signal that is genuinely a vector is plotted against element +index, not unrolled onto the time axis. Tasks 6–8 already store and override +those signals; nothing draws them yet and nothing lets the user flip the +interpretation. This closes both. + +**Files:** +- Modify: `Client/udpscope/PlotData.h` +- Modify: `Client/udpscope/PlotData.cpp` +- Modify: `Client/udpscope/PaneView.cpp` +- Modify: `Client/udpscope/SignalList.cpp` +- Modify: `Client/udpscope/tests/PlotDataTest.cpp` + +**Interfaces:** +- Consumes: `SignalStore::readProfile`, `Profile` (Task 6); + `Receiver::setProfileOverride`/`profileOverride` (Task 8); + `SignalMeta::isVectorProfile()` (Task 4); `PaneNode::profilePane` (Task 2); + `TraceData` (Task 10). +- Produces: `bool FetchProfileTrace(const SignalStore&, const std::string&, + TraceData&, double& stamp)`. + +**Why a profile gets a whole pane rather than sharing one:** its X axis is +element index, not seconds. Mixing it with a time trace in the same pane would +put two incompatible units on one axis. `PaneNode::profilePane` already exists +for exactly this, and `PaneTree` already propagates it through splits and +closes; this task is the first code that reads it. + +- [ ] **Step 1: Write the failing tests** + +Append to `Client/udpscope/tests/PlotDataTest.cpp`: + +```cpp +TEST(FetchProfileTrace, PlotsTheVectorAgainstElementIndex) { + SignalStore store; + std::vector metas; + SignalMeta m = scalar("vec"); + m.numCols = 4; + metas.push_back(m); + store.setSignals(metas); + + const double vals[4] = {10.0, 20.0, 30.0, 40.0}; + store.pushProfile("vec", 1234.5, vals, 4u); + + TraceData d; + double stamp = 0.0; + ASSERT_TRUE(FetchProfileTrace(store, "vec", d, stamp)); + EXPECT_TRUE(d.found); + EXPECT_DOUBLE_EQ(stamp, 1234.5); + ASSERT_EQ(d.raw.size(), 4u); + EXPECT_DOUBLE_EQ(d.raw.t[0], 0.0); + EXPECT_DOUBLE_EQ(d.raw.t[3], 3.0); + EXPECT_DOUBLE_EQ(d.raw.v[2], 30.0); + /* A profile is one screenful of points; it is never decimated. */ + EXPECT_EQ(d.draw.size(), 4u); +} + +TEST(FetchProfileTrace, FailsForASignalThatIsNotAProfile) { + SignalStore store; + std::vector metas; + metas.push_back(scalar("plain")); + store.setSignals(metas); + const double t[1] = {1.0}; + const double v[1] = {2.0}; + store.push("plain", t, v, 1u); + + TraceData d; + double stamp = 0.0; + EXPECT_FALSE(FetchProfileTrace(store, "plain", d, stamp)); + EXPECT_FALSE(FetchProfileTrace(store, "absent", d, stamp)); +} + +TEST(FetchProfileTrace, AnEmptyProfileIsNotDrawable) { + SignalStore store; + std::vector metas; + SignalMeta m = scalar("vec"); + m.numCols = 4; + metas.push_back(m); + store.setSignals(metas); + + TraceData d; + double stamp = 0.0; + EXPECT_FALSE(FetchProfileTrace(store, "vec", d, stamp)); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd Client/udpscope && cmake --build build -j 2>&1 | tail -5 +``` + +Expected: FAIL — `'FetchProfileTrace' was not declared in this scope`. + +- [ ] **Step 3: Implement `FetchProfileTrace`** + +Add to `Client/udpscope/PlotData.h`, beside the other fetchers: + +```cpp +/** + * @brief Reads the latest vector snapshot, plotted against element index. + * @param stamp receives the wall-clock time the snapshot was taken. + * @return false when the signal has no profile snapshot. + */ +bool FetchProfileTrace(const SignalStore& store, const std::string& name, + TraceData& out, double& stamp); +``` + +and to `Client/udpscope/PlotData.cpp`: + +```cpp +bool FetchProfileTrace(const SignalStore& store, const std::string& name, + TraceData& out, double& stamp) { + out.raw.clear(); + out.draw.clear(); + out.found = false; + + Profile p; + if (!store.readProfile(name, p) || p.v.empty()) { + return false; + } + out.found = true; + stamp = p.time; + out.raw.t = p.x; + out.raw.v = p.v; + /* Element count is bounded by the array size, not by a sample rate, so + there is nothing to decimate. */ + out.draw = out.raw; + return true; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FetchProfileTrace*' +``` + +Expected: PASS, 3 tests. + +- [ ] **Step 5: Draw profile panes** + +In `Client/udpscope/PaneView.cpp`, at the top of `drawLeaf()`'s plot body, +branch on the pane kind. Replace the `ImPlot::SetupAxes("t [s]", "div")` call +and the trace loop's entry condition with: + +```cpp + if (leaf.profilePane) { + ImPlot::SetupAxes("element", "value"); + ImPlot::SetupAxisLimits(ImAxis_X1, 0.0, 1.0, ImPlotCond_Once); + ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Once); + for (size_t i = 0u; i < leaf.signals.size(); i++) { + Assignment& a = leaf.signals[i]; + TraceData d; + double stamp = 0.0; + if (ctx.store == nullptr || + !FetchProfileTrace(*ctx.store, a.signalName, d, stamp) || + d.draw.empty()) { + continue; + } + char plabel[128]; + std::snprintf(plabel, sizeof(plabel), "%s @%.3f s", + a.signalName.c_str(), stamp); + ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth); + ImPlot::PlotLine(plabel, d.draw.t.data(), d.draw.v.data(), + static_cast(d.draw.size())); + } + ImPlot::EndPlot(); + return; /* no divisions, no cursors, no shared X axis here */ + } +``` + +A profile pane keeps ImPlot's own auto-fit (`ImPlotCond_Once` plus the user's +zoom), because it has no time axis to share and no division model to obey. + +Assigning a signal to a pane decides what kind of pane it is. In the +drag-and-drop accept block, replace the plain `leaf.signals.push_back(a);` +with: + +```cpp + const SignalMeta& dm = ctx.metaFor(dropped); + const bool wantsProfile = dm.isVectorProfile(); + /* Not a `return`: the drop target sits between BeginPlot() and + EndPlot(), so bailing out here would unbalance ImPlot. */ + if (leaf.signals.empty() || leaf.profilePane == wantsProfile) { + leaf.profilePane = wantsProfile; + leaf.signals.push_back(a); + } +``` + +- [ ] **Step 6: Add the interpretation toggle to the signal list** + +In `Client/udpscope/SignalList.cpp`, inside the per-signal loop after the +tooltip, add: + +```cpp + if (m.numElements() > 1u && m.timeMode == kTimePacket) { + /* Only PACKET arrays are ambiguous: everything else says outright + whether it is a burst. */ + if (ImGui::BeginPopupContextItem("##sigmenu")) { + bool prof = rx_.profileOverride(m.name); + if (ImGui::MenuItem("plot against element index", NULL, &prof)) { + rx_.setProfileOverride(m.name, prof); + status_ = m.name + (prof ? ": vector profile" + : ": packed burst"); + } + ImGui::EndPopup(); + } + } +``` + +The override reaches the receiver thread through `Receiver`'s command queue +and survives a CONFIG re-send (Task 8), so a stream that re-announces itself +does not silently revert to burst. + +`SignalList.cpp` needs `#include "Types.h"` for `kTimePacket`; `App.h` already +pulls it in through `SignalStore.h`. + +- [ ] **Step 7: Build and verify by hand** + +```bash +cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests +``` + +Expected: PASS, all tests from Tasks 1–17. + +Against the demo streamer, whose `SineArrayGAM` produces a packed array: + +1. Drop the array signal into a pane: it is unrolled onto the time axis and + looks like a continuous sine, which is the correct default. +2. Right-click it in the signal list, tick **plot against element index**: the + status bar confirms `vector profile`. +3. Drop it into an empty pane: the X axis now reads `element`, the trace has + exactly `NumberOfElements` points, and the legend shows the snapshot time + ticking forward. +4. Try to drop a scalar into that same pane: the drop is refused, because the + axes are incompatible. +5. Untick the override: dropping it into a fresh pane gives a time trace again. + +- [ ] **Step 8: Commit** + +```bash +git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \ + Client/udpscope/PaneView.cpp Client/udpscope/SignalList.cpp \ + Client/udpscope/tests/PlotDataTest.cpp +git commit -m "feat(udpscope): index-plot vector profiles and toggle the array interpretation" +``` + +--- + +### Task 18: Documentation and repository integration + +**Files:** +- Create: `Docs/UDPScope.md` +- Create: `Client/udpscope/resources/udpscope.desktop` +- Create: `Client/udpscope/resources/icons/udpscope.svg` +- Modify: `Client/udpscope/CMakeLists.txt` +- Modify: `README.md` +- Modify: `CLAUDE.md` +- Modify: `ARCHITECTURE.md` +- Modify: `.gitignore` + +**Interfaces:** +- Consumes: everything built in Tasks 1–17. No new code. + +- [ ] **Step 1: Write `Docs/UDPScope.md`** + +Create `Docs/UDPScope.md`: + +````markdown +# UDPScope + +A bench oscilloscope that attaches directly to one `UDPStreamer`. No StreamHub, +no WebSocket, no browser: the UDPS datagrams go straight into the scope through +the standalone C client in `Common/Client/c`. + +Use it when you want to look at a signal now — on a control-room machine, over +a lab network, on a host that has nothing installed. Use StreamHub instead when +you need several sources aggregated, history on disk, or more than one client +watching at once. + +## Build + +Needs SDL2 and OpenGL; ImGui, ImPlot and GoogleTest are fetched by CMake. + +```bash +cd Client/udpscope +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +./build/udpscope_tests # unit tests +``` + +## Run + +```bash +./build/UDPScope --host 192.168.1.10 --port 44500 +``` + +| Option | Default | Meaning | +|---|---|---| +| `--host ADDR` | `127.0.0.1` | Streamer control address | +| `--port N` | `44500` | Streamer control port | +| `--multicast GROUP` | — | Join this group instead of unicast | +| `--iface ADDR` | — | Local interface IP for the multicast join | +| `--data-port N` | `0` | Data port when the streamer separates it | +| `--silence SEC` | `2.0` | Reconnect after this long without a packet | +| `--config PATH` | `$XDG_CONFIG_HOME/udpscope/session.conf` | Session file | +| `--max-mpts N` | `4000` | Drawn points per trace before decimation | + +Long `--` options only, matching `Common/Client/c/example/udps_dump.c`. An +option given on the command line beats the session file; anything omitted comes +from the file. + +## Panes + +The plot area is a splittable grid. Hover a pane to reveal its handles: + +- the four edge handles split it left/right/top/bottom; +- the ✕ closes it and gives its space back to its sibling; +- the border between two panes is a splitter you can drag. + +Drag a signal from the list on the left into a pane to plot it. Right-click a +legend entry for colour, line width and vertical scale; the same menu removes +the trace. + +## Vertical scale + +Panes are eight divisions tall, ±4 about the centre line, so traces in +different units share a pane without lying about each other's amplitude. Each +trace has its own volts-per-division: + +- **auto** — fits the samples currently on screen; +- **range** — uses the `range_min`/`range_max` the CONFIG packet carries; +- **manual** — you set per-division and offset, as on a bench scope. + +The legend shows the value per division for each trace. + +## Time axis + +Every pane shares one X axis. In live mode it follows the newest sample; any +pan or zoom detaches it, and View → Live re-attaches. View → window sets the +span in seconds. + +## Trigger + +The trigger bar configures a client-side trigger: signal, edge, threshold, +hysteresis, window length, and where in that window the trigger point sits +(`pre %`). + +| Mode | Behaviour | +|---|---| +| **Norm** | Re-arms after every capture; the display holds the last one until the next trigger | +| **1x** | Captures once and stays held until you press Re-arm | + +The badge reads `IDLE`, `ARMED nn%`, `TRIG'D` or `HELD`. The percentage is the +pre-trigger window filling up: the scope refuses to arm until it holds enough +history to back-fill the part of the capture that precedes the trigger, so a +capture never starts mid-waveform. + +Untick **follow** to study one capture while later ones go by; tick it again +and the newest capture appears at once. **Live** drops the capture and goes +back to the rolling display. + +Known simplification: with `edge = both` and a non-zero hysteresis, re-arming +uses the rising-edge arm level for both directions. Set hysteresis to 0 if you +need symmetric behaviour on a noisy bipolar signal. + +## Cursors and measurements + +View → Cursors puts two draggable time cursors in every pane at once; the +status bar reads `A`, `B`, `dt` and `1/dt`. View → Measurements overlays +min/max/peak-to-peak/average/RMS per trace, taken between the cursors when they +are up and over the visible range otherwise. + +All statistics come from the undecimated samples, never from the drawn +envelope, so a single-sample spike is counted even when it is not individually +visible. + +## Array signals + +A signal with `NumberOfElements > 1` is a packed burst by default: its elements +are unrolled onto the time axis using the accompanying time signal or the +declared sampling rate. A signal that is genuinely a vector — a spatial +profile, not a burst — is plotted against element index instead. Toggle the +interpretation from the signal list's context menu; the choice survives a +CONFIG re-send. + +## Export + +File → Export CSV writes long format: + +``` +signal,time_s,wallclock_s,value +Voltage,0.000000000,1756291200.123456,0.4981 +``` + +`time_s` is measured from the start of the exported window; `wallclock_s` is +absolute. One row per sample per signal, because signals carry independent +timestamps and a wide format would need resampling. Export covers every +assigned signal, or just the pane under the pointer. + +## Session file + +Layout, colours, vertical scales, trigger settings, cursors and the source are +saved on exit and from File → Save Layout, to +`$XDG_CONFIG_HOME/udpscope/session.conf`. The format is line-based and +hand-editable; indentation is decorative. A malformed file is reported in the +status bar and ignored outright, never partially applied. + +## Diagnostics + +The status bar counts packets, frames, counter gaps, dropped fragments and +reconnects; the last three turn red as soon as they are non-zero. + +`udpscope_rxprobe` is a headless build of the same receive path. When the GUI +shows nothing, run it to find out whether the problem is the network or the +scope: + +```bash +./build/udpscope_rxprobe --host 192.168.1.10 --port 44500 +``` + +Cross-check against the reference C client if they disagree: + +```bash +cd Common/Client/c && make && ./udps_dump 192.168.1.10 44500 +``` + +## Relationship to StreamHub + +| | UDPScope | StreamHub clients | +|---|---|---| +| Sources | One streamer | Many, aggregated | +| Transport | UDPS direct | UDPS → hub → WebSocket | +| History | In-memory rings only | Disk-backed `.shist` files | +| Trigger | In the client | In the hub, shared by clients | +| Processes | One | Hub plus client | + +The two share the wire format (`Common/UDP/UDPSProtocol.h`) and nothing else. +UDPScope reads `Client/streamhub/SignalBuffer.h` and the shared fonts +read-only; it never modifies anything under `Client/streamhub/`. +```` + +- [ ] **Step 1b: Add the desktop entry and icon** + +Spec §12 asks for install rules covering the `.desktop` entry and icon, which +Tasks 1–17 left out because there was nothing to install them from. UDPScope +needs its own rather than reusing `Client/streamhub/resources/`, which is +read-only to this project. + +Create `Client/udpscope/resources/udpscope.desktop`: + +```ini +[Desktop Entry] +Type=Application +Name=UDPScope +GenericName=Signal Oscilloscope +Comment=Bench oscilloscope attached directly to a MARTe2 UDPStreamer +Exec=UDPScope +Icon=udpscope +Terminal=false +Categories=Science;Engineering;DataVisualization; +Keywords=MARTe2;oscilloscope;UDPS;signals; +StartupWMClass=UDPScope +``` + +Create `Client/udpscope/resources/icons/udpscope.svg` — a trace on a graticule, +in the same Catppuccin palette the app uses: + +```xml + + + + + + + + + +``` + +Extend the install block in `Client/udpscope/CMakeLists.txt`: + +```cmake +install(FILES ${RESOURCE_DIR}/udpscope.desktop + DESTINATION share/applications) +install(FILES ${RESOURCE_DIR}/icons/udpscope.svg + DESTINATION share/icons/hicolor/scalable/apps) +``` + +`RESOURCE_DIR` points at `Client/streamhub/resources` (Task 1, for the fonts), +so add a second variable beside it and use that here: + +```cmake +set(UDPSCOPE_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/resources) +``` + +then replace `${RESOURCE_DIR}` with `${UDPSCOPE_RESOURCE_DIR}` in the two +install lines above. + +Verify: + +```bash +cd Client/udpscope && cmake -B build -DCMAKE_INSTALL_PREFIX=/tmp/udpscope-prefix \ + && cmake --build build -j && cmake --install build +find /tmp/udpscope-prefix -type f | sort +``` + +Expected: `bin/UDPScope`, the fonts under `share/udpscope`, +`share/applications/udpscope.desktop` and +`share/icons/hicolor/scalable/apps/udpscope.svg`. + +- [ ] **Step 2: Add the repository entries** + +In `README.md`, add to the capability table after the `Integrated client` row: + +```markdown +| **Direct scope** | `Client/udpscope` | ImGui bench oscilloscope attached straight to one UDPStreamer | +``` + +add to the repository structure block under `Client/`: + +```text +├── Client/udpscope/ Direct-UDPS ImGui oscilloscope (SDL2 + ImPlot) +``` + +add a component section after **StreamHub Application**: + +```markdown +### UDPScope + +Single-source bench oscilloscope (`Client/udpscope/`) that decodes UDPS +datagrams directly through `Common/Client/c` — no hub, no browser, one process. +Splittable pane grid, client-side Normal/Single trigger with a pre-trigger +window, per-trace division scaling, cursors and measurements, CSV export and a +saved session layout. + +```bash +cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build +./build/UDPScope --host 127.0.0.1 --port 44500 +``` + +See `Docs/UDPScope.md`. +``` + +and add to the documentation table: + +```markdown +| `Docs/UDPScope.md` | Direct-UDPS bench oscilloscope user guide | +``` + +In `CLAUDE.md`, add to the build block after the Qt client lines: + +```bash +# Direct-UDPS ImGui bench scope (not a MARTe2 component; needs SDL2) +cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build +./build/udpscope_tests +``` + +and a paragraph after the Qt client paragraph: + +```markdown +**UDPScope** (`Client/udpscope/`): bench oscilloscope that talks UDPS directly +to a single `UDPStreamer` through the standalone C client, bypassing StreamHub +entirely. A receiver thread owns the C client and the trigger FSM; a +mutex-guarded `SignalStore` hands data to the GUI thread. Everything except +`main.cpp`, `App.cpp` and `PaneView.cpp` is framework-free and unit-tested in +`udpscope_tests`. It consumes `Client/streamhub/SignalBuffer.h` and the shared +fonts read-only and must never modify anything under `Client/streamhub/`. +``` + +In `ARCHITECTURE.md`, add UDPScope as a second consumer of the streaming path: +a client that attaches to `UDPStreamer` directly rather than through StreamHub, +with the trigger in the client instead of the hub. + +In `.gitignore`, add: + +```text +Client/udpscope/build/ +``` + +- [ ] **Step 3: Check the documentation against the build** + +```bash +cd Client/udpscope && rm -rf build \ + && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j \ + && ./build/udpscope_tests +``` + +Expected: a clean tree builds from the commands as written, and every test +passes. Then walk `Docs/UDPScope.md` from top to bottom against the running +binary: every option in the table is accepted, every menu item named exists, +and the trigger badge shows the four documented strings. + +- [ ] **Step 4: Commit** + +```bash +git add Docs/UDPScope.md Client/udpscope/resources Client/udpscope/CMakeLists.txt \ + README.md CLAUDE.md ARCHITECTURE.md .gitignore +git commit -m "docs: UDPScope bench oscilloscope" +``` + +--- +