Files
MARTe-Integrated-Components/docs/superpowers/plans/2026-08-27-udpscope.md
T
Martino FerrariandClaude Opus 4.6 892e3eae28 fix(udpscope): bound accumulated-burst chaining so packet loss cannot displace the trace
Forward-chaining each accumulated burst onto the previous one suppresses
arrival jitter, but an unchecked chain never recovers: one lost datagram, or a
declared sampling rate that differs from the producer's real one, dates every
later sample early for the rest of the run. The chain is now a prediction,
compared each packet against the arrival anchor and abandoned beyond
kBurstResyncThresholdS, which bounds the error instead of accumulating it.

Plan amended so the hrt-fit fallback (unusable here: the fit needs 32 packets
and is itself corrupted by bursty arrivals) cannot come back.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 20:08:05 +02:00

324 KiB
Raw Blame History

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<double> 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:

#include "Decimate.h"

#include <gtest/gtest.h>

#include <vector>

using namespace udpscope;

TEST(MinMaxDecimate, PassesShortInputThroughUnchanged) {
    const std::vector<double> t{0.0, 1.0, 2.0};
    const std::vector<double> 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<double> t(1000), v(1000, 0.0);
    for (size_t i = 0; i < t.size(); i++) { t[i] = static_cast<double>(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<double> t(100), v(100);
    for (size_t i = 0; i < t.size(); i++) {
        t[i] = static_cast<double>(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<double> t(400), v(400);
    for (size_t i = 0; i < t.size(); i++) {
        t[i] = static_cast<double>(i);
        v[i] = (i % 2 == 0) ? -static_cast<double>(i) : static_cast<double>(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:

/**
 * @file Types.h
 * @brief Plain data shared across UDPScope modules. No logic, no dependencies.
 */
#pragma once

#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>

namespace udpscope {

/** A time series as two parallel arrays, which is what ImPlot wants. */
struct Series {
    std::vector<double> t;
    std::vector<double> 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:

/**
 * @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:

#include "Decimate.h"

#include <algorithm>

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_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 18 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
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:

./build/udpscope_tests

Expected on a first run before Decimate.cpp is written: a link error for MinMaxDecimate. Since Steps 34 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
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<Assignment> signals; Orient orient; double ratio; std::unique_ptr<PaneNode> 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:

#include "PaneTree.h"

#include <gtest/gtest.h>

using namespace udpscope;

namespace {

const Rect kScreen{0.0, 0.0, 1000.0, 600.0};

std::vector<PaneTree::Placed> leavesOf(const PaneTree& tree, const Rect& area) {
    std::vector<PaneTree::Placed>   leaves;
    std::vector<PaneTree::Splitter> 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<PaneTree::Placed>   out;
    std::vector<PaneTree::Splitter> 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<PaneTree::Placed>   leaves;
    std::vector<PaneTree::Splitter> 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
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:

/**
 * @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 <memory>
#include <string>
#include <vector>

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<Assignment> 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<PaneNode> 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<PaneNode> node);

    /**
     * @brief Walk the tree, producing every leaf's rectangle and every split's
     *        drag zone.
     */
    void layout(const Rect& area,
                std::vector<Placed>& leaves,
                std::vector<Splitter>& 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<Splitter>& 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<Placed>& leaves,
                           std::vector<Splitter>& 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<PaneNode> root_;
};

} /* namespace udpscope */
  • Step 4: Write the implementation

Create Client/udpscope/PaneTree.cpp:

#include "PaneTree.h"

#include <algorithm>

namespace udpscope {

PaneTree::PaneTree() : root_(new PaneNode()) {}

void PaneTree::setRoot(std::unique_ptr<PaneNode> 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<Placed>& leaves,
                          std::vector<Splitter>& 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<Placed>& leaves,
                      std::vector<Splitter>& 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<PaneNode> first(new PaneNode());
    first->signals     = std::move(leaf->signals);
    first->profilePane = leaf->profilePane;

    std::unique_ptr<PaneNode> 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<PaneNode> 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<Splitter>& 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 <cmath> 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:

set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
)
  • Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneTree*'

Expected: PASS, 12 tests.

  • Step 7: Commit
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 ClockOffsetdouble map(double producerSec, double wallSec), bool valid() const, void reset(), static constexpr double kRecalibThresholdS = 0.5
    • class HrtRateFitvoid 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:

#include "TimeBase.h"

#include <gtest/gtest.h>

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<uint64_t>(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<uint64_t>(i) * 1000000u, 1000.0 + i * 0.001);
    }
    EXPECT_FALSE(fit.ready());

    fit.add(static_cast<uint64_t>(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<uint64_t>(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
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:

/**
 * @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 <cstddef>
#include <cstdint>
#include <deque>

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<Sample> samples_;
    size_t n_    = 0;
    double rate_ = 0.0;
};

} /* namespace udpscope */
  • Step 4: Write the implementation

Create Client/udpscope/TimeBase.cpp:

#include "TimeBase.h"

#include <cmath>

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<double>(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<double>(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<double>(hrt) / rate_;
}

} /* namespace udpscope */
  • Step 5: Register the source with CMake
set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
)
  • Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ClockOffset*:HrtRateFit*:TimeSignalScale*'

Expected: PASS, 9 tests.

  • Step 7: Commit
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:

/* 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<uint64_t>(numRows ? numRows : 1u) *
                           static_cast<uint64_t>(numCols ? numCols : 1u);
        return n == 0u ? 1u : static_cast<uint32_t>(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:

#include "FrameDecoder.h"

#include <gtest/gtest.h>

#include <vector>

using namespace udpscope;

namespace {

/** Builds a FrameView over vectors the test owns. */
struct FrameBuilder {
    std::vector<std::vector<double>> storage;
    std::vector<const double*>       ptrs;
    std::vector<uint32_t>            counts;
    FrameView                        view;

    void addSignal(std::vector<double> 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<uint32_t>(s.size()));
        }
        view.hrt        = hrt;
        view.recvTime   = recvTime;
        view.numSamples = numSamples;
        view.numSignals = static_cast<uint32_t>(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<double> 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<double> 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<double> 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<double> 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<double> all;

    for (int p = 0; p < 40; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, static_cast<double>(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<uint64_t>(producerSec * ticks),
                                      arrival, 10);
        dec.beginFrame(f);
        std::vector<double> 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)";
    }
}

// ADDED in Task 4 review. The counterweight to the test above: suppressing
// arrival jitter by chaining bursts is only safe while the chain is CHECKED. On
// UDP packets are lost, and an unchecked chain closes the hole silently and
// dates every later sample early for the rest of the run.
TEST(FrameDecoder, AccumulatedScalarResynchronisesAfterLostPackets) {
    FrameDecoder dec;
    SignalMeta m;
    m.name         = "Acc";
    m.typeCode     = 9;
    m.numRows      = 1;
    m.samplingRate = 1000.0;          /* 10 samples = 10 ms per packet */
    dec.setSignals({m});

    std::vector<double> ts;
    for (int p = 0; p < 10; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const FrameView& f = fb.build(0, 500.0 + p * 0.010, 10);
        dec.beginFrame(f);
        ASSERT_TRUE(dec.timestamps(f, 0, ts));
    }
    EXPECT_NEAR(ts[9], 500.090, 1e-9);

    /* A full second of packets never arrives. The next one lands at 501.100. */
    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& f = fb.build(0, 501.100, 10);
    dec.beginFrame(f);
    ASSERT_TRUE(dec.timestamps(f, 0, ts));

    /* Chaining blindly would put this burst at 500.091..500.100, as though no
     * data were missing. */
    EXPECT_NEAR(ts[0], 501.091, 1e-9);
    EXPECT_NEAR(ts[9], 501.100, 1e-9);
}

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<double> last;
    for (int p = 0; p < 40; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const double producerSec = 100.0 + p * 0.010;   /* 10 ms per packet */
        const FrameView& f = fb.build(static_cast<uint64_t>(producerSec * ticks),
                                      700.0 + p * 0.010, 10);
        dec.beginFrame(f);
        std::vector<double> 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<double> 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<double> all;
    for (int p = 0; p < 8; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(8, 1.0));
        const FrameView& f = fb.build(0, 20.0 + p * 0.05 + jitter[p]);
        dec.beginFrame(f);
        std::vector<double> 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<double> 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
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:

/**
 * @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 <vector>

namespace udpscope {

class FrameDecoder {
public:
    /** Installs the signal table. Clears all per-signal timing history. */
    void setSignals(const std::vector<SignalMeta>& signals);

    const std::vector<SignalMeta>& 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<double>& tsOut);

    /** Forgets all timing history; call on reconnect. */
    void reset();

private:
    bool packetBurst(uint32_t idx, uint32_t nElems, double wallNow,
                     std::vector<double>& 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<SignalMeta> signals_;
    std::vector<SigState>   state_;
    HrtRateFit              hrtFit_;
};

} /* namespace udpscope */
  • Step 5: Write the implementation

Create Client/udpscope/FrameDecoder.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<SignalMeta>& 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<double>& 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<double>(nElems);
    tsOut.resize(nElems);
    for (uint32_t e = 0; e < nElems; e++) {
        tsOut[e] = st.lastPacketWall + static_cast<double>(e + 1u) * dt;
    }
    st.lastPacketWall = wallNow;
    return true;
}

bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
                              std::vector<double>& 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<double>(e) * dt)
                     : (anchor - static_cast<double>(nElems - 1u - e) * dt);
        }
        return true;
    }

    /* Rule 3: accumulated scalar.
     *
     * AMENDED after Task 4 review. The version below originally sent EVERY
     * accumulated scalar through the hrt fit, falling back to packetBurst until
     * the fit was ready. That cannot work when a declared samplingRate is
     * present: HrtRateFit needs 32 packets, bursty delivery can begin before
     * that, and packetBurst then crams a 10 ms burst into a 50 us arrival gap —
     * exactly the sawtooth this rule exists to prevent. Worse, HrtRateFit fits
     * hrt against ARRIVAL time, so a burst episode corrupts the very rate the
     * fallback is waiting on.
     *
     * With a declared rate none of that is needed: the intra-packet step is
     * exact, and bursts are contiguous, so the next burst is PREDICTED at
     * lastEmittedEnd + dt. The prediction must be checked, not trusted — a pure
     * chain silently closes the hole left by a lost datagram and dates every
     * later sample early for the rest of the run. So each packet compares the
     * prediction against the arrival anchor and abandons it beyond
     * kBurstResyncThresholdS. The hrt path below remains for samplingRate == 0. */
    if (d.numElements() == 1u && nElems > 1u) {
        const double dtDeclared = (d.samplingRate > 0.0) ? (1.0 / d.samplingRate) : 0.0;
        if (d.samplingRate > 0.0) {
            const double arrivalAnchor =
                wallNow - static_cast<double>(nElems - 1u) * dtDeclared;
            double base = arrivalAnchor;
            if (st.lastEmittedValid) {
                const double predicted = st.lastEmittedEnd + dtDeclared;
                if (std::fabs(predicted - arrivalAnchor) <= kBurstResyncThresholdS) {
                    base = predicted;
                }
            }
            tsOut.resize(nElems);
            for (uint32_t e = 0; e < nElems; e++) {
                tsOut[e] = base + static_cast<double>(e) * dtDeclared;
            }
            st.lastEmittedEnd   = tsOut[nElems - 1u];
            st.lastEmittedValid = true;
            return true;
        }
        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 (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<double>(st.prevAccCount);
        } else {
            dt = kDefaultDt;
        }

        tsOut.resize(nElems);
        for (uint32_t e = 0; e < nElems; e++) {
            tsOut[e] = base + static_cast<double>(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
set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
)
  • Step 7: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*'

Expected: PASS, 9 tests.

If AccumulatedScalarSurvivesBurstyDelivery fails, do NOT reach for the hrt fit: with a declared samplingRate rule 3 never consults it, precisely because the fit is not ready for the first 32 packets and is itself corrupted by bursty arrivals. Check instead that lastEmittedEnd/lastEmittedValid are being updated on every emitted burst. The only test that may legitimately fall through to rule 4 early is AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared, whose arrivals are uniform, so packetBurst is accurate there.

  • Step 8: Commit
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:
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:

#include "Trigger.h"
#include <gtest/gtest.h>
#include <cmath>
#include <vector>

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<double> t(1000), v(1000);
    for (size_t i = 0; i < t.size(); ++i) {
        t[i] = 1.0 + static_cast<double>(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
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:

/**
 * @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 <cstddef>
#include <string>

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:

#include "Trigger.h"

#include <algorithm>

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:

set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
    Trigger.cpp
)
  • Step 6: Run the tests and verify they pass
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
cd Client/udpscope && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 15.

  • Step 8: Commit
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:
struct Profile { double time = 0.0; std::vector<double> x, v; };
struct Capture {
    uint64_t seq = 0;
    double   trigTime = 0.0, t0 = 0.0, t1 = 0.0;
    std::vector<std::string> names;
    std::vector<Series>      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<SignalMeta>& metas);
    std::vector<SignalMeta> 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:

#include "SignalStore.h"
#include <gtest/gtest.h>
#include <atomic>
#include <cmath>
#include <thread>
#include <vector>

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<double> t(n), v(n);
    const double dt = 1.0 / rate;
    for (size_t i = 0; i < n; ++i) {
        t[i] = t0 + static_cast<double>(i) * dt;
        v[i] = static_cast<double>(i);
    }
    s.push(name, t.data(), v.data(), n);
    return t0 + static_cast<double>(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<double>(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<size_t>(1.0e6 * 0.2 * SignalStore::kRingMargin);
    EXPECT_NEAR(static_cast<double>(s.capacity("a")), static_cast<double>(want),
                0.1 * static_cast<double>(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<SignalMeta> 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<bool> 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
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:

/**
 * @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 <cstdint>
#include <map>
#include <mutex>
#include <string>
#include <vector>

namespace udpscope {

/** Latest snapshot of a true vector signal, plotted against element index. */
struct Profile {
    double time = 0.0;
    std::vector<double> 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<std::string> names;
    std::vector<Series>      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<SignalMeta>& metas);
    std::vector<SignalMeta> 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<SignalMeta>     metas_;
    std::map<std::string, Entry> 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:

#include "SignalStore.h"

#include <algorithm>
#include <cmath>

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<SignalMeta>& metas) {
    std::lock_guard<std::mutex> 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<SignalMeta> SignalStore::signals() const {
    std::lock_guard<std::mutex> lk(mu_);
    return metas_;
}

uint64_t SignalStore::generation() const {
    std::lock_guard<std::mutex> 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<std::mutex> lk(mu_);
    std::map<std::string, Entry>::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<double>(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<std::mutex> lk(mu_);
    std::map<std::string, Entry>::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<double>(i);
        p.v[i] = v[i];
    }
}

size_t SignalStore::readLast(const std::string& name, size_t n, Series& out) const {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::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<std::mutex> lk(mu_);
    std::map<std::string, Entry>::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<std::mutex> lk(mu_);
    std::map<std::string, Entry>::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<std::mutex> lk(mu_);
    std::map<std::string, Entry>::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<std::mutex> lk(mu_);
    std::map<std::string, Entry>::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<std::mutex> lk(mu_);
    std::map<std::string, Entry>::const_iterator it = entries_.find(name);
    return (it == entries_.end()) ? 0u : it->second.buf.capacity;
}

void SignalStore::setWindowSec(double windowSec) {
    std::lock_guard<std::mutex> lk(mu_);
    if (windowSec > 0.0) {
        windowSec_ = windowSec;
    }
}

double SignalStore::windowSec() const {
    std::lock_guard<std::mutex> 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<double>(kMaxRingPoints))
                     ? kMaxRingPoints
                     : static_cast<size_t>(want);
    const size_t share = kTotalPointBudget / std::max<size_t>(1u, ringCount);
    cap = std::min(cap, share);
    cap = std::min(cap, kMaxRingPoints);
    return std::max(cap, kMinRingPoints);
}

void SignalStore::maintain() {
    std::lock_guard<std::mutex> lk(mu_);
    const size_t ringCount = entries_.size();
    std::vector<double> t, v;
    for (std::map<std::string, Entry>::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<double>(e.buf.capacity);
        if (std::fabs(static_cast<double>(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<std::mutex> lk(mu_);
    ++captureSeq_;
    capture_     = std::move(c);
    capture_.seq = captureSeq_;
}

uint64_t SignalStore::captureSeq() const {
    std::lock_guard<std::mutex> lk(mu_);
    return captureSeq_;
}

bool SignalStore::readCapture(Capture& out) const {
    std::lock_guard<std::mutex> 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:

set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
    Trigger.cpp
    SignalStore.cpp
)

The tests now use threads, so link them:

find_package(Threads REQUIRED)
target_link_libraries(udpscope_core PUBLIC Threads::Threads)
  • Step 6: Run the tests and verify they pass
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
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:
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<SignalMeta>& 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:

#include "Receiver.h"
#include <gtest/gtest.h>
#include <cmath>
#include <string>
#include <vector>

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<std::vector<double> > vals;
    std::vector<const double*>        ptrs;
    std::vector<uint32_t>             counts;

    void add(const std::vector<double>& 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<uint32_t>(vals[i].size()));
        }
        FrameView f;
        f.counter    = counter;
        f.hrt        = hrt;
        f.recvTime   = recvTime;
        f.numSamples = numSamples;
        f.numSignals = static_cast<uint32_t>(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<double>(i)});
        FrameView f = s.view(static_cast<uint32_t>(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<double> ts(N), vs(N);
    for (uint32_t e = 0; e < N; ++e) {
        ts[e] = 1.0e9 + static_cast<double>(e) * 1.0e5;  /* 1 s + e * 0.1 ms, in ns */
        vs[e] = static_cast<double>(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<double>(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<double>(i)});
        FrameView f = s.view(static_cast<uint32_t>(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<uint32_t>(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<uint32_t>(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
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:

/**
 * @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 <cstdint>
#include <mutex>
#include <string>
#include <vector>

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<SignalMeta>& 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<double> 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:

#include "Receiver.h"

namespace udpscope {

void Receiver::handleConfig(const std::vector<SignalMeta>& 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<std::mutex> 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<SignalMeta>& 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<SignalMeta>& 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<std::mutex> 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<std::mutex> 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<std::mutex> lk(ctlMu_);
    return cfg_;
}

void Receiver::arm() {
    std::lock_guard<std::mutex> lk(ctlMu_);
    cmd_ = Cmd::Arm;
}

void Receiver::disarm() {
    std::lock_guard<std::mutex> lk(ctlMu_);
    cmd_ = Cmd::Disarm;
}

void Receiver::rearm() {
    std::lock_guard<std::mutex> lk(ctlMu_);
    cmd_ = Cmd::Rearm;
}

TrigStatus Receiver::trigStatus() const {
    std::lock_guard<std::mutex> 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
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
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
cd Client/udpscope && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 17.

  • Step 8: Commit
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:
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:

#include "Receiver.h"
#include <gtest/gtest.h>
#include <string>
#include <vector>

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<std::vector<double> > vals;
    std::vector<const double*>        ptrs;
    std::vector<uint32_t>             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<uint32_t>(vals[i].size()));
        }
        FrameView f;
        f.recvTime   = recvTime;
        f.numSamples = 4u;
        f.numSignals = static_cast<uint32_t>(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
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:

#include <atomic>
#include <map>
#include <thread>

struct udps_client;   /* opaque, see Common/Client/c/udps_client.h */
/** 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:

    ~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:

    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<SignalMeta> withOverrides(const std::vector<SignalMeta>& in) const;

    udps_client_t*    client_ = nullptr;
    std::thread       thread_;
    std::atomic<bool> running_{false};
    ReceiverOptions   opt_;
    std::string       hostStr_, groupStr_, ifaceStr_;  /**< own the C strings */

    std::vector<SignalMeta> wireMetas_;   /**< last CONFIG, before overrides */
    std::vector<const double*> valPtrs_;  /**< per-frame scratch */
    std::vector<uint32_t>      valCounts_;

    mutable std::mutex linkMu_;
    LinkStatus         link_;

ctlMu_ also gains:

    std::map<std::string, bool> overrides_;
    bool overridesDirty_ = false;

The #include <mutex> and <vector> 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:

std::vector<SignalMeta> Receiver::withOverrides(
    const std::vector<SignalMeta>& in) const {
    std::lock_guard<std::mutex> lk(ctlMu_);
    std::vector<SignalMeta> out = in;
    for (size_t i = 0u; i < out.size(); i++) {
        std::map<std::string, bool>::const_iterator it = overrides_.find(out[i].name);
        out[i].profileOverride = (it != overrides_.end()) && it->second;
    }
    return out;
}

void Receiver::handleConfig(const std::vector<SignalMeta>& metas) {
    wireMetas_ = metas;
    const std::vector<SignalMeta> 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<std::mutex> 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<std::mutex> lk(ctlMu_);
    overrides_[signalName] = isProfile;
    overridesDirty_        = true;
}

bool Receiver::profileOverride(const std::string& signalName) const {
    std::lock_guard<std::mutex> lk(ctlMu_);
    std::map<std::string, bool>::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 <cstring> at the top):

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<Receiver*>(user);
    std::vector<SignalMeta> 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<std::mutex> lk(self->linkMu_);
        self->link_.haveConfig = true;
    }
}

void Receiver::onDataC(const udps_frame_t* frame, void* user) {
    Receiver* self = static_cast<Receiver*>(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<std::mutex> 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<Receiver*>(user);
    std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lk(linkMu_);
    return link_;
}
  • Step 6: Write the probe tool

Create Client/udpscope/tools/rxprobe.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 <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>

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<uint16_t>(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<uint16_t>(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<udpscope::SignalMeta> 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
add_executable(udpscope_rxprobe tools/rxprobe.cpp)
target_link_libraries(udpscope_rxprobe PRIVATE udpscope_core)
  • Step 8: Run the tests and verify they pass
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:

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:

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:

cd ../../Common/Client/c && make && \
    ./build/udps_dump --host 127.0.0.1 --port 44501 --frames 20
  • Step 10: Commit
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:
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:

#include "Cli.h"
#include <gtest/gtest.h>
#include <cstdlib>
#include <string>
#include <vector>

using namespace udpscope;

namespace {

// ParseCli takes char**, as main() does.
CliResult parse(const std::vector<std::string>& args, CliOptions& out,
                std::string& err) {
    std::vector<char*> argv;
    argv.push_back(const_cast<char*>("udpscope"));
    for (size_t i = 0; i < args.size(); ++i) {
        argv.push_back(const_cast<char*>(args[i].c_str()));
    }
    return ParseCli(static_cast<int>(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
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:

/**
 * @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 <cstddef>
#include <string>

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:

#include "Cli.h"

#include <cstdlib>
#include <cstring>
#include <string>

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<uint16_t>(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<uint16_t>(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<size_t>(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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Cli*'

Expected: PASS, 8 tests. Add Cli.cpp to CORE_SOURCES first:

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:

/**
 * @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 <string>
#include <vector>

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<SignalMeta> 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:

#include "App.h"

#include "imgui.h"
#include "implot.h"

#include <cinttypes>
#include <cstdio>

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<unsigned>(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:

#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<int>(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:

/**
 * @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 <SDL.h>
#include <SDL_opengl.h>

#include <cstdio>
#include <string>
#include <sys/stat.h>
#include <unistd.h>

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:

# ── 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
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
./build/udpscope_tests

Expected: PASS, all tests from Tasks 19.

# 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.

./Client/udpscope/build/UDPScope --help          # prints usage, exits 0
./Client/udpscope/build/UDPScope -host 1.2.3.4   # rejects, exits 2
  • Step 10: Commit
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:
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:

#include "PlotData.h"
#include <gtest/gtest.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>

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<double> t(10000), v(10000);
    for (size_t i = 0; i < t.size(); ++i) {
        t[i] = static_cast<double>(i) * 1e-4;
        v[i] = static_cast<double>(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<double>(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
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:

/**
 * @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 <cstddef>
#include <string>

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:

#include "PlotData.h"

#include "Decimate.h"

#include <algorithm>

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
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:

/**
 * @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:

#include "PaneView.h"

#include "implot.h"

#include <cstdio>
#include <cstring>

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<int>(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<long>(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<const char*>(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:

    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:

        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:

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
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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 110.

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
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:
// 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:

#include "PaneTree.h"
#include <gtest/gtest.h>

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
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:

/**
 * 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
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
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:

    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:

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<PaneTree::Placed>   placed;
    std::vector<PaneTree::Splitter> 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<int>(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<PaneNode*>(node);
        cmd.ratio  = node->ratio + delta / parentExtent;
    }

    ApplyPaneCommand(tree, cmd);
}

drawTree() needs #include <cstdio> for snprintf and <vector>; 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():

    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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 111.

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
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:
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:

#include "Axes.h"
#include <gtest/gtest.h>
#include <cmath>

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<double>(i) / static_cast<double>(n - 1);
        s.t.push_back(static_cast<double>(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
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:

/**
 * @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:

#include "Axes.h"

#include <algorithm>
#include <cmath>

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
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:

        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<int>(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:

            const char* modes[] = {"auto", "range", "manual"};
            int mode = static_cast<int>(a.vs.mode);
            if (ImGui::Combo("v-scale", &mode, modes, 3)) {
                a.vs.mode = static_cast<VMode>(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:

    std::vector<double> divScratch_;

and to PaneContext:

    const std::vector<SignalMeta>* 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:

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:

    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():

    /* 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:

    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:

    XAxisController xaxis_;

In App.cpp, replace the range computation in drawPlotArea() with:

    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(...):

    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:

    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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 112.

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
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:

#include "CaptureLatch.h"

#include <gtest/gtest.h>
#include <string>

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
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:

/**
 * @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 <cstdint>
#include <string>

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:

#include "CaptureLatch.h"

#include <algorithm>
#include <cmath>
#include <cstdio>

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<int>(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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='CaptureLatch*:TrigBadge*'

Expected: PASS, 12 tests.

  • Step 6: Commit the latch
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:

#include "CaptureLatch.h"

Add to the private method list, next to drawMenuBar():

    void drawTriggerBar();
    /** Pushes the edited config into the receiver and re-ranges the panes. */
    void applyTrigConfig();

and to the private data, after PaneTree tree_;:

    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:

#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<int>(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<int>(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<int>(trig_.edge);
    ImGui::SetNextItemWidth(90.0f);
    if (ImGui::Combo("##trigedge", &edge, edges, 3)) {
        trig_.edge = static_cast<Edge>(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<float>(trig_.prePercent);
    ImGui::SetNextItemWidth(120.0f);
    if (ImGui::SliderFloat("pre %", &pre, 0.0f, 90.0f, "%.0f")) {
        trig_.prePercent = static_cast<double>(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<unsigned long long>(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:

    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:

    ctx.capture = latch_.showing() ? &latch_.capture() : nullptr;
  • Step 10: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 113.

Against the demo streamer:

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
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:

#include "Measure.h"

#include <gtest/gtest.h>
#include <cmath>

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<double>(i));
        s.v.push_back(static_cast<double>(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
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:

/**
 * @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 <cstddef>

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:

#include "Measure.h"

#include <algorithm>
#include <cmath>

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<double>(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<size_t>(
        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
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
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:

    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:

    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:

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:

    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<size_t>(used),
                                          "  A %.4g  B %.4g  dV %.4g",
                                          va, vb, vb - va);
                } else {
                    used += std::snprintf(line + used,
                                          sizeof(line) - static_cast<size_t>(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<size_t>(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<size_t>(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:

    struct TraceKeep {
        std::string name;
        Color       color;
        Series      raw;
    };
    std::vector<TraceKeep> traces_;

clear it at the top of the trace loop (traces_.clear();) and append inside the loop, right after ComputeVScale(...):

        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_:

    Cursors cursors_;
    bool    showStats_ = false;

In App.cpp, extend the View menu built in Task 12:

        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_;:

    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():

    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:

    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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 114.

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
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<PaneNode> 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:

#include "Settings.h"

#include <gtest/gtest.h>
#include <string>

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<PaneNode>(new PaneNode());
    root->leaf   = false;
    root->orient = Orient::Columns;
    root->ratio  = 0.4;

    auto left = std::unique_ptr<PaneNode>(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<PaneNode>(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<PaneNode>(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
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:

/**
 * @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 <memory>
#include <string>

namespace udpscope {

/** Everything that survives a restart. */
struct Session {
    ReceiverOptions           source;
    TrigConfig                trigger;
    Cursors                   cursors;
    std::unique_ptr<PaneNode> 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:

#include "Settings.h"

#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
#include <sys/types.h>
#include <vector>

namespace udpscope {

namespace {

std::vector<std::string> tokens(const std::string& line) {
    std::vector<std::string> 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<unsigned>(std::strtoul(s.c_str(), NULL, 10));
}

std::string hexOf(const Color& c) {
    const int r = static_cast<int>(c.r * 255.0f + 0.5f);
    const int g = static_cast<int>(c.g * 255.0f + 0.5f);
    const int b = static_cast<int>(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<unsigned char>(s[i])) == 0) {
            return false;
        }
    }
    const unsigned long v = std::strtoul(s.c_str() + 1, NULL, 16);
    c.r = static_cast<float>((v >> 16) & 0xffu) / 255.0f;
    c.g = static_cast<float>((v >> 8) & 0xffu) / 255.0f;
    c.b = static_cast<float>(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<unsigned char>(s[i])) != 0) {
            return true;
        }
    }
    return s.empty();
}

void writeNode(const PaneNode& n, int depth, std::string& out) {
    const std::string pad(static_cast<size_t>(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<double>(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<PaneNode> readNode(const std::vector<std::vector<std::string> >& L,
                                   size_t& i, std::string& err) {
    if (i >= L.size()) {
        err = "tree ends early";
        return std::unique_ptr<PaneNode>();
    }
    const std::vector<std::string>& t = L[i];
    std::unique_ptr<PaneNode> n(new PaneNode());

    if (t[0] == "split") {
        if (t.size() != 3u) {
            err = "split needs an orientation and a ratio";
            return std::unique_ptr<PaneNode>();
        }
        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<PaneNode>();
        }
        n->ratio = toD(t[2]);
        if (!(n->ratio > 0.0) || !(n->ratio < 1.0)) {
            err = "split ratio out of range";
            return std::unique_ptr<PaneNode>();
        }
        i++;
        n->a = readNode(L, i, err);
        if (!n->a) { return std::unique_ptr<PaneNode>(); }
        n->b = readNode(L, i, err);
        if (!n->b) { return std::unique_ptr<PaneNode>(); }
        return n;
    }

    if (t[0] != "leaf") {
        err = "expected 'split' or 'leaf', got '" + t[0] + "'";
        return std::unique_ptr<PaneNode>();
    }
    i++;
    while (i < L.size() && L[i][0] == "sig") {
        const std::vector<std::string>& s = L[i];
        if (s.size() < 2u) {
            err = "sig needs a name";
            return std::unique_ptr<PaneNode>();
        }
        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<PaneNode>();
            }
            if (key == "color") {
                if (!colorOf(val, a.color)) {
                    err = "bad colour '" + val + "'";
                    return std::unique_ptr<PaneNode>();
                }
            } else if (key == "width") {
                a.lineWidth = static_cast<float>(toD(val));
            } else if (key == "vs") {
                if (!vmodeOf(val, a.vs.mode)) {
                    err = "bad vertical mode '" + val + "'";
                    return std::unique_ptr<PaneNode>();
                }
            } 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<PaneNode>();
            }
        }
        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<unsigned>(s.source.port),
                  s.source.multicastGroup.c_str(), s.source.interfaceAddr.c_str(),
                  static_cast<unsigned>(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<std::vector<std::string> > lines;
    {
        std::istringstream is(text);
        std::string line;
        while (std::getline(is, line)) {
            std::vector<std::string> 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<std::string>& 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<uint16_t>(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<uint16_t>(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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Settings*:MergeCli*'

Expected: PASS, 18 tests.

  • Step 6: Commit the session core
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_:

    std::string configPath_;
    bool        dirty_ = false;   /**< something worth saving has changed */

and declare:

    void loadSession();
    void saveSession();
    Session currentSession() const;

Replace the body of App::App() in App.cpp with:

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<unsigned>(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:

/** Deep-copies a subtree; returns null for a null input. */
std::unique_ptr<PaneNode> ClonePane(const PaneNode* n);

and to PaneTree.cpp:

std::unique_ptr<PaneNode> ClonePane(const PaneNode* n) {
    if (n == NULL) {
        return std::unique_ptr<PaneNode>();
    }
    std::unique_ptr<PaneNode> 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:

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<PaneNode> 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:

    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():

App::~App() {
    saveSession();   /* spec §9: saved on clean exit */
    rx_.stop();
}
  • Step 9: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 115.

Against the demo streamer:

./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
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<CsvTrace>&, double t0), bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>&, double t0, std::string& err), std::vector<std::string> 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:

#include "Export.h"

#include <gtest/gtest.h>
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>

using namespace udpscope;

namespace {

CsvTrace trace(const std::string& name, const std::vector<double>& t,
               const std::vector<double>& v) {
    CsvTrace c;
    c.name   = name;
    c.data.t = t;
    c.data.v = v;
    return c;
}

std::vector<std::string> linesOf(const std::string& s) {
    std::vector<std::string> 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<CsvTrace> tr;
    const std::vector<std::string> 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<CsvTrace> 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<std::string> 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<CsvTrace> tr;
    tr.push_back(trace("S", {1756291200.123456}, {1.0}));
    const std::vector<std::string> 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<CsvTrace> 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<CsvTrace> 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<CsvTrace> 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<CsvTrace> 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<std::string> 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<PaneNode>(new PaneNode());
    root.b    = std::unique_ptr<PaneNode>(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<std::string> 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
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:

/**
 * @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 <string>
#include <vector>

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<CsvTrace>& traces, double t0);

bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>& traces,
                   double t0, std::string& err);

/** Every distinct signal assigned anywhere under @a n, first use first. */
std::vector<std::string> SignalsInPane(const PaneNode* n);

} /* namespace udpscope */

#endif /* UDPSCOPE_EXPORT_H */
  • Step 4: Write Export.cpp

Create Client/udpscope/Export.cpp:

#include "Export.h"

#include <algorithm>
#include <cstdio>
#include <fstream>

namespace udpscope {

namespace {

void collect(const PaneNode* n, std::vector<std::string>& 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<std::string> SignalsInPane(const PaneNode* n) {
    std::vector<std::string> out;
    collect(n, out);
    return out;
}

std::string BuildCsv(const std::vector<CsvTrace>& 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<CsvTrace>& 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
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
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:

    /**
     * @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<CsvTrace>& 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:

bool App::gatherExport(const PaneNode* node, std::vector<CsvTrace>& out,
                       double& t0, std::string& err) const {
    const std::vector<std::string> 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<CsvTrace> 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<long long>(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 <ctime> for the file name stamp.

Add to the File menu, above Save Layout:

        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:

    exportPane_ = ctx.hoveredLeaf;

with PaneNode* hoveredLeaf = nullptr; added to PaneContext, set in PaneView::drawLeaf() right after ImPlot::BeginPlot() succeeds:

    if (ImPlot::IsPlotHovered()) {
        ctx.hoveredLeaf = &leaf;
    }

and cleared by PaneView::drawTree() before it walks the layout:

    ctx.hoveredLeaf = nullptr;
  • Step 8: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 116.

Against the demo streamer:

  1. Two panes with one signal each, live. File → Export CSV (all panes) writes udpscope-<unix>.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
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 68 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:

TEST(FetchProfileTrace, PlotsTheVectorAgainstElementIndex) {
    SignalStore store;
    std::vector<SignalMeta> 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<SignalMeta> 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<SignalMeta> 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
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:

/**
 * @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:

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
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:

    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<int>(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:

            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:

        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
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 117.

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
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 117. No new code.

  • Step 1: Write Docs/UDPScope.md

Create Docs/UDPScope.md:

# 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 117 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:

[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 version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
  <rect x="2" y="2" width="60" height="60" rx="8" fill="#1e1e2e"/>
  <g stroke="#45475a" stroke-width="1">
    <path d="M2 17h60M2 32h60M2 47h60M17 2v60M32 2v60M47 2v60"/>
  </g>
  <path d="M4 32 C 12 4, 20 60, 32 32 S 52 4, 60 32"
        fill="none" stroke="#a6e3a1" stroke-width="3" stroke-linecap="round"/>
  <circle cx="32" cy="32" r="3" fill="#fab387"/>
</svg>

Extend the install block in Client/udpscope/CMakeLists.txt:

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:

set(UDPSCOPE_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/resources)

then replace ${RESOURCE_DIR} with ${UDPSCOPE_RESOURCE_DIR} in the two install lines above.

Verify:

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:

| **Direct scope**      | `Client/udpscope`        | ImGui bench oscilloscope attached straight to one UDPStreamer                                                  |

add to the repository structure block under Client/:

├── Client/udpscope/                Direct-UDPS ImGui oscilloscope (SDL2 + ImPlot)

add a component section after StreamHub Application:

### 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:

# 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:

**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:

Client/udpscope/build/
  • Step 3: Check the documentation against the build
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
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"