# 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) { // Two buckets of four. In the first the minimum comes before the maximum, // in the second the order is reversed. An implementation that emitted // (min, max) by value rather than by time passes on bucket 0 and fails on // bucket 1, so this data exercises the swap that a monotonically growing // ramp never triggers. const double st[8] = {0, 1, 2, 3, 4, 5, 6, 7}; const double sv[8] = {-5, 0, 0, 9, 9, 0, 0, -5}; Series pair; MinMaxDecimate(st, sv, 8, 4, pair); ASSERT_EQ(pair.size(), 4u); const double wantT[4] = {0, 3, 4, 7}; const double wantV[4] = {-5, 9, 9, -5}; for (size_t i = 0; i < 4; i++) { EXPECT_EQ(pair.t[i], wantT[i]) << "time at " << i; EXPECT_EQ(pair.v[i], wantV[i]) << "value at " << i; } 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() # Guarded: file(COPY) is a hard configure error on a missing source, which # would defeat the fallback the block above just chose. if(EXISTS ${FONT_DIR}) file(COPY ${FONT_DIR} DESTINATION ${CMAKE_BINARY_DIR}/resources) endif() # ── 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 // Arrival wanders either side of the prediction. wallSec is a local receive // timestamp, so it only ever advances — jitter shows up as the gap growing // and shrinking, never as the clock going backwards. EXPECT_DOUBLE_EQ(off.map(11.0, 1001.02), 1001.0); // +0.02 late EXPECT_DOUBLE_EQ(off.map(12.0, 1001.97), 1002.0); // -0.03 early } // The threshold has to be symmetric. A producer whose clock steps FORWARD (an // NTP correction on the producer's host, say) puts the prediction permanently // ahead of the wall clock — a one-sided "recalibrate only when wall is ahead" // test never fires for it, and the trace sits in the future for the rest of the // run. TEST(ClockOffset, RecalibratesWhenTheProducerClockJumpsForward) { ClockOffset off; off.map(10.0, 1000.0); // offset = 990 // Producer leaps 100 s ahead while only 1 s of wall time passes. EXPECT_DOUBLE_EQ(off.map(111.0, 1001.0), 1001.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(); /* Not just "it did not crash": the store must still be coherent after the race. The window is 0.05 s at 1 MHz, so the ring spans at most 0.05 * kRingMargin seconds, and readLast was capped at 4096 points. */ double oldest = 0.0, newest = 0.0; ASSERT_TRUE(s.span("a", oldest, newest)); EXPECT_GE(newest, oldest); EXPECT_LE(newest - oldest, 0.05 * SignalStore::kRingMargin * 1.5); EXPECT_LE(out.size(), 4096u); EXPECT_EQ(out.t.size(), out.v.size()); for (size_t i = 1; i < out.size(); ++i) { EXPECT_GE(out.t[i], out.t[i - 1]) << "timestamps went backwards at " << i; } } ``` - [ ] **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()); EXPECT_FALSE(rx.link().running); } ``` - [ ] **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("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/SignalList.cpp` — that is where Task 9 put `App::drawSignalList()`, not `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 SignalList.cpp PaneView.cpp ) ``` `SignalList.cpp` stays in the list — it holds `App::drawSignalList()` and dropping it is a link error, not a warning. `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); } ``` There is no View menu yet — Task 9 built only File and Help. Add one to `drawMenuBar()`, between the File and Help blocks: ```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()`, **replace** the File menu block Task 9 wrote (the one whose only item is Quit) — do not add a second one: ```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" ``` ---