Files
MARTe-Integrated-Components/docs/superpowers/plans/2026-08-27-udpscope.md
T
Martino FerrariandClaude Opus 4.6 c1029a25df fix(udpscope): make the time-order test actually exercise the swap, guard the font copy
The ramp data in EmitsPointsInTimeOrder never produced a bucket whose maximum
preceded its minimum, so an implementation ordering the emitted pair by value
instead of by time would have passed. Replaced with an explicit two-bucket case
whose second bucket reverses the order.

file(COPY) is a hard configure error on a missing source, so a checkout without
the sibling StreamHub resources failed to configure despite the ASCII-icon
fallback the block above had just selected.

Plan amended to match on both points.

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

10351 lines
320 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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`:
```cpp
/**
* @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`:
```cpp
/**
* @file Decimate.h
* @brief Min/max envelope decimation for screen rendering.
*/
#pragma once
#include "Types.h"
namespace udpscope {
/**
* @brief Reduce n points to at most maxPoints by emitting each bucket's
* minimum and maximum, in time order.
*
* LTTB is deliberately not used. It selects representative points and will
* silently drop a one-sample glitch; on a scope that glitch is usually the
* thing being looked for. The emitted pair stays in time order rather than
* value order because callers binary-search the result by time.
*
* Input shorter than maxPoints is copied through unchanged.
*/
void MinMaxDecimate(const double* t, const double* v, size_t n,
size_t maxPoints, Series& out);
} /* namespace udpscope */
```
- [ ] **Step 4: Write the minimal implementation**
Create `Client/udpscope/Decimate.cpp`:
```cpp
#include "Decimate.h"
#include <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
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**
```bash
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j
```
The first configure downloads ImGui, ImPlot and GoogleTest, so it needs network access and takes a minute.
Then:
```bash
./build/udpscope_tests
```
Expected on a first run *before* `Decimate.cpp` is written: a link error for `MinMaxDecimate`. Since Steps 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**
```bash
git add Client/udpscope/CMakeLists.txt Client/udpscope/.gitignore \
Client/udpscope/Types.h Client/udpscope/Decimate.h \
Client/udpscope/Decimate.cpp Client/udpscope/tests/DecimateTest.cpp
git commit -m "feat(udpscope): build scaffold and min/max envelope decimation"
```
---
### Task 2: Pane tree
The BSP layout that the whole UI hangs off. Framework-free, so all the fiddly geometry is settled before a single ImGui call exists.
**Files:**
- Create: `Client/udpscope/PaneTree.h`
- Create: `Client/udpscope/PaneTree.cpp`
- Create: `Client/udpscope/tests/PaneTreeTest.cpp`
- Modify: `Client/udpscope/CMakeLists.txt` (add `PaneTree.cpp` to `CORE_SOURCES`)
**Interfaces:**
- Consumes: `udpscope::Rect`, `udpscope::Color` from `Types.h` (Task 1).
- Produces:
- `enum class Orient { Columns, Rows }`
- `enum class VMode { Auto, Range, Manual }`
- `struct VScale { VMode mode; double div; double offset; }`
- `struct Assignment { std::string signalName; Color color; float lineWidth; VScale vs; }`
- `struct PaneNode { bool leaf; std::vector<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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j
```
Expected: FAIL — `PaneTree.h: No such file or directory`.
- [ ] **Step 3: Write the header**
Create `Client/udpscope/PaneTree.h`:
```cpp
/**
* @file PaneTree.h
* @brief Binary-space-partition layout of the plot area.
*
* Framework-free: no ImGui, no UDPS. The geometry and the hit-testing are the
* fiddly part of the pane UI and are unit-tested without a window.
*/
#pragma once
#include "Types.h"
#include <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`:
```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:
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
)
```
- [ ] **Step 6: Run the tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneTree*'
```
Expected: PASS, 12 tests.
- [ ] **Step 7: Commit**
```bash
git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
Client/udpscope/tests/PaneTreeTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): BSP pane tree with split, close and hit-testing"
```
---
### Task 3: Time base
Maps a producer clock onto wall clock. Two pieces: a one-off offset with drift-triggered recalibration, and a least-squares fit that recovers the `hrt` tick rate without assuming the client runs on the producer's host.
**Files:**
- Create: `Client/udpscope/TimeBase.h`
- Create: `Client/udpscope/TimeBase.cpp`
- Create: `Client/udpscope/tests/TimeBaseTest.cpp`
- Modify: `Client/udpscope/CMakeLists.txt` (add `TimeBase.cpp`)
**Interfaces:**
- Consumes: nothing.
- Produces:
- `class ClockOffset``double map(double producerSec, double wallSec)`, `bool valid() const`, `void reset()`, `static constexpr double kRecalibThresholdS = 0.5`
- `class HrtRateFit``void add(uint64_t hrt, double wallSec)`, `bool ready() const`, `double ticksPerSecond() const`, `double toSeconds(uint64_t hrt) const`, `void reset()`, `static constexpr size_t kMinSamples = 32`
- `double TimeSignalScale(uint8_t typeCode)`
- [ ] **Step 1: Write the failing test**
Create `Client/udpscope/tests/TimeBaseTest.cpp`:
```cpp
#include "TimeBase.h"
#include <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
EXPECT_DOUBLE_EQ(off.map(11.0, 1001.02), 1001.0);
EXPECT_DOUBLE_EQ(off.map(12.0, 1000.97), 1002.0);
}
TEST(ClockOffset, RecalibratesWhenDriftExceedsTheThreshold) {
ClockOffset off;
off.map(10.0, 1000.0); // offset = 990
/* Producer clock jumped (restart, re-phase): 5 s of error is not jitter. */
const double mapped = off.map(11.0, 1006.0);
EXPECT_DOUBLE_EQ(mapped, 1006.0);
}
TEST(ClockOffset, ResetForgetsTheCalibration) {
ClockOffset off;
off.map(10.0, 1000.0);
off.reset();
EXPECT_FALSE(off.valid());
EXPECT_DOUBLE_EQ(off.map(50.0, 2000.0), 2000.0);
}
// The tick rate of the producer's high-resolution timer is not carried by the
// protocol, and StreamHub's trick of using the local MARTe timer frequency only
// works on the producer's own host. Recover it from the data instead.
TEST(HrtRateFit, RecoversAKnownTickRate) {
HrtRateFit fit;
const double ticksPerSec = 2.5e9;
EXPECT_FALSE(fit.ready());
for (int i = 0; i < 64; i++) {
const double wall = 1000.0 + i * 0.01;
fit.add(static_cast<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**
```bash
cd Client/udpscope && cmake --build build -j
```
Expected: FAIL — `TimeBase.h: No such file or directory`.
- [ ] **Step 3: Write the header**
Create `Client/udpscope/TimeBase.h`:
```cpp
/**
* @file TimeBase.h
* @brief Producer-clock to wall-clock reconstruction.
*
* Framework-free. A UDPS stream's accurate timestamps come from a producer
* clock — either a declared time signal or the packet's embedded hrt — and both
* need mapping onto the client's wall clock before they can be plotted.
*/
#pragma once
#include <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`:
```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**
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
)
```
- [ ] **Step 6: Run the tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ClockOffset*:HrtRateFit*:TimeSignalScale*'
```
Expected: PASS, 9 tests.
- [ ] **Step 7: Commit**
```bash
git add Client/udpscope/TimeBase.h Client/udpscope/TimeBase.cpp \
Client/udpscope/tests/TimeBaseTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): producer-clock calibration and hrt tick-rate fit"
```
---
### Task 4: Frame decoder — per-element timestamps
Spec §5, the part the C library deliberately does not do for you. `udps_frame_element_time()` is an arrival-anchored *estimate*; relying on it renders bursty delivery as a sawtooth. This reproduces `UDPSourceSession.cpp`'s rules on top of the C API.
**Files:**
- Create: `Client/udpscope/FrameDecoder.h`
- Create: `Client/udpscope/FrameDecoder.cpp`
- Create: `Client/udpscope/tests/FrameDecoderTest.cpp`
- Modify: `Client/udpscope/Types.h` (add `SignalMeta`, `FrameView`, protocol constants)
- Modify: `Client/udpscope/CMakeLists.txt` (add `FrameDecoder.cpp`)
**Interfaces:**
- Consumes: `ClockOffset`, `HrtRateFit`, `TimeSignalScale` (Task 3).
- Produces:
- `udpscope::SignalMeta` with `numElements()`, `hasTimeSignal(uint32_t)`, `isVectorProfile()`
- `udpscope::FrameView` — a non-owning mirror of `udps_frame_t`
- `constexpr uint8_t kTimePacket/kTimeFullArray/kTimeFirstSample/kTimeLastSample`
- `constexpr uint32_t kNoTimeSignal`
- `class FrameDecoder` with `setSignals()`, `beginFrame()`, `timestamps()`, `reset()`
- [ ] **Step 1: Extend the shared types**
Append to `Client/udpscope/Types.h`, inside `namespace udpscope`:
```cpp
/* Protocol constants, spelled out rather than included, so the framework-free
* modules stay independent of udps_client.h. They mirror Common/UDP/UDPSProtocol.h. */
constexpr uint8_t kTimePacket = 0;
constexpr uint8_t kTimeFullArray = 1;
constexpr uint8_t kTimeFirstSample = 2;
constexpr uint8_t kTimeLastSample = 3;
constexpr uint32_t kNoTimeSignal = 0xFFFFFFFFu;
/** Framework-free mirror of udps_signal_t, plus UI state. */
struct SignalMeta {
std::string name;
uint8_t typeCode = 255;
uint8_t quantType = 0;
uint32_t numRows = 1;
uint32_t numCols = 1;
double rangeMin = 0.0;
double rangeMax = 0.0;
uint8_t timeMode = kTimePacket;
double samplingRate = 0.0;
uint32_t timeSignalIdx = kNoTimeSignal;
std::string unit;
/** User override: treat an ambiguous PACKET array as a profile, not a burst. */
bool profileOverride = false;
uint32_t numElements() const {
const uint64_t n = static_cast<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`:
```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)";
}
}
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**
```bash
cd Client/udpscope && cmake --build build -j
```
Expected: FAIL — `FrameDecoder.h: No such file or directory`.
- [ ] **Step 4: Write the header**
Create `Client/udpscope/FrameDecoder.h`:
```cpp
/**
* @file FrameDecoder.h
* @brief Per-element timestamp reconstruction for UDPS frames.
*
* The C client's udps_frame_element_time() is explicitly an arrival-anchored
* estimate. It is not sufficient: the kernel frequently delivers several queued
* datagrams in one burst, so two packets are processed microseconds apart even
* though each represents ~10 ms of signal, and arrival-time interpolation then
* crams a packet's samples into that tiny gap — the trace renders as a sawtooth.
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
* solves it; these are the same rules, computed from udps_frame_t's own fields
* so the scope and StreamHub agree on the same stream.
*/
#pragma once
#include "TimeBase.h"
#include "Types.h"
#include <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`:
```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, based on the producer's own hrt. */
if (d.numElements() == 1u && nElems > 1u) {
if (!hrtFit_.ready()) {
return packetBurst(idx, nElems, wallNow, tsOut);
}
const double hrtSec = hrtFit_.toSeconds(f.hrt);
const double base = st.offset.map(hrtSec, wallNow);
double dt;
if (d.samplingRate > 0.0) {
dt = 1.0 / d.samplingRate;
} else if (st.lastAccValid && st.prevAccCount > 0u &&
hrtSec > st.lastAccHrtSec) {
/* The flushes carry contiguous RT cycles, so the gap divided by the
* previous packet's sample count is exactly one cycle period. */
dt = (hrtSec - st.lastAccHrtSec) /
static_cast<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**
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
)
```
- [ ] **Step 7: Run the tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*'
```
Expected: PASS, 9 tests.
If `AccumulatedScalarSurvivesBurstyDelivery` fails on the first few samples, check that `beginFrame()` is being called before `timestamps()` — the hrt fit needs 32 packets before rule 3 engages, and the packets before that legitimately go through rule 4.
- [ ] **Step 8: Commit**
```bash
git add Client/udpscope/FrameDecoder.h Client/udpscope/FrameDecoder.cpp \
Client/udpscope/Types.h Client/udpscope/tests/FrameDecoderTest.cpp \
Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): per-element timestamp reconstruction from UDPS frames"
```
---
### Task 5: Trigger FSM
The trigger runs in the receiver thread, fed the same reconstructed
`(t, v)` arrays that go into the ring. It never stores samples itself — it
only decides *when* a capture window opened and closed. The GUI later reads
`[tTrig - preSec, tTrig + postSec]` out of the ring.
**Files:**
- Create: `Client/udpscope/Trigger.h`
- Create: `Client/udpscope/Trigger.cpp`
- Modify: `Client/udpscope/CMakeLists.txt` (add `Trigger.cpp` to `CORE_SOURCES`)
- Test: `Client/udpscope/tests/TriggerTest.cpp`
**Interfaces:**
- Consumes: nothing from earlier tasks (pure logic over `double` arrays).
- Produces:
```cpp
enum class Edge { Rising, Falling, Both };
enum class TrigMode { Normal, Single };
enum class TrigState{ Idle, Armed, Collecting, Held };
struct TrigConfig {
std::string signalName;
Edge edge = Edge::Rising;
double threshold = 0.0;
double hysteresis = 0.0;
double windowSec = 0.1;
double prePercent = 20.0;
TrigMode mode = TrigMode::Normal;
double preSec() const;
double postSec() const;
};
class Trigger {
public:
void setConfig(const TrigConfig& c);
const TrigConfig& config() const;
void arm();
void disarm();
void rearm();
void feed(const double* t, const double* v, size_t n, double ringOldestTime);
TrigState state() const;
double fillFraction() const;
double trigTime() const;
bool captureReady() const;
void captureTaken();
static constexpr double kHarvestMarginSec = 0.05;
};
```
**Why the harvest margin exists:** the GUI reads the capture out of the ring
one repaint tick after the receiver declares it complete. Without a margin
the last samples of the window can be overwritten before they are read. This
is exactly the bug that was found and fixed in the Go hub
(`Common/Client/go/wshub/ringbuf.go`, `captureLagSec`) — do not remove it.
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/TriggerTest.cpp`:
```cpp
#include "Trigger.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `Trigger.h: No such file or directory`.
- [ ] **Step 3: Write the header**
Create `Client/udpscope/Trigger.h`:
```cpp
/**
* @file Trigger.h
* @brief Client-side edge trigger FSM for UDPScope.
*
* The trigger observes one signal's reconstructed samples in the receiver
* thread and decides when a capture window opened and closed. It stores no
* samples: the GUI reads [trigTime() - preSec, trigTime() + postSec] out of
* the ring once captureReady() goes true.
*/
#ifndef UDPSCOPE_TRIGGER_H
#define UDPSCOPE_TRIGGER_H
#include <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`:
```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`:
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
)
```
- [ ] **Step 6: Run the tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Trigger*'
```
Expected: PASS, 15 tests.
- [ ] **Step 7: Run the whole suite to check nothing regressed**
```bash
cd Client/udpscope && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 15.
- [ ] **Step 8: Commit**
```bash
git add Client/udpscope/Trigger.h Client/udpscope/Trigger.cpp \
Client/udpscope/tests/TriggerTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): edge trigger FSM with fill gate and harvest margin"
```
---
### Task 6: SignalStore — the one lock between the two threads
`SignalStore` owns every ring, the latest vector profiles, and the most
recent completed capture. The receiver thread writes; the GUI thread reads;
one `std::mutex` guards all of it. Nothing else in the program is shared
between the threads.
**Files:**
- Create: `Client/udpscope/SignalStore.h`
- Create: `Client/udpscope/SignalStore.cpp`
- Modify: `Client/udpscope/CMakeLists.txt` (add `SignalStore.cpp` to `CORE_SOURCES`)
- Test: `Client/udpscope/tests/SignalStoreTest.cpp`
**Interfaces:**
- Consumes: `SignalMeta`, `Series` from `Types.h` (Task 1/4);
`StreamHubClient::SignalBuffer` from `../streamhub/SignalBuffer.h`
(already on the include path from Task 1).
- Produces:
```cpp
struct Profile { double time = 0.0; std::vector<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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `SignalStore.h: No such file or directory`.
- [ ] **Step 3: Write the header**
Create `Client/udpscope/SignalStore.h`:
```cpp
/**
* @file SignalStore.h
* @brief The single mutex-guarded handoff between the receiver and GUI threads.
*
* Owns one ring per scalar signal, the latest snapshot per vector-profile
* signal, and the most recent completed capture. Every accessor takes the
* same lock; the reused StreamHubClient::SignalBuffer has none of its own
* despite what its comment claims.
*/
#ifndef UDPSCOPE_SIGNALSTORE_H
#define UDPSCOPE_SIGNALSTORE_H
#include "SignalBuffer.h"
#include "Types.h"
#include <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`:
```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`:
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
)
```
The tests now use threads, so link them:
```cmake
find_package(Threads REQUIRED)
target_link_libraries(udpscope_core PUBLIC Threads::Threads)
```
- [ ] **Step 6: Run the tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='SignalStore*'
```
Expected: PASS, 15 tests.
If `MaintainSharesTheGlobalBudgetBetweenSignals` reports an unused budget,
check that `targetCapacity()` divides by the number of *rings*, not the
number of assigned signals.
- [ ] **Step 7: Commit**
```bash
git add Client/udpscope/SignalStore.h Client/udpscope/SignalStore.cpp \
Client/udpscope/tests/SignalStoreTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): mutex-guarded signal store with window-driven ring sizing"
```
---
### Task 7: Receiver — frame path (no thread, no socket)
The receiver's decision-making is factored out of the C callback into plain
methods so it can be driven from tests with synthetic frames. This task
writes those methods and their tests; Task 8 bolts the thread and the C
client onto the same class.
**Files:**
- Create: `Client/udpscope/Receiver.h`
- Create: `Client/udpscope/Receiver.cpp`
- Modify: `Client/udpscope/CMakeLists.txt` (add `Receiver.cpp` to `CORE_SOURCES`)
- Test: `Client/udpscope/tests/ReceiverTest.cpp`
**Interfaces:**
- Consumes: `SignalStore`, `Capture` (Task 6); `Trigger`, `TrigConfig`,
`TrigState`, `TrigMode` (Task 5); `FrameDecoder` (Task 4); `SignalMeta`,
`FrameView`, `Series` (Tasks 1/4).
- Produces:
```cpp
struct TrigStatus {
TrigState state = TrigState::Idle;
double fill = 0.0;
double trigTime = 0.0;
uint64_t captures = 0u;
};
class Receiver {
public:
explicit Receiver(SignalStore& store);
void handleConfig(const std::vector<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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `Receiver.h: No such file or directory`.
- [ ] **Step 3: Write the header**
Create `Client/udpscope/Receiver.h`:
```cpp
/**
* @file Receiver.h
* @brief Frame-path logic for the UDPS receiver.
*
* handleConfig()/handleFrame() run on the receiver thread only. The GUI thread
* uses the trigger control methods, which queue their effect under ctlMu_ and
* are applied at the top of the next frame. ctlMu_ is never held across a
* SignalStore call.
*/
#ifndef UDPSCOPE_RECEIVER_H
#define UDPSCOPE_RECEIVER_H
#include "FrameDecoder.h"
#include "SignalStore.h"
#include "Trigger.h"
#include "Types.h"
#include <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`:
```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**
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
Receiver.cpp
)
```
- [ ] **Step 6: Run the tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Receiver*'
```
Expected: PASS, 9 tests.
If `AReadyCaptureIsPublishedWithEverySignal` reports zero captures, check
that the fill gate is being fed the ring's oldest time and not the frame's
first timestamp — the trigger cannot fire until `preSec` of history exists.
- [ ] **Step 7: Run the whole suite**
```bash
cd Client/udpscope && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 17.
- [ ] **Step 8: Commit**
```bash
git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \
Client/udpscope/tests/ReceiverTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): receiver frame path with trigger control and capture harvest"
```
---
### Task 8: Receiver — thread, C client wiring and profile overrides
Bolt the socket and the thread onto `Receiver`, translate the C structs into
the framework-free ones, and add the per-signal "this is a vector, not a
burst" override the spec requires for ambiguous `PACKET` signals. The task
ships `udpscope_rxprobe`, a console tool that runs the receiver against a
real streamer and prints the link counters — that is how the socket path is
verified, and it stays in the tree as a diagnostic.
**Files:**
- Modify: `Client/udpscope/Receiver.h` (thread, options, link status, overrides)
- Modify: `Client/udpscope/Receiver.cpp` (same)
- Create: `Client/udpscope/tools/rxprobe.cpp`
- Modify: `Client/udpscope/CMakeLists.txt` (add the `udpscope_rxprobe` target)
- Test: `Client/udpscope/tests/ReceiverLinkTest.cpp`
**Interfaces:**
- Consumes: everything from Task 7, plus the C API from
`Common/Client/c/udps_client.h` (`udps_client_config_init`,
`udps_client_create`, `udps_client_set_callbacks`, `udps_client_poll`,
`udps_client_destroy`, `udps_client_is_connected`, `udps_client_stats`,
`udps_client_last_error`).
- Produces:
```cpp
struct ReceiverOptions {
std::string host = "127.0.0.1";
uint16_t port = 44500u;
std::string multicastGroup; // empty = unicast
std::string interfaceAddr;
uint16_t dataPort = 0u;
double silenceTimeoutSec = 2.0;
};
struct LinkStatus {
bool running = false, connected = false, haveConfig = false;
uint64_t packets = 0u, frames = 0u, configUpdates = 0u;
uint64_t counterGaps = 0u, fragmentsDropped = 0u, reconnects = 0u;
double lastFrameWall = 0.0;
std::string lastEvent;
};
// added to Receiver:
bool start(const ReceiverOptions& opt, std::string& err);
void stop();
bool running() const;
LinkStatus link() const;
void setProfileOverride(const std::string& signalName, bool isProfile);
bool profileOverride(const std::string& signalName) const;
```
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/ReceiverLinkTest.cpp`:
```cpp
#include "Receiver.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `no member named 'start' in 'udpscope::Receiver'`.
- [ ] **Step 3: Extend the header**
In `Client/udpscope/Receiver.h`, add above the class:
```cpp
#include <atomic>
#include <map>
#include <thread>
struct udps_client; /* opaque, see Common/Client/c/udps_client.h */
```
```cpp
/** Where to attach. Mirrors the fields of udps_client_config_t we expose. */
struct ReceiverOptions {
std::string host = "127.0.0.1";
uint16_t port = 44500u;
std::string multicastGroup; /**< empty = unicast */
std::string interfaceAddr; /**< local interface IP, not a name */
uint16_t dataPort = 0u; /**< 0 = server-chosen */
double silenceTimeoutSec = 2.0;
};
/** Link health, published once per poll for the status bar. */
struct LinkStatus {
bool running = false;
bool connected = false;
bool haveConfig = false;
uint64_t packets = 0u;
uint64_t frames = 0u;
uint64_t configUpdates = 0u;
uint64_t counterGaps = 0u;
uint64_t fragmentsDropped = 0u;
uint64_t reconnects = 0u;
double lastFrameWall = 0.0;
std::string lastEvent;
};
```
Add to the public section of `Receiver`:
```cpp
~Receiver();
/** Spawns the receiver thread. @return false and fills err on failure. */
bool start(const ReceiverOptions& opt, std::string& err);
/** Joins the thread. Safe to call when not running. */
void stop();
bool running() const { return running_.load(); }
LinkStatus link() const;
/**
* Force a PACKET signal with more than one element to be read as a vector
* profile (index axis) rather than a time burst. Applied on the next frame
* and remembered across reconfigures.
*/
void setProfileOverride(const std::string& signalName, bool isProfile);
bool profileOverride(const std::string& signalName) const;
```
Add to the private section:
```cpp
static void onConfigC(const udps_signal_t* sigs, uint32_t n,
uint8_t publishMode, void* user);
static void onDataC(const udps_frame_t* frame, void* user);
static void onEventC(udps_event_t ev, const char* detail, void* user);
void threadMain();
void updateLink();
std::vector<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:
```cpp
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`:
```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):
```cpp
Receiver::~Receiver() {
stop();
}
void Receiver::onConfigC(const udps_signal_t* sigs, uint32_t n,
uint8_t publishMode, void* user) {
(void) publishMode;
Receiver* self = static_cast<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`:
```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**
```cmake
add_executable(udpscope_rxprobe tools/rxprobe.cpp)
target_link_libraries(udpscope_rxprobe PRIVATE udpscope_core)
```
- [ ] **Step 8: Run the tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ReceiverLink*:Receiver*'
```
Expected: PASS, 15 tests (9 from Task 7, 6 new).
- [ ] **Step 9: Verify the socket path against a real streamer**
In one terminal:
```bash
cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components
source env.sh
"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \
-l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \
-s Running -m StateMachine:START
```
In another:
```bash
cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components/Client/udpscope
./build/udpscope_rxprobe --host 127.0.0.1 --port 44501 --seconds 5
```
Expected: `UP`, `pkts` and `frames` climbing, one line per signal with a
plausible rate and a span that stops growing once the ring is full. `gaps`
and `frag` should stay at 0 on loopback.
Cross-check the same source with the reference dumper, which must report the
same signal names and rates:
```bash
cd ../../Common/Client/c && make && \
./build/udps_dump --host 127.0.0.1 --port 44501 --frames 20
```
- [ ] **Step 10: Commit**
```bash
git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \
Client/udpscope/tools/rxprobe.cpp \
Client/udpscope/tests/ReceiverLinkTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): receiver thread, UDPS client wiring and rxprobe tool"
```
---
### Task 9: Command line, window bootstrap and the app shell
First runnable binary: parses the command line, opens an SDL2/OpenGL window,
starts the receiver, and draws the menu bar, the signal list and the status
bar. The plot area is an empty placeholder that Task 10 fills in.
**Files:**
- Create: `Client/udpscope/Cli.h`
- Create: `Client/udpscope/Cli.cpp`
- Create: `Client/udpscope/App.h`
- Create: `Client/udpscope/App.cpp`
- Create: `Client/udpscope/SignalList.cpp`
- Create: `Client/udpscope/main.cpp`
- Modify: `Client/udpscope/CMakeLists.txt`
- Test: `Client/udpscope/tests/CliTest.cpp`
**Interfaces:**
- Consumes: `ReceiverOptions`, `Receiver`, `LinkStatus` (Task 8);
`SignalStore` (Task 6); `PaneTree` (Task 2).
- Produces:
```cpp
struct CliOptions {
ReceiverOptions source;
std::string configPath; // empty = DefaultConfigPath()
size_t maxPlotPoints = 4000u;
bool setHost = false, setPort = false, setMulticast = false;
bool setIface = false, setDataPort = false, setSilence = false;
bool setConfigPath = false, setMaxPlotPoints = false;
};
enum class CliResult { Ok, Help, Error };
CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err);
std::string CliUsage();
std::string DefaultConfigPath();
class App {
public:
explicit App(const CliOptions& opt);
~App();
void draw(); // one ImGui frame
bool wantsQuit() const;
void requestQuit();
};
```
**Why the `set*` flags exist:** spec §11 requires that an explicit command-line
option beats the settings file, while an omitted one falls back to it. Without
per-field "was it given" flags there is no way to tell `--port 44500` from the
default, and connecting from the command line would silently override a saved
session (or vice versa). Task 15 consumes these flags.
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/CliTest.cpp`:
```cpp
#include "Cli.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `Cli.h: No such file or directory`.
- [ ] **Step 3: Write `Cli.h` and `Cli.cpp`**
Create `Client/udpscope/Cli.h`:
```cpp
/**
* @file Cli.h
* @brief Command-line parsing, mirroring Common/Client/c/example/udps_dump.c.
*
* Every field records whether it was given explicitly, because an explicit
* option must beat the settings file while an omitted one must not.
*/
#ifndef UDPSCOPE_CLI_H
#define UDPSCOPE_CLI_H
#include "Receiver.h"
#include <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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Cli*'
```
Expected: PASS, 8 tests. Add `Cli.cpp` to `CORE_SOURCES` first:
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
Receiver.cpp
Cli.cpp
)
```
- [ ] **Step 5: Write the app shell header**
Create `Client/udpscope/App.h`:
```cpp
/**
* @file App.h
* @brief GUI-thread application state and per-frame drawing.
*
* Owns the store, the receiver and the pane tree. Everything here runs on the
* GUI thread; the only cross-thread access is through SignalStore and the
* Receiver's control methods.
*/
#ifndef UDPSCOPE_APP_H
#define UDPSCOPE_APP_H
#include "Cli.h"
#include "PaneTree.h"
#include "Receiver.h"
#include "SignalStore.h"
#include <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`:
```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`:
```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`:
```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:
```cmake
# ── Application ───────────────────────────────────────────────────────────────
set(APP_SOURCES
main.cpp
App.cpp
SignalList.cpp
)
add_executable(UDPScope ${APP_SOURCES})
target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL)
target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}")
if(HAVE_FONT_AWESOME)
target_include_directories(UDPScope PRIVATE ${FONT_DIR})
target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME)
endif()
target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter)
install(TARGETS UDPScope DESTINATION bin)
install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope)
```
- [ ] **Step 9: Build and run against a real streamer**
```bash
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 19.
```bash
# terminal 1
source env.sh && "${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \
-l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \
-s Running -m StateMachine:START
# terminal 2
./Client/udpscope/build/UDPScope --host 127.0.0.1 --port 44501
```
Expected: a window opens, the badge reads `CONNECTED`, the signal list fills
with the demo signals, hovering one shows a plausible rate and buffered span,
and the status bar counters climb with `gaps 0`. Ctrl+Q closes it.
```bash
./Client/udpscope/build/UDPScope --help # prints usage, exits 0
./Client/udpscope/build/UDPScope -host 1.2.3.4 # rejects, exits 2
```
- [ ] **Step 10: Commit**
```bash
git add Client/udpscope/Cli.h Client/udpscope/Cli.cpp Client/udpscope/App.h \
Client/udpscope/App.cpp Client/udpscope/SignalList.cpp \
Client/udpscope/main.cpp \
Client/udpscope/tests/CliTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): CLI, SDL2/ImGui bootstrap and application shell"
```
---
### Task 10: Trace fetching and the first real plot
Split the plotting work in two: `PlotData` decides *what* points to draw and
is fully unit-tested against the store; `PaneView` is thin ImPlot glue that
draws them. This task draws the tree's single root leaf across the whole plot
area and lets the user drag signals onto it. Splitting arrives in Task 11.
**Files:**
- Create: `Client/udpscope/PlotData.h`
- Create: `Client/udpscope/PlotData.cpp`
- Create: `Client/udpscope/PaneView.h`
- Create: `Client/udpscope/PaneView.cpp`
- Modify: `Client/udpscope/App.h`, `Client/udpscope/App.cpp`
- Modify: `Client/udpscope/CMakeLists.txt`
- Test: `Client/udpscope/tests/PlotDataTest.cpp`
**Interfaces:**
- Consumes: `SignalStore`, `Capture`, `Profile` (Task 6); `MinMaxDecimate`,
`Series`, `Color` (Task 1); `PaneNode`, `Assignment` (Task 2).
- Produces:
```cpp
struct TraceData {
Series raw; // every sample in range — statistics use this
Series draw; // decimated to the plot budget
bool found = false;
};
bool FetchLiveTrace(const SignalStore& store, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out);
bool FetchCaptureTrace(const Capture& cap, const std::string& name,
double t0, double t1, size_t maxPoints, TraceData& out);
Color PaletteColor(size_t index);
struct PaneContext {
SignalStore* store = nullptr;
const Capture* capture = nullptr; // nullptr => live
double x0 = 0.0, x1 = 1.0;
size_t maxPoints = 4000u;
};
class PaneView {
public:
void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
PaneContext& ctx);
};
```
**Why `raw` is kept alongside `draw`:** spec §8.4 requires min/max/pp/mean/RMS
to be computed from undecimated data. Computing them from the decimated
series would report the extremes of the drawn envelope but a mean and RMS
weighted by bucket rather than by sample. Task 14 reads `TraceData::raw`.
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/PlotDataTest.cpp`:
```cpp
#include "PlotData.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `PlotData.h: No such file or directory`.
- [ ] **Step 3: Write `PlotData.h`**
Create `Client/udpscope/PlotData.h`:
```cpp
/**
* @file PlotData.h
* @brief Turns store or capture contents into a drawable trace.
*
* Framework-free so it can be tested without a GUI. Every fetch returns both
* the undecimated samples (for statistics) and the decimated ones (for the
* screen).
*/
#ifndef UDPSCOPE_PLOTDATA_H
#define UDPSCOPE_PLOTDATA_H
#include "SignalStore.h"
#include "Types.h"
#include <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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PlotData*'
```
Expected: PASS, 7 tests.
- [ ] **Step 6: Write the pane view**
Create `Client/udpscope/PaneView.h`:
```cpp
/**
* @file PaneView.h
* @brief ImPlot rendering of one pane-tree leaf. GUI thread only.
*/
#ifndef UDPSCOPE_PANEVIEW_H
#define UDPSCOPE_PANEVIEW_H
#include "PaneTree.h"
#include "PlotData.h"
#include "SignalStore.h"
#include "imgui.h"
namespace udpscope {
/** Everything a leaf needs to draw itself, rebuilt each frame by App. */
struct PaneContext {
SignalStore* store = nullptr;
const Capture* capture = nullptr; /**< nullptr => draw live data */
double x0 = 0.0;
double x1 = 1.0;
size_t maxPoints = 4000u;
};
/** The ImGui drag-and-drop payload carrying a signal name from the list. */
extern const char* const kSignalPayload;
class PaneView {
public:
/** Draws one leaf, including its drop target and legend context menu. */
void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
PaneContext& ctx);
};
} /* namespace udpscope */
#endif /* UDPSCOPE_PANEVIEW_H */
```
Create `Client/udpscope/PaneView.cpp`:
```cpp
#include "PaneView.h"
#include "implot.h"
#include <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:
```cpp
PaneView paneView_;
double xSpanSec_ = 1.0; /**< live window width, Task 12 makes it settable */
```
In `Client/udpscope/SignalList.cpp` — that is where Task 9 put
`App::drawSignalList()`, not `App.cpp` — make the signal list a drag source by
replacing the `ImGui::Selectable(m.name.c_str());` line with:
```cpp
ImGui::Selectable(m.name.c_str());
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
/* Payload is the name including its terminator, so the drop side
can use it as a C string directly. */
ImGui::SetDragDropPayload(kSignalPayload, m.name.c_str(),
m.name.size() + 1u);
ImGui::TextUnformatted(m.name.c_str());
ImGui::EndDragDropSource();
}
```
and replace `drawPlotArea()` with:
```cpp
void App::drawPlotArea() {
PaneContext ctx;
ctx.store = &store_;
ctx.capture = nullptr;
ctx.maxPoints = opt_.maxPlotPoints;
/* Live: the window ends at the newest sample of any assigned signal. The
shared, user-controllable X range arrives in Task 12. */
double newest = 0.0;
bool any = false;
for (size_t i = 0u; i < sigs_.size(); i++) {
double o = 0.0, n = 0.0;
if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) {
newest = n;
any = true;
}
}
ctx.x1 = any ? newest : 1.0;
ctx.x0 = ctx.x1 - xSpanSec_;
PaneNode* root = tree_.root();
paneView_.drawLeaf(*root, "##pane0", ImGui::GetContentRegionAvail(), ctx);
}
```
- [ ] **Step 8: Update CMake**
```cmake
set(CORE_SOURCES
Decimate.cpp
PaneTree.cpp
TimeBase.cpp
FrameDecoder.cpp
Trigger.cpp
SignalStore.cpp
Receiver.cpp
Cli.cpp
PlotData.cpp
)
set(APP_SOURCES
main.cpp
App.cpp
SignalList.cpp
PaneView.cpp
)
```
`SignalList.cpp` stays in the list — it holds `App::drawSignalList()` and
dropping it is a link error, not a warning.
`PaneView.cpp` needs ImGui headers, so it belongs to the executable, not the
core library — that is what keeps `udpscope_tests` free of a GUI dependency.
- [ ] **Step 9: Build and check it plots**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 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**
```bash
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/tests/PlotDataTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): trace fetching, min/max decimation and the first live plot"
```
---
### Task 11: Splittable pane grid
Draw the whole tree instead of just the root leaf: every leaf gets its rect
from `PaneTree::layout()`, hovering one reveals four inset split handles and
a close ✕, and the shared borders drag to re-proportion the split.
**Files:**
- Modify: `Client/udpscope/PaneTree.h`, `Client/udpscope/PaneTree.cpp`
(deferred command application)
- Modify: `Client/udpscope/PaneView.h`, `Client/udpscope/PaneView.cpp`
(tree drawing and interaction)
- Modify: `Client/udpscope/App.cpp`
- Test: `Client/udpscope/tests/PaneCommandTest.cpp`
**Interfaces:**
- Consumes: `PaneTree`, `PaneNode`, `Rect`, `Orient`, `Handle`,
`kMinPaneSize`, `kSplitterGrab`, `kHandleSize` (Task 2); `PaneView`,
`PaneContext` (Task 10).
- Produces:
```cpp
// PaneTree.h
struct PaneCommand {
enum class Kind { None, Split, Close, SetRatio };
Kind kind = Kind::None;
PaneNode* target = nullptr;
Orient orient = Orient::Columns; // Split only
double ratio = 0.5; // SetRatio only
};
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd);
// PaneView.h
void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx);
```
**Why commands are deferred:** `layout()` hands out raw `PaneNode*`. Splitting
or closing during the walk re-parents nodes and invalidates those pointers
mid-frame. Collect at most one command per frame and apply it after the walk.
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/PaneCommandTest.cpp`:
```cpp
#include "PaneTree.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `no type named 'PaneCommand' in namespace 'udpscope'`.
- [ ] **Step 3: Add `PaneCommand` to `PaneTree.h`**
Append inside `namespace udpscope`, after the `PaneTree` class:
```cpp
/**
* A pending mutation of the tree.
*
* layout() hands out raw PaneNode*, which splitting or closing invalidates.
* The view therefore records at most one command per frame and applies it
* after the layout walk has finished.
*/
struct PaneCommand {
enum class Kind { None, Split, Close, SetRatio };
Kind kind = Kind::None;
PaneNode* target = nullptr;
Orient orient = Orient::Columns;
double ratio = 0.5;
};
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd);
```
- [ ] **Step 4: Implement it in `PaneTree.cpp`**
```cpp
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd) {
if (cmd.kind == PaneCommand::Kind::None || cmd.target == nullptr) {
return;
}
switch (cmd.kind) {
case PaneCommand::Kind::Split:
tree.splitLeaf(cmd.target, cmd.orient);
break;
case PaneCommand::Kind::Close:
/* Refuse the last one: an empty tree has nothing to draw and no way
back. */
if (tree.leafCount() > 1u) {
tree.closeLeaf(cmd.target);
}
break;
case PaneCommand::Kind::SetRatio:
tree.setRatio(cmd.target, cmd.ratio); /* clamps internally */
break;
case PaneCommand::Kind::None:
break;
}
}
```
- [ ] **Step 5: Run the command tests and verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneCommand*:PaneTree*'
```
Expected: PASS, 18 tests (12 from Task 2, 6 new).
- [ ] **Step 6: Draw the tree**
In `Client/udpscope/PaneView.h`, add to the class:
```cpp
void drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx);
private:
void drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd);
PaneCommand pending_;
PaneNode* dragSplitter_ = nullptr;
```
In `Client/udpscope/PaneView.cpp`, add:
```cpp
namespace {
/* Panes are drawn inset so the shared border stays free for the splitter. */
const float kInset = 3.0f;
ImVec2 topLeft(const Rect& r) { return ImVec2((float) r.x, (float) r.y); }
ImVec2 sizeOf(const Rect& r) { return ImVec2((float) r.w, (float) r.h); }
} /* namespace */
void PaneView::drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd) {
ImDrawList* dl = ImGui::GetWindowDrawList();
const float h = (float) kHandleSize;
const ImU32 bg = IM_COL32(60, 62, 84, 220);
const ImU32 fg = IM_COL32(205, 214, 244, 255);
struct Spot { Handle which; ImVec2 pos; const char* glyph; };
const Spot spots[5] = {
{Handle::Left, ImVec2((float) (r.x + kHandleSize),
(float) (r.y + r.h * 0.5)), "|"},
{Handle::Right, ImVec2((float) (r.x + r.w - kHandleSize),
(float) (r.y + r.h * 0.5)), "|"},
{Handle::Top, ImVec2((float) (r.x + r.w * 0.5),
(float) (r.y + kHandleSize)), "-"},
{Handle::Bottom, ImVec2((float) (r.x + r.w * 0.5),
(float) (r.y + r.h - kHandleSize)), "-"},
{Handle::Close, ImVec2((float) (r.x + r.w - kHandleSize),
(float) (r.y + kHandleSize)), "x"},
};
for (int i = 0; i < 5; i++) {
const ImVec2 c = spots[i].pos;
ImGui::SetCursorScreenPos(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f));
ImGui::PushID(i);
/* Submitted after the plot, so it wins the hit test over it. */
const bool clicked = ImGui::InvisibleButton("##handle", ImVec2(h, h));
const bool hovered = ImGui::IsItemHovered();
ImGui::PopID();
dl->AddRectFilled(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f),
ImVec2(c.x + h * 0.5f, c.y + h * 0.5f),
hovered ? IM_COL32(88, 91, 112, 255) : bg, 3.0f);
dl->AddText(ImVec2(c.x - 3.0f, c.y - 7.0f), fg, spots[i].glyph);
if (!clicked) {
continue;
}
switch (spots[i].which) {
case Handle::Left:
case Handle::Right:
cmd.kind = PaneCommand::Kind::Split;
cmd.target = &leaf;
cmd.orient = Orient::Columns;
break;
case Handle::Top:
case Handle::Bottom:
cmd.kind = PaneCommand::Kind::Split;
cmd.target = &leaf;
cmd.orient = Orient::Rows;
break;
case Handle::Close:
cmd.kind = PaneCommand::Kind::Close;
cmd.target = &leaf;
break;
case Handle::None:
break;
}
}
}
void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx) {
std::vector<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()`:
```cpp
const ImVec2 origin = ImGui::GetCursorScreenPos();
const ImVec2 avail = ImGui::GetContentRegionAvail();
Rect area;
area.x = origin.x;
area.y = origin.y;
area.w = avail.x;
area.h = avail.y;
paneView_.drawTree(tree_, area, ctx);
```
- [ ] **Step 8: Build and check the interaction by hand**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 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**
```bash
git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
Client/udpscope/App.cpp Client/udpscope/tests/PaneCommandTest.cpp
git commit -m "feat(udpscope): splittable pane grid with inset handles and draggable splitters"
```
---
### Task 12: Shared X axis and per-trace vertical scale
Two pieces of scope behaviour that are pure arithmetic and therefore fully
testable: the X axis that every pane shares and that follows the newest
sample until the user pans, and the per-trace vertical scale in the three
modes the spec calls for.
Every pane's Y axis is a fixed ±4 divisions, exactly like a bench scope, and
each trace is mapped into division space by its own volts-per-division and
offset. `Auto` and `Range` compute those two numbers; `Manual` takes them from
the user. This is what makes traces with different units share a pane without
either being squashed.
**Files:**
- Create: `Client/udpscope/Axes.h`
- Create: `Client/udpscope/Axes.cpp`
- Modify: `Client/udpscope/App.h`, `Client/udpscope/App.cpp`
- Modify: `Client/udpscope/PaneView.cpp`
- Modify: `Client/udpscope/CMakeLists.txt`
- Test: `Client/udpscope/tests/AxesTest.cpp`
**Interfaces:**
- Consumes: `VScale`, `VMode` (Task 2); `SignalMeta`, `Series` (Task 1).
- Produces:
```cpp
constexpr double kDivisionsY = 8.0; // full pane height, ±4
constexpr double kUsableDivsY = 6.0; // data fills 6 of the 8
void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs);
double ToDivisions(double value, const VScale& vs);
class XAxisController {
public:
void setSpan(double sec);
double span() const;
void setLive(bool live);
bool live() const;
void followNewest(double newest);
void userRange(double x0, double x1);
double x0() const;
double x1() const;
};
```
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/AxesTest.cpp`:
```cpp
#include "Axes.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `Axes.h: No such file or directory`.
- [ ] **Step 3: Write `Axes.h`**
Create `Client/udpscope/Axes.h`:
```cpp
/**
* @file Axes.h
* @brief Shared X axis behaviour and per-trace vertical scaling.
*
* Panes always show ±4 divisions vertically, as a bench scope does. Each trace
* carries its own volts-per-division and offset, so traces with different units
* can share a pane without one flattening the other.
*/
#ifndef UDPSCOPE_AXES_H
#define UDPSCOPE_AXES_H
#include "PaneTree.h"
#include "Types.h"
namespace udpscope {
/** Full pane height in divisions. */
constexpr double kDivisionsY = 8.0;
/** Divisions the data is scaled to fill, leaving one at the top and bottom. */
constexpr double kUsableDivsY = 6.0;
/**
* Fill vs.div and vs.offset for Auto and Range. Manual is left untouched.
* Never leaves div at zero, whatever the data does.
*/
void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs);
/** Map a value into division space: 0 is the centre line. */
double ToDivisions(double value, const VScale& vs);
/**
* The one X range every pane shares.
*
* Live mode pins the right edge to the newest sample. Any pan or zoom detaches
* it — silently re-attaching would fight the user every frame — and the Live
* button re-attaches.
*/
class XAxisController {
public:
void setSpan(double sec);
double span() const { return span_; }
void setLive(bool live) { live_ = live; }
bool live() const { return live_; }
/** Called once a frame while live. Ignored when detached. */
void followNewest(double newest);
/** Records a user pan or zoom and detaches. */
void userRange(double x0, double x1);
double x0() const { return x1_ - span_; }
double x1() const { return x1_; }
private:
double span_ = 1.0;
double x1_ = 1.0;
bool live_ = true;
};
} /* namespace udpscope */
#endif /* UDPSCOPE_AXES_H */
```
- [ ] **Step 4: Write `Axes.cpp`**
Create `Client/udpscope/Axes.cpp`:
```cpp
#include "Axes.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='VScale*:XAxis*'
```
Expected: PASS, 10 tests.
- [ ] **Step 6: Draw traces in division space**
In `Client/udpscope/PaneView.cpp`, add `#include "Axes.h"` and replace the
plotting body of `drawLeaf()`'s loop (between the `FetchLiveTrace` call and
`BeginLegendPopup`) with:
```cpp
if (!ok || d.draw.empty()) {
continue;
}
ComputeVScale(ctx.metaFor(a.signalName), d.raw, a.vs);
/* Reuse one scratch buffer per pane rather than allocating per trace
at 60 Hz. */
divScratch_.resize(d.draw.size());
for (size_t k = 0u; k < d.draw.size(); k++) {
divScratch_[k] = ToDivisions(d.draw.v[k], a.vs);
}
char label[128];
std::snprintf(label, sizeof(label), "%s %.4g %s/div", a.signalName.c_str(),
a.vs.div, ctx.metaFor(a.signalName).unit.empty()
? "u" : ctx.metaFor(a.signalName).unit.c_str());
ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
ImPlot::PlotLine(label, d.draw.t.data(), divScratch_.data(),
static_cast<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:
```cpp
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:
```cpp
std::vector<double> divScratch_;
```
and to `PaneContext`:
```cpp
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`:
```cpp
const SignalMeta& PaneContext::metaFor(const std::string& name) const {
static const SignalMeta kUnknown;
if (metas != nullptr) {
for (size_t i = 0u; i < metas->size(); i++) {
if ((*metas)[i].name == name) {
return (*metas)[i];
}
}
}
return kUnknown;
}
```
Finally pin the Y axis and let ImPlot report the user's X range. Replace the
`SetupAxes`/`SetupAxisLimits` pair with:
```cpp
ImPlot::SetupAxes("t [s]", "div");
ImPlot::SetupAxisLimits(ImAxis_X1, ctx.x0, ctx.x1,
ctx.xLive ? ImPlotCond_Always : ImPlotCond_Once);
ImPlot::SetupAxisLimits(ImAxis_Y1, -kDivisionsY * 0.5, kDivisionsY * 0.5,
ImPlotCond_Always);
```
and just before `ImPlot::EndPlot()`:
```cpp
/* A pan or zoom in any pane redefines the shared range for all of them. */
if (ImPlot::IsPlotHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
const ImPlotRect lim = ImPlot::GetPlotLimits();
ctx.userX0 = lim.X.Min;
ctx.userX1 = lim.X.Max;
ctx.userChanged = true;
}
if (ImPlot::IsPlotHovered() && ImGui::GetIO().MouseWheel != 0.0f) {
const ImPlotRect lim = ImPlot::GetPlotLimits();
ctx.userX0 = lim.X.Min;
ctx.userX1 = lim.X.Max;
ctx.userChanged = true;
}
```
with the three new `PaneContext` fields:
```cpp
bool xLive = true;
double userX0 = 0.0, userX1 = 0.0;
bool userChanged = false;
```
- [ ] **Step 7: Own the axis in App**
In `App.h`, add `#include "Axes.h"` and replace `double xSpanSec_` with:
```cpp
XAxisController xaxis_;
```
In `App.cpp`, replace the range computation in `drawPlotArea()` with:
```cpp
double newest = 0.0;
bool any = false;
for (size_t i = 0u; i < sigs_.size(); i++) {
double o = 0.0, n = 0.0;
if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) {
newest = n;
any = true;
}
}
if (any) {
xaxis_.followNewest(newest);
}
ctx.x0 = xaxis_.x0();
ctx.x1 = xaxis_.x1();
ctx.xLive = xaxis_.live();
ctx.metas = &sigs_;
```
and after `paneView_.drawTree(...)`:
```cpp
if (ctx.userChanged) {
xaxis_.userRange(ctx.userX0, ctx.userX1);
}
```
There is no View menu yet — Task 9 built only File and Help. Add one to
`drawMenuBar()`, between the File and Help blocks:
```cpp
if (ImGui::BeginMenu("View")) {
bool live = xaxis_.live();
if (ImGui::MenuItem("Live", "L", &live)) {
xaxis_.setLive(live);
}
double span = xaxis_.span();
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputDouble("window [s]", &span, 0.0, 0.0, "%.4g")) {
xaxis_.setSpan(span);
}
ImGui::EndMenu();
}
```
- [ ] **Step 8: Build and verify by hand**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 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**
```bash
git add Client/udpscope/Axes.h Client/udpscope/Axes.cpp \
Client/udpscope/App.h Client/udpscope/App.cpp Client/udpscope/PaneView.h \
Client/udpscope/PaneView.cpp Client/udpscope/tests/AxesTest.cpp \
Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): shared X axis with live follow and per-trace division scaling"
```
---
### Task 13: Trigger bar and capture display
**Files:**
- Create: `Client/udpscope/CaptureLatch.h`
- Create: `Client/udpscope/CaptureLatch.cpp`
- Create: `Client/udpscope/tests/CaptureLatchTest.cpp`
- Create: `Client/udpscope/TriggerBar.cpp`
- Modify: `Client/udpscope/App.h`
- Modify: `Client/udpscope/App.cpp`
- Modify: `Client/udpscope/CMakeLists.txt`
**Interfaces:**
- Consumes: `SignalStore::captureSeq()`, `SignalStore::readCapture()`,
`Capture` (Task 6); `Receiver::setTrigConfig/trigConfig/arm/disarm/rearm/trigStatus`
and `TrigStatus` (Task 7); `TrigConfig`, `Edge`, `TrigMode`, `TrigState`
(Task 5); `PaneContext::capture` (Task 10); `XAxisController` (Task 12).
- Produces: `class CaptureLatch` with `poll(const SignalStore&) -> bool`,
`showing()`, `setFollow(bool)`, `follow()`, `returnToLive()`,
`capture() -> const Capture&`, `x0()`, `x1()`, `seen() -> uint64_t`; and
`std::string TrigBadge(const TrigStatus&)`. Task 16 exports
`CaptureLatch::capture()` to CSV.
**Why a latch rather than reading the store directly in the draw call:**
the pane tree draws many leaves per frame and each one needs the *same*
capture, so the copy out of the store must happen once per frame, not once
per pane. The latch also owns the freeze decision — a user studying one
capture must not have it swapped out from under them by the next trigger —
and that decision is pure state machine, so it is unit-testable without a
window.
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/CaptureLatchTest.cpp`:
```cpp
#include "CaptureLatch.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `CaptureLatch.h: No such file or directory`.
- [ ] **Step 3: Write `CaptureLatch.h`**
Create `Client/udpscope/CaptureLatch.h`:
```cpp
/**
* @file CaptureLatch.h
* @brief Decides, once per frame, whether the panes draw live data or a
* harvested trigger capture.
*/
#ifndef UDPSCOPE_CAPTURELATCH_H
#define UDPSCOPE_CAPTURELATCH_H
#include "Receiver.h"
#include "SignalStore.h"
#include <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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='CaptureLatch*:TrigBadge*'
```
Expected: PASS, 12 tests.
- [ ] **Step 6: Commit the latch**
```bash
git add Client/udpscope/CaptureLatch.h Client/udpscope/CaptureLatch.cpp \
Client/udpscope/tests/CaptureLatchTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): capture latch and trigger state badge"
```
- [ ] **Step 7: Add the trigger bar to App.h**
In `Client/udpscope/App.h`, add the include and members:
```cpp
#include "CaptureLatch.h"
```
Add to the private method list, next to `drawMenuBar()`:
```cpp
void drawTriggerBar();
/** Pushes the edited config into the receiver and re-ranges the panes. */
void applyTrigConfig();
```
and to the private data, after `PaneTree tree_;`:
```cpp
CaptureLatch latch_;
TrigConfig trig_; /**< the bar's editable copy */
int trigSignal_ = -1; /**< index into sigs_, -1 = none */
```
- [ ] **Step 8: Draw the trigger bar**
The bar gets its own translation unit, as spec §3.4 lays out; the functions
are still `App` methods so they reach the receiver and the latch directly.
Create `Client/udpscope/TriggerBar.cpp`:
```cpp
#include "App.h"
#include "imgui.h"
namespace udpscope {
void App::applyTrigConfig() {
rx_.setTrigConfig(trig_);
}
void App::drawTriggerBar() {
ImGui::BeginChild("trigbar", ImVec2(0.0f, ImGui::GetFrameHeightWithSpacing() + 6.0f),
false, ImGuiWindowFlags_NoScrollbar);
/* Signal. The list can change under us on a CONFIG re-send, so the
selection is re-resolved by name every frame. */
trigSignal_ = -1;
for (size_t i = 0u; i < sigs_.size(); i++) {
if (sigs_[i].name == trig_.signalName) {
trigSignal_ = static_cast<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:
```cpp
if (latch_.poll(store_)) {
/* A capture just arrived: pin the shared axis to its window. This
detaches the axis from live follow, which is what we want — the
user can then zoom inside the capture with the normal controls. */
xaxis_.userRange(latch_.x0(), latch_.x1());
}
```
and add `drawTriggerBar();` to the frame between `drawMenuBar()` and the
signal list, matching spec §8.1's ordering.
In `App::drawPlotArea()`, replace `ctx.capture = nullptr;` with:
```cpp
ctx.capture = latch_.showing() ? &latch_.capture() : nullptr;
```
- [ ] **Step 10: Build and verify by hand**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 113.
Against the demo streamer:
```bash
source env.sh
"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" -l RealTimeLoader \
-f Test/Configurations/streamhub_demo.cfg -s Running -m StateMachine:START &
./Client/udpscope/build/UDPScope --port 44501
```
1. Drop a sine into a pane; the badge reads `IDLE`.
2. Pick that signal in the trigger bar, threshold `0`, window `0.02`,
pre `20`, `Norm`, press **Arm**: the badge goes `ARMED 0%``ARMED 100%`
within a fraction of a second, then flashes `TRIG'D` on each trigger.
3. The pane freezes onto a stable waveform whose rising edge sits 20 % in
from the left, and the capture counter climbs.
4. Untick **follow**: the waveform stops updating while the counter keeps
climbing. Tick it again: it jumps straight to the newest capture.
5. Press **Live**: the pane scrolls again and the **Live** button greys out.
6. Switch to `1x` and press **Arm**: exactly one capture appears, the badge
settles on `HELD`, and **Re-arm** produces the next one.
7. Set the threshold above the sine's amplitude: the badge stays
`ARMED 100%` and no new captures arrive.
- [ ] **Step 11: Commit**
```bash
git add Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/TriggerBar.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): trigger bar with arm/disarm and capture display"
```
---
### Task 14: Cursors and measurements
**Files:**
- Create: `Client/udpscope/Measure.h`
- Create: `Client/udpscope/Measure.cpp`
- Create: `Client/udpscope/tests/MeasureTest.cpp`
- Modify: `Client/udpscope/PaneView.h`
- Modify: `Client/udpscope/PaneView.cpp`
- Modify: `Client/udpscope/App.h`
- Modify: `Client/udpscope/App.cpp`
- Modify: `Client/udpscope/CMakeLists.txt`
**Interfaces:**
- Consumes: `Series` (Task 1), `TraceData::raw` (Task 10), `PaneContext`
(Tasks 10/12), `VScale`/`ToDivisions` (Task 12).
- Produces: `struct Stats`, `ComputeStats(const Series&, double t0, double t1)`,
`bool SampleAt(const Series&, double t, double& v)`, `struct Cursors`
(`enabled`, `tA`, `tB`, `dt()`, `freq()`). Task 15 persists `Cursors`.
**Why statistics come from `TraceData::raw`:** the drawn series is a min/max
envelope, two points per bucket. Its extremes are correct, but a mean or RMS
over it weights each bucket equally instead of each sample, so a burst of
1000 samples in one bucket would count the same as a bucket holding two.
Spec §8.4 requires true statistics, so `ComputeStats` reads the undecimated
series.
- [ ] **Step 1: Write the failing tests**
Create `Client/udpscope/tests/MeasureTest.cpp`:
```cpp
#include "Measure.h"
#include <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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `Measure.h: No such file or directory`.
- [ ] **Step 3: Write `Measure.h`**
Create `Client/udpscope/Measure.h`:
```cpp
/**
* @file Measure.h
* @brief Cursor readouts and per-trace statistics (spec §8.4).
*/
#ifndef UDPSCOPE_MEASURE_H
#define UDPSCOPE_MEASURE_H
#include "Types.h"
#include <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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ComputeStats*:SampleAt*:Cursors*'
```
Expected: PASS, 13 tests.
- [ ] **Step 6: Commit the measurement core**
```bash
git add Client/udpscope/Measure.h Client/udpscope/Measure.cpp \
Client/udpscope/tests/MeasureTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): cursor sampling and undecimated trace statistics"
```
- [ ] **Step 7: Draw the cursors and the statistics table**
In `Client/udpscope/PaneView.h`, add `#include "Measure.h"` and two fields to
`PaneContext`:
```cpp
Cursors* cursors = nullptr; /**< shared by every pane; may be null */
bool showStats = false;
```
In `Client/udpscope/PaneView.cpp`, inside `drawLeaf()`'s plot, after the trace
loop and before the legend popup handling, add:
```cpp
if (ctx.cursors != nullptr && ctx.cursors->enabled) {
/* Dragging in any pane moves the cursors in all of them, because
they are one shared pair of times (spec §8.4). */
double a = ctx.cursors->tA;
double b = ctx.cursors->tB;
const ImVec4 amber(0.98f, 0.70f, 0.53f, 1.0f);
const ImVec4 blue(0.54f, 0.71f, 0.98f, 1.0f);
if (ImPlot::DragLineX(kCursorAId, &a, amber, 1.0f)) {
ctx.cursors->tA = a;
}
if (ImPlot::DragLineX(kCursorBId, &b, blue, 1.0f)) {
ctx.cursors->tB = b;
}
}
```
with the ids declared at file scope in `PaneView.cpp`:
```cpp
namespace {
/* ImPlot drag-line ids must be unique within a plot but may repeat across
plots; A and B are the same logical cursors in every pane. */
const int kCursorAId = 1001;
const int kCursorBId = 1002;
} /* namespace */
```
Then, still inside the plot and after the cursor block, draw the readout as a
plot annotation-free overlay:
```cpp
const bool cursorsUp = (ctx.cursors != nullptr) && ctx.cursors->enabled;
if ((ctx.showStats || cursorsUp) && !traces_.empty()) {
/* Statistics are taken between the cursors when they are up, over the
visible range otherwise. */
const double s0 = cursorsUp ? ctx.cursors->tA : ctx.x0;
const double s1 = cursorsUp ? ctx.cursors->tB : ctx.x1;
ImPlot::PushPlotClipRect();
const ImVec2 org = ImPlot::GetPlotPos();
ImDrawList* dl = ImPlot::GetPlotDrawList();
float y = org.y + 4.0f;
for (size_t i = 0u; i < traces_.size(); i++) {
char line[288];
int used = std::snprintf(line, sizeof(line), "%s",
traces_[i].name.c_str());
if (cursorsUp) {
/* Spec §8.4: the value at each cursor and their difference,
per displayed signal. */
double va = 0.0, vb = 0.0;
const bool ha = SampleAt(traces_[i].raw, ctx.cursors->tA, va);
const bool hb = SampleAt(traces_[i].raw, ctx.cursors->tB, vb);
if (ha && hb) {
used += std::snprintf(line + used,
sizeof(line) - static_cast<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:
```cpp
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(...)`:
```cpp
if (ctx.showStats || (ctx.cursors != nullptr && ctx.cursors->enabled)) {
TraceKeep keep;
keep.name = a.signalName;
keep.color = a.color;
keep.raw = d.raw; /* copied: d is reused by the next trace */
traces_.push_back(keep);
}
```
- [ ] **Step 8: Own the cursors in App**
In `App.h`, add `#include "Measure.h"` and, next to `latch_`:
```cpp
Cursors cursors_;
bool showStats_ = false;
```
In `App.cpp`, extend the View menu built in Task 12:
```cpp
ImGui::Separator();
ImGui::MenuItem("Cursors", "C", &cursors_.enabled);
ImGui::MenuItem("Measurements", "M", &showStats_);
```
and pass them to the panes in `drawPlotArea()`, beside `ctx.metas = &sigs_;`:
```cpp
ctx.cursors = &cursors_;
ctx.showStats = showStats_;
```
Place the cursors sensibly the first time they are switched on, otherwise
they sit at t=0, far off the left of a wall-clock axis. Immediately after the
View menu block in `drawMenuBar()`:
```cpp
if (cursors_.enabled && !cursorsPlaced_) {
const double span = xaxis_.span();
cursors_.tA = xaxis_.x0() + span * 0.25;
cursors_.tB = xaxis_.x0() + span * 0.75;
cursorsPlaced_ = true;
}
if (!cursors_.enabled) {
cursorsPlaced_ = false;
}
```
with `bool cursorsPlaced_ = false;` added next to `showStats_`.
Finally show the readout in the status bar. In `drawStatusBar()`, before the
existing counters:
```cpp
if (cursors_.enabled) {
ImGui::Text("A %.6g s B %.6g s dt %.6g s 1/dt %.6g Hz",
cursors_.tA, cursors_.tB, cursors_.dt(), cursors_.freq());
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
}
```
- [ ] **Step 9: Build and verify by hand**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 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**
```bash
git add Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
Client/udpscope/App.h Client/udpscope/App.cpp
git commit -m "feat(udpscope): shared time cursors and per-pane measurements"
```
---
### Task 15: Session persistence
**Files:**
- Create: `Client/udpscope/Settings.h`
- Create: `Client/udpscope/Settings.cpp`
- Create: `Client/udpscope/tests/SettingsTest.cpp`
- Modify: `Client/udpscope/App.h`
- Modify: `Client/udpscope/App.cpp`
- Modify: `Client/udpscope/CMakeLists.txt`
**Interfaces:**
- Consumes: `PaneNode`, `Assignment`, `VScale`, `VMode`, `Orient`,
`PaneTree::setRoot` (Task 2); `TrigConfig`, `Edge`, `TrigMode` (Task 5);
`ReceiverOptions` (Task 7); `CliOptions` and its `set*` flags,
`DefaultConfigPath()` (Task 9); `Cursors` (Task 14).
- Produces: `struct Session { ReceiverOptions source; TrigConfig trigger;
Cursors cursors; std::unique_ptr<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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `Settings.h: No such file or directory`.
- [ ] **Step 3: Write `Settings.h`**
Create `Client/udpscope/Settings.h`:
```cpp
/**
* @file Settings.h
* @brief The line-based session file described in spec §9.
*/
#ifndef UDPSCOPE_SETTINGS_H
#define UDPSCOPE_SETTINGS_H
#include "Cli.h"
#include "Measure.h"
#include "PaneTree.h"
#include "Receiver.h"
#include "Trigger.h"
#include <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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Settings*:MergeCli*'
```
Expected: PASS, 18 tests.
- [ ] **Step 6: Commit the session core**
```bash
git add Client/udpscope/Settings.h Client/udpscope/Settings.cpp \
Client/udpscope/tests/SettingsTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): session file writer, parser and CLI precedence"
```
- [ ] **Step 7: Load the session before the receiver starts**
In `App.h` add `#include "Settings.h"` and these members next to `opt_`:
```cpp
std::string configPath_;
bool dirty_ = false; /**< something worth saving has changed */
```
and declare:
```cpp
void loadSession();
void saveSession();
Session currentSession() const;
```
Replace the body of `App::App()` in `App.cpp` with:
```cpp
App::App(const CliOptions& opt) : opt_(opt), rx_(store_) {
configPath_ = opt_.setConfigPath ? opt_.configPath : DefaultConfigPath();
loadSession();
std::string err;
if (!rx_.start(opt_.source, err)) {
status_ = "receiver failed to start: " + err;
} else {
char buf[160];
std::snprintf(buf, sizeof(buf), "attaching to %s:%u",
opt_.source.host.c_str(),
static_cast<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`:
```cpp
/** Deep-copies a subtree; returns null for a null input. */
std::unique_ptr<PaneNode> ClonePane(const PaneNode* n);
```
and to `PaneTree.cpp`:
```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`:
```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:
```cpp
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Save Layout", "Ctrl+S")) {
saveSession();
}
if (ImGui::MenuItem("Reload Layout")) {
loadSession();
}
ImGui::Separator();
if (ImGui::MenuItem("Quit", "Ctrl+Q")) {
requestQuit();
}
ImGui::EndMenu();
}
```
and in `App::~App()`:
```cpp
App::~App() {
saveSession(); /* spec §9: saved on clean exit */
rx_.stop();
}
```
- [ ] **Step 9: Build and verify by hand**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 115.
Against the demo streamer:
```bash
./Client/udpscope/build/UDPScope --port 44501
```
1. Split into three panes, drop different signals in each, set one to manual
v-scale, turn cursors on, configure the trigger. Quit.
2. `cat ~/.config/udpscope/session.conf` shows the documented shape.
3. Relaunch with no arguments: the same three panes, signals, colours,
v-scales, cursors and trigger settings come back, and it reconnects to
port 44501 because the file recorded it.
4. Relaunch with `--port 44502`: the layout is restored but the source is the
command-line one; the status bar shows the new port.
5. Append `bananas` to the file and relaunch: the status bar reports
`unknown keyword 'bananas'` and the app opens with a single empty pane —
nothing half-applied.
- [ ] **Step 10: Commit**
```bash
git add Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
Client/udpscope/tests/PaneTreeTest.cpp
git commit -m "feat(udpscope): restore and save the session layout"
```
---
### Task 16: CSV export
**Files:**
- Create: `Client/udpscope/Export.h`
- Create: `Client/udpscope/Export.cpp`
- Create: `Client/udpscope/tests/ExportTest.cpp`
- Modify: `Client/udpscope/App.h`
- Modify: `Client/udpscope/App.cpp`
- Modify: `Client/udpscope/CMakeLists.txt`
**Interfaces:**
- Consumes: `Series` (Task 1), `PaneNode` (Task 2), `SignalStore` and
`Capture` (Task 6), `FetchLiveTrace`/`FetchCaptureTrace` (Task 10),
`CaptureLatch` (Task 13).
- Produces: `struct CsvTrace { std::string name; Series data; }`,
`std::string BuildCsv(const std::vector<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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `Export.h: No such file or directory`.
- [ ] **Step 3: Write `Export.h`**
Create `Client/udpscope/Export.h`:
```cpp
/**
* @file Export.h
* @brief Long-format CSV export of a capture or of the visible live window
* (spec §10).
*/
#ifndef UDPSCOPE_EXPORT_H
#define UDPSCOPE_EXPORT_H
#include "PaneTree.h"
#include "Types.h"
#include <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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='BuildCsv*:ExportCsvFile*:SignalsInPane*'
```
Expected: PASS, 10 tests.
- [ ] **Step 6: Commit the export core**
```bash
git add Client/udpscope/Export.h Client/udpscope/Export.cpp \
Client/udpscope/tests/ExportTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): long-format CSV export"
```
- [ ] **Step 7: Gather the traces and hook up the File menu**
In `App.h`, add `#include "Export.h"` and:
```cpp
/**
* @param node the subtree to export, or the whole tree when null.
* @return false with @a err set when there is nothing to export.
*/
bool gatherExport(const PaneNode* node, std::vector<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`:
```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`:
```cpp
if (ImGui::MenuItem("Export CSV (all panes)")) {
exportCsv(NULL);
}
if (ImGui::MenuItem("Export CSV (this pane)", NULL, false,
exportPane_ != NULL)) {
exportCsv(exportPane_);
}
ImGui::Separator();
```
`exportPane_` is the pane the pointer was last over. In `drawPlotArea()`,
after `paneView_.drawTree(...)`, add:
```cpp
exportPane_ = ctx.hoveredLeaf;
```
with `PaneNode* hoveredLeaf = nullptr;` added to `PaneContext`, set in
`PaneView::drawLeaf()` right after `ImPlot::BeginPlot()` succeeds:
```cpp
if (ImPlot::IsPlotHovered()) {
ctx.hoveredLeaf = &leaf;
}
```
and cleared by `PaneView::drawTree()` before it walks the layout:
```cpp
ctx.hoveredLeaf = nullptr;
```
- [ ] **Step 8: Build and verify by hand**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 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**
```bash
git add Client/udpscope/App.h Client/udpscope/App.cpp \
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp
git commit -m "feat(udpscope): export the capture or the live window to CSV"
```
---
### Task 17: Profile panes and the array-interpretation toggle
Spec §4.2 says a signal that is genuinely a vector is plotted against element
index, not unrolled onto the time axis. Tasks 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`:
```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**
```bash
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5
```
Expected: FAIL — `'FetchProfileTrace' was not declared in this scope`.
- [ ] **Step 3: Implement `FetchProfileTrace`**
Add to `Client/udpscope/PlotData.h`, beside the other fetchers:
```cpp
/**
* @brief Reads the latest vector snapshot, plotted against element index.
* @param stamp receives the wall-clock time the snapshot was taken.
* @return false when the signal has no profile snapshot.
*/
bool FetchProfileTrace(const SignalStore& store, const std::string& name,
TraceData& out, double& stamp);
```
and to `Client/udpscope/PlotData.cpp`:
```cpp
bool FetchProfileTrace(const SignalStore& store, const std::string& name,
TraceData& out, double& stamp) {
out.raw.clear();
out.draw.clear();
out.found = false;
Profile p;
if (!store.readProfile(name, p) || p.v.empty()) {
return false;
}
out.found = true;
stamp = p.time;
out.raw.t = p.x;
out.raw.v = p.v;
/* Element count is bounded by the array size, not by a sample rate, so
there is nothing to decimate. */
out.draw = out.raw;
return true;
}
```
- [ ] **Step 4: Run the tests to verify they pass**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FetchProfileTrace*'
```
Expected: PASS, 3 tests.
- [ ] **Step 5: Draw profile panes**
In `Client/udpscope/PaneView.cpp`, at the top of `drawLeaf()`'s plot body,
branch on the pane kind. Replace the `ImPlot::SetupAxes("t [s]", "div")` call
and the trace loop's entry condition with:
```cpp
if (leaf.profilePane) {
ImPlot::SetupAxes("element", "value");
ImPlot::SetupAxisLimits(ImAxis_X1, 0.0, 1.0, ImPlotCond_Once);
ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Once);
for (size_t i = 0u; i < leaf.signals.size(); i++) {
Assignment& a = leaf.signals[i];
TraceData d;
double stamp = 0.0;
if (ctx.store == nullptr ||
!FetchProfileTrace(*ctx.store, a.signalName, d, stamp) ||
d.draw.empty()) {
continue;
}
char plabel[128];
std::snprintf(plabel, sizeof(plabel), "%s @%.3f s",
a.signalName.c_str(), stamp);
ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
ImPlot::PlotLine(plabel, d.draw.t.data(), d.draw.v.data(),
static_cast<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:
```cpp
const SignalMeta& dm = ctx.metaFor(dropped);
const bool wantsProfile = dm.isVectorProfile();
/* Not a `return`: the drop target sits between BeginPlot() and
EndPlot(), so bailing out here would unbalance ImPlot. */
if (leaf.signals.empty() || leaf.profilePane == wantsProfile) {
leaf.profilePane = wantsProfile;
leaf.signals.push_back(a);
}
```
- [ ] **Step 6: Add the interpretation toggle to the signal list**
In `Client/udpscope/SignalList.cpp`, inside the per-signal loop after the
tooltip, add:
```cpp
if (m.numElements() > 1u && m.timeMode == kTimePacket) {
/* Only PACKET arrays are ambiguous: everything else says outright
whether it is a burst. */
if (ImGui::BeginPopupContextItem("##sigmenu")) {
bool prof = rx_.profileOverride(m.name);
if (ImGui::MenuItem("plot against element index", NULL, &prof)) {
rx_.setProfileOverride(m.name, prof);
status_ = m.name + (prof ? ": vector profile"
: ": packed burst");
}
ImGui::EndPopup();
}
}
```
The override reaches the receiver thread through `Receiver`'s command queue
and survives a CONFIG re-send (Task 8), so a stream that re-announces itself
does not silently revert to burst.
`SignalList.cpp` needs `#include "Types.h"` for `kTimePacket`; `App.h` already
pulls it in through `SignalStore.h`.
- [ ] **Step 7: Build and verify by hand**
```bash
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests
```
Expected: PASS, all tests from Tasks 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**
```bash
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
Client/udpscope/PaneView.cpp Client/udpscope/SignalList.cpp \
Client/udpscope/tests/PlotDataTest.cpp
git commit -m "feat(udpscope): index-plot vector profiles and toggle the array interpretation"
```
---
### Task 18: Documentation and repository integration
**Files:**
- Create: `Docs/UDPScope.md`
- Create: `Client/udpscope/resources/udpscope.desktop`
- Create: `Client/udpscope/resources/icons/udpscope.svg`
- Modify: `Client/udpscope/CMakeLists.txt`
- Modify: `README.md`
- Modify: `CLAUDE.md`
- Modify: `ARCHITECTURE.md`
- Modify: `.gitignore`
**Interfaces:**
- Consumes: everything built in Tasks 117. No new code.
- [ ] **Step 1: Write `Docs/UDPScope.md`**
Create `Docs/UDPScope.md`:
````markdown
# UDPScope
A bench oscilloscope that attaches directly to one `UDPStreamer`. No StreamHub,
no WebSocket, no browser: the UDPS datagrams go straight into the scope through
the standalone C client in `Common/Client/c`.
Use it when you want to look at a signal now — on a control-room machine, over
a lab network, on a host that has nothing installed. Use StreamHub instead when
you need several sources aggregated, history on disk, or more than one client
watching at once.
## Build
Needs SDL2 and OpenGL; ImGui, ImPlot and GoogleTest are fetched by CMake.
```bash
cd Client/udpscope
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/udpscope_tests # unit tests
```
## Run
```bash
./build/UDPScope --host 192.168.1.10 --port 44500
```
| Option | Default | Meaning |
|---|---|---|
| `--host ADDR` | `127.0.0.1` | Streamer control address |
| `--port N` | `44500` | Streamer control port |
| `--multicast GROUP` | — | Join this group instead of unicast |
| `--iface ADDR` | — | Local interface IP for the multicast join |
| `--data-port N` | `0` | Data port when the streamer separates it |
| `--silence SEC` | `2.0` | Reconnect after this long without a packet |
| `--config PATH` | `$XDG_CONFIG_HOME/udpscope/session.conf` | Session file |
| `--max-mpts N` | `4000` | Drawn points per trace before decimation |
Long `--` options only, matching `Common/Client/c/example/udps_dump.c`. An
option given on the command line beats the session file; anything omitted comes
from the file.
## Panes
The plot area is a splittable grid. Hover a pane to reveal its handles:
- the four edge handles split it left/right/top/bottom;
- the ✕ closes it and gives its space back to its sibling;
- the border between two panes is a splitter you can drag.
Drag a signal from the list on the left into a pane to plot it. Right-click a
legend entry for colour, line width and vertical scale; the same menu removes
the trace.
## Vertical scale
Panes are eight divisions tall, ±4 about the centre line, so traces in
different units share a pane without lying about each other's amplitude. Each
trace has its own volts-per-division:
- **auto** — fits the samples currently on screen;
- **range** — uses the `range_min`/`range_max` the CONFIG packet carries;
- **manual** — you set per-division and offset, as on a bench scope.
The legend shows the value per division for each trace.
## Time axis
Every pane shares one X axis. In live mode it follows the newest sample; any
pan or zoom detaches it, and View → Live re-attaches. View → window sets the
span in seconds.
## Trigger
The trigger bar configures a client-side trigger: signal, edge, threshold,
hysteresis, window length, and where in that window the trigger point sits
(`pre %`).
| Mode | Behaviour |
|---|---|
| **Norm** | Re-arms after every capture; the display holds the last one until the next trigger |
| **1x** | Captures once and stays held until you press Re-arm |
The badge reads `IDLE`, `ARMED nn%`, `TRIG'D` or `HELD`. The percentage is the
pre-trigger window filling up: the scope refuses to arm until it holds enough
history to back-fill the part of the capture that precedes the trigger, so a
capture never starts mid-waveform.
Untick **follow** to study one capture while later ones go by; tick it again
and the newest capture appears at once. **Live** drops the capture and goes
back to the rolling display.
Known simplification: with `edge = both` and a non-zero hysteresis, re-arming
uses the rising-edge arm level for both directions. Set hysteresis to 0 if you
need symmetric behaviour on a noisy bipolar signal.
## Cursors and measurements
View → Cursors puts two draggable time cursors in every pane at once; the
status bar reads `A`, `B`, `dt` and `1/dt`. View → Measurements overlays
min/max/peak-to-peak/average/RMS per trace, taken between the cursors when they
are up and over the visible range otherwise.
All statistics come from the undecimated samples, never from the drawn
envelope, so a single-sample spike is counted even when it is not individually
visible.
## Array signals
A signal with `NumberOfElements > 1` is a packed burst by default: its elements
are unrolled onto the time axis using the accompanying time signal or the
declared sampling rate. A signal that is genuinely a vector — a spatial
profile, not a burst — is plotted against element index instead. Toggle the
interpretation from the signal list's context menu; the choice survives a
CONFIG re-send.
## Export
File → Export CSV writes long format:
```
signal,time_s,wallclock_s,value
Voltage,0.000000000,1756291200.123456,0.4981
```
`time_s` is measured from the start of the exported window; `wallclock_s` is
absolute. One row per sample per signal, because signals carry independent
timestamps and a wide format would need resampling. Export covers every
assigned signal, or just the pane under the pointer.
## Session file
Layout, colours, vertical scales, trigger settings, cursors and the source are
saved on exit and from File → Save Layout, to
`$XDG_CONFIG_HOME/udpscope/session.conf`. The format is line-based and
hand-editable; indentation is decorative. A malformed file is reported in the
status bar and ignored outright, never partially applied.
## Diagnostics
The status bar counts packets, frames, counter gaps, dropped fragments and
reconnects; the last three turn red as soon as they are non-zero.
`udpscope_rxprobe` is a headless build of the same receive path. When the GUI
shows nothing, run it to find out whether the problem is the network or the
scope:
```bash
./build/udpscope_rxprobe --host 192.168.1.10 --port 44500
```
Cross-check against the reference C client if they disagree:
```bash
cd Common/Client/c && make && ./udps_dump 192.168.1.10 44500
```
## Relationship to StreamHub
| | UDPScope | StreamHub clients |
|---|---|---|
| Sources | One streamer | Many, aggregated |
| Transport | UDPS direct | UDPS → hub → WebSocket |
| History | In-memory rings only | Disk-backed `.shist` files |
| Trigger | In the client | In the hub, shared by clients |
| Processes | One | Hub plus client |
The two share the wire format (`Common/UDP/UDPSProtocol.h`) and nothing else.
UDPScope reads `Client/streamhub/SignalBuffer.h` and the shared fonts
read-only; it never modifies anything under `Client/streamhub/`.
````
- [ ] **Step 1b: Add the desktop entry and icon**
Spec §12 asks for install rules covering the `.desktop` entry and icon, which
Tasks 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`:
```ini
[Desktop Entry]
Type=Application
Name=UDPScope
GenericName=Signal Oscilloscope
Comment=Bench oscilloscope attached directly to a MARTe2 UDPStreamer
Exec=UDPScope
Icon=udpscope
Terminal=false
Categories=Science;Engineering;DataVisualization;
Keywords=MARTe2;oscilloscope;UDPS;signals;
StartupWMClass=UDPScope
```
Create `Client/udpscope/resources/icons/udpscope.svg` — a trace on a graticule,
in the same Catppuccin palette the app uses:
```xml
<?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`:
```cmake
install(FILES ${RESOURCE_DIR}/udpscope.desktop
DESTINATION share/applications)
install(FILES ${RESOURCE_DIR}/icons/udpscope.svg
DESTINATION share/icons/hicolor/scalable/apps)
```
`RESOURCE_DIR` points at `Client/streamhub/resources` (Task 1, for the fonts),
so add a second variable beside it and use that here:
```cmake
set(UDPSCOPE_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/resources)
```
then replace `${RESOURCE_DIR}` with `${UDPSCOPE_RESOURCE_DIR}` in the two
install lines above.
Verify:
```bash
cd Client/udpscope && cmake -B build -DCMAKE_INSTALL_PREFIX=/tmp/udpscope-prefix \
&& cmake --build build -j && cmake --install build
find /tmp/udpscope-prefix -type f | sort
```
Expected: `bin/UDPScope`, the fonts under `share/udpscope`,
`share/applications/udpscope.desktop` and
`share/icons/hicolor/scalable/apps/udpscope.svg`.
- [ ] **Step 2: Add the repository entries**
In `README.md`, add to the capability table after the `Integrated client` row:
```markdown
| **Direct scope** | `Client/udpscope` | ImGui bench oscilloscope attached straight to one UDPStreamer |
```
add to the repository structure block under `Client/`:
```text
├── Client/udpscope/ Direct-UDPS ImGui oscilloscope (SDL2 + ImPlot)
```
add a component section after **StreamHub Application**:
```markdown
### UDPScope
Single-source bench oscilloscope (`Client/udpscope/`) that decodes UDPS
datagrams directly through `Common/Client/c` — no hub, no browser, one process.
Splittable pane grid, client-side Normal/Single trigger with a pre-trigger
window, per-trace division scaling, cursors and measurements, CSV export and a
saved session layout.
```bash
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
./build/UDPScope --host 127.0.0.1 --port 44500
```
See `Docs/UDPScope.md`.
```
and add to the documentation table:
```markdown
| `Docs/UDPScope.md` | Direct-UDPS bench oscilloscope user guide |
```
In `CLAUDE.md`, add to the build block after the Qt client lines:
```bash
# Direct-UDPS ImGui bench scope (not a MARTe2 component; needs SDL2)
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
./build/udpscope_tests
```
and a paragraph after the Qt client paragraph:
```markdown
**UDPScope** (`Client/udpscope/`): bench oscilloscope that talks UDPS directly
to a single `UDPStreamer` through the standalone C client, bypassing StreamHub
entirely. A receiver thread owns the C client and the trigger FSM; a
mutex-guarded `SignalStore` hands data to the GUI thread. Everything except
`main.cpp`, `App.cpp` and `PaneView.cpp` is framework-free and unit-tested in
`udpscope_tests`. It consumes `Client/streamhub/SignalBuffer.h` and the shared
fonts read-only and must never modify anything under `Client/streamhub/`.
```
In `ARCHITECTURE.md`, add UDPScope as a second consumer of the streaming path:
a client that attaches to `UDPStreamer` directly rather than through StreamHub,
with the trigger in the client instead of the hub.
In `.gitignore`, add:
```text
Client/udpscope/build/
```
- [ ] **Step 3: Check the documentation against the build**
```bash
cd Client/udpscope && rm -rf build \
&& cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j \
&& ./build/udpscope_tests
```
Expected: a clean tree builds from the commands as written, and every test
passes. Then walk `Docs/UDPScope.md` from top to bottom against the running
binary: every option in the table is accepted, every menu item named exists,
and the trigger badge shows the four documented strings.
- [ ] **Step 4: Commit**
```bash
git add Docs/UDPScope.md Client/udpscope/resources Client/udpscope/CMakeLists.txt \
README.md CLAUDE.md ARCHITECTURE.md .gitignore
git commit -m "docs: UDPScope bench oscilloscope"
```
---