The jitter test fed a receive timestamp that went backwards (1001.02 then 1000.97), putting the second reading 1.03 s from the prediction — twice the threshold, so not jitter under any reading. That is a digit slip for 1001.97. It had been worked around by making the threshold one-sided, which passes the test but never fires when the producer's clock steps forward: the prediction stays ahead of the wall clock, the error stays negative, and the trace sits in the future for the rest of the run. Restored std::fabs, corrected the test data, and added the forward-jump case that the one-sided version silently failed. Plan amended so the bad data does not come back. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10367 lines
321 KiB
Markdown
10367 lines
321 KiB
Markdown
# 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 1–8 build and test with no GUI code present at all, and disappears in Task 9 when `main.cpp` lands.
|
||
|
||
- [ ] **Step 6: Configure, then run the tests and watch them fail**
|
||
|
||
```bash
|
||
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j
|
||
```
|
||
|
||
The first configure downloads ImGui, ImPlot and GoogleTest, so it needs network access and takes a minute.
|
||
|
||
Then:
|
||
|
||
```bash
|
||
./build/udpscope_tests
|
||
```
|
||
|
||
Expected on a first run *before* `Decimate.cpp` is written: a link error for `MinMaxDecimate`. Since Steps 3–4 already wrote it, the expected result here is **PASS, 5 tests**. If you want to see the red first, comment out `Decimate.cpp` in `CORE_SOURCES`, rebuild, observe the undefined-reference failure, then restore it.
|
||
|
||
- [ ] **Step 7: Add the build directory to git ignore**
|
||
|
||
Create `Client/udpscope/.gitignore`:
|
||
|
||
```
|
||
build/
|
||
compile_commands.json
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/CMakeLists.txt Client/udpscope/.gitignore \
|
||
Client/udpscope/Types.h Client/udpscope/Decimate.h \
|
||
Client/udpscope/Decimate.cpp Client/udpscope/tests/DecimateTest.cpp
|
||
git commit -m "feat(udpscope): build scaffold and min/max envelope decimation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Pane tree
|
||
|
||
The BSP layout that the whole UI hangs off. Framework-free, so all the fiddly geometry is settled before a single ImGui call exists.
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/PaneTree.h`
|
||
- Create: `Client/udpscope/PaneTree.cpp`
|
||
- Create: `Client/udpscope/tests/PaneTreeTest.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt` (add `PaneTree.cpp` to `CORE_SOURCES`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `udpscope::Rect`, `udpscope::Color` from `Types.h` (Task 1).
|
||
- Produces:
|
||
- `enum class Orient { Columns, Rows }`
|
||
- `enum class VMode { Auto, Range, Manual }`
|
||
- `struct VScale { VMode mode; double div; double offset; }`
|
||
- `struct Assignment { std::string signalName; Color color; float lineWidth; VScale vs; }`
|
||
- `struct PaneNode { bool leaf; std::vector<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
|
||
|
||
// Arrival wanders either side of the prediction. wallSec is a local receive
|
||
// timestamp, so it only ever advances — jitter shows up as the gap growing
|
||
// and shrinking, never as the clock going backwards.
|
||
EXPECT_DOUBLE_EQ(off.map(11.0, 1001.02), 1001.0); // +0.02 late
|
||
EXPECT_DOUBLE_EQ(off.map(12.0, 1001.97), 1002.0); // -0.03 early
|
||
}
|
||
|
||
// The threshold has to be symmetric. A producer whose clock steps FORWARD (an
|
||
// NTP correction on the producer's host, say) puts the prediction permanently
|
||
// ahead of the wall clock — a one-sided "recalibrate only when wall is ahead"
|
||
// test never fires for it, and the trace sits in the future for the rest of the
|
||
// run.
|
||
TEST(ClockOffset, RecalibratesWhenTheProducerClockJumpsForward) {
|
||
ClockOffset off;
|
||
off.map(10.0, 1000.0); // offset = 990
|
||
|
||
// Producer leaps 100 s ahead while only 1 s of wall time passes.
|
||
EXPECT_DOUBLE_EQ(off.map(111.0, 1001.0), 1001.0);
|
||
}
|
||
|
||
TEST(ClockOffset, RecalibratesWhenDriftExceedsTheThreshold) {
|
||
ClockOffset off;
|
||
off.map(10.0, 1000.0); // offset = 990
|
||
|
||
/* Producer clock jumped (restart, re-phase): 5 s of error is not jitter. */
|
||
const double mapped = off.map(11.0, 1006.0);
|
||
EXPECT_DOUBLE_EQ(mapped, 1006.0);
|
||
}
|
||
|
||
TEST(ClockOffset, ResetForgetsTheCalibration) {
|
||
ClockOffset off;
|
||
off.map(10.0, 1000.0);
|
||
off.reset();
|
||
EXPECT_FALSE(off.valid());
|
||
EXPECT_DOUBLE_EQ(off.map(50.0, 2000.0), 2000.0);
|
||
}
|
||
|
||
// The tick rate of the producer's high-resolution timer is not carried by the
|
||
// protocol, and StreamHub's trick of using the local MARTe timer frequency only
|
||
// works on the producer's own host. Recover it from the data instead.
|
||
TEST(HrtRateFit, RecoversAKnownTickRate) {
|
||
HrtRateFit fit;
|
||
const double ticksPerSec = 2.5e9;
|
||
|
||
EXPECT_FALSE(fit.ready());
|
||
for (int i = 0; i < 64; i++) {
|
||
const double wall = 1000.0 + i * 0.01;
|
||
fit.add(static_cast<uint64_t>(wall * ticksPerSec), wall);
|
||
}
|
||
|
||
ASSERT_TRUE(fit.ready());
|
||
EXPECT_NEAR(fit.ticksPerSecond(), ticksPerSec, ticksPerSec * 1e-6);
|
||
}
|
||
|
||
TEST(HrtRateFit, IsNotReadyBeforeTheMinimumSampleCount) {
|
||
HrtRateFit fit;
|
||
for (size_t i = 0; i < HrtRateFit::kMinSamples - 1; i++) {
|
||
fit.add(static_cast<uint64_t>(i) * 1000000u, 1000.0 + i * 0.001);
|
||
}
|
||
EXPECT_FALSE(fit.ready());
|
||
|
||
fit.add(static_cast<uint64_t>(HrtRateFit::kMinSamples) * 1000000u,
|
||
1000.0 + HrtRateFit::kMinSamples * 0.001);
|
||
EXPECT_TRUE(fit.ready());
|
||
}
|
||
|
||
TEST(HrtRateFit, ToSecondsUsesTheFittedRate) {
|
||
HrtRateFit fit;
|
||
const double ticksPerSec = 1.0e9;
|
||
for (int i = 0; i < 64; i++) {
|
||
const double wall = 500.0 + i * 0.005;
|
||
fit.add(static_cast<uint64_t>(wall * ticksPerSec), wall);
|
||
}
|
||
ASSERT_TRUE(fit.ready());
|
||
EXPECT_NEAR(fit.toSeconds(2000000000ull), 2.0, 1e-4);
|
||
}
|
||
|
||
TEST(HrtRateFit, SurvivesAStalledClock) {
|
||
HrtRateFit fit;
|
||
for (int i = 0; i < 64; i++) {
|
||
fit.add(12345u, 1000.0 + i * 0.01); /* hrt never advances */
|
||
}
|
||
/* A degenerate fit must not produce a rate that would divide by zero. */
|
||
if (fit.ready()) {
|
||
EXPECT_GT(fit.ticksPerSecond(), 0.0);
|
||
}
|
||
}
|
||
|
||
TEST(TimeSignalScale, UsesNanosecondsForUint64AndMicrosecondsOtherwise) {
|
||
EXPECT_DOUBLE_EQ(TimeSignalScale(6 /* UDPS_T_UINT64 */), 1.0e-9);
|
||
EXPECT_DOUBLE_EQ(TimeSignalScale(9 /* UDPS_T_FLOAT64 */), 1.0e-6);
|
||
EXPECT_DOUBLE_EQ(TimeSignalScale(4 /* UDPS_T_UINT32 */), 1.0e-6);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
```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 1–5.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/Trigger.h Client/udpscope/Trigger.cpp \
|
||
Client/udpscope/tests/TriggerTest.cpp Client/udpscope/CMakeLists.txt
|
||
git commit -m "feat(udpscope): edge trigger FSM with fill gate and harvest margin"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: SignalStore — the one lock between the two threads
|
||
|
||
`SignalStore` owns every ring, the latest vector profiles, and the most
|
||
recent completed capture. The receiver thread writes; the GUI thread reads;
|
||
one `std::mutex` guards all of it. Nothing else in the program is shared
|
||
between the threads.
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/SignalStore.h`
|
||
- Create: `Client/udpscope/SignalStore.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt` (add `SignalStore.cpp` to `CORE_SOURCES`)
|
||
- Test: `Client/udpscope/tests/SignalStoreTest.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SignalMeta`, `Series` from `Types.h` (Task 1/4);
|
||
`StreamHubClient::SignalBuffer` from `../streamhub/SignalBuffer.h`
|
||
(already on the include path from Task 1).
|
||
- Produces:
|
||
|
||
```cpp
|
||
struct Profile { double time = 0.0; std::vector<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 1–7.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \
|
||
Client/udpscope/tests/ReceiverTest.cpp Client/udpscope/CMakeLists.txt
|
||
git commit -m "feat(udpscope): receiver frame path with trigger control and capture harvest"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Receiver — thread, C client wiring and profile overrides
|
||
|
||
Bolt the socket and the thread onto `Receiver`, translate the C structs into
|
||
the framework-free ones, and add the per-signal "this is a vector, not a
|
||
burst" override the spec requires for ambiguous `PACKET` signals. The task
|
||
ships `udpscope_rxprobe`, a console tool that runs the receiver against a
|
||
real streamer and prints the link counters — that is how the socket path is
|
||
verified, and it stays in the tree as a diagnostic.
|
||
|
||
**Files:**
|
||
- Modify: `Client/udpscope/Receiver.h` (thread, options, link status, overrides)
|
||
- Modify: `Client/udpscope/Receiver.cpp` (same)
|
||
- Create: `Client/udpscope/tools/rxprobe.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt` (add the `udpscope_rxprobe` target)
|
||
- Test: `Client/udpscope/tests/ReceiverLinkTest.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything from Task 7, plus the C API from
|
||
`Common/Client/c/udps_client.h` (`udps_client_config_init`,
|
||
`udps_client_create`, `udps_client_set_callbacks`, `udps_client_poll`,
|
||
`udps_client_destroy`, `udps_client_is_connected`, `udps_client_stats`,
|
||
`udps_client_last_error`).
|
||
- Produces:
|
||
|
||
```cpp
|
||
struct ReceiverOptions {
|
||
std::string host = "127.0.0.1";
|
||
uint16_t port = 44500u;
|
||
std::string multicastGroup; // empty = unicast
|
||
std::string interfaceAddr;
|
||
uint16_t dataPort = 0u;
|
||
double silenceTimeoutSec = 2.0;
|
||
};
|
||
|
||
struct LinkStatus {
|
||
bool running = false, connected = false, haveConfig = false;
|
||
uint64_t packets = 0u, frames = 0u, configUpdates = 0u;
|
||
uint64_t counterGaps = 0u, fragmentsDropped = 0u, reconnects = 0u;
|
||
double lastFrameWall = 0.0;
|
||
std::string lastEvent;
|
||
};
|
||
|
||
// added to Receiver:
|
||
bool start(const ReceiverOptions& opt, std::string& err);
|
||
void stop();
|
||
bool running() const;
|
||
LinkStatus link() const;
|
||
void setProfileOverride(const std::string& signalName, bool isProfile);
|
||
bool profileOverride(const std::string& signalName) const;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `Client/udpscope/tests/ReceiverLinkTest.cpp`:
|
||
|
||
```cpp
|
||
#include "Receiver.h"
|
||
#include <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 1–9.
|
||
|
||
```bash
|
||
# terminal 1
|
||
source env.sh && "${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \
|
||
-l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \
|
||
-s Running -m StateMachine:START
|
||
# terminal 2
|
||
./Client/udpscope/build/UDPScope --host 127.0.0.1 --port 44501
|
||
```
|
||
|
||
Expected: a window opens, the badge reads `CONNECTED`, the signal list fills
|
||
with the demo signals, hovering one shows a plausible rate and buffered span,
|
||
and the status bar counters climb with `gaps 0`. Ctrl+Q closes it.
|
||
|
||
```bash
|
||
./Client/udpscope/build/UDPScope --help # prints usage, exits 0
|
||
./Client/udpscope/build/UDPScope -host 1.2.3.4 # rejects, exits 2
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/Cli.h Client/udpscope/Cli.cpp Client/udpscope/App.h \
|
||
Client/udpscope/App.cpp Client/udpscope/SignalList.cpp \
|
||
Client/udpscope/main.cpp \
|
||
Client/udpscope/tests/CliTest.cpp Client/udpscope/CMakeLists.txt
|
||
git commit -m "feat(udpscope): CLI, SDL2/ImGui bootstrap and application shell"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Trace fetching and the first real plot
|
||
|
||
Split the plotting work in two: `PlotData` decides *what* points to draw and
|
||
is fully unit-tested against the store; `PaneView` is thin ImPlot glue that
|
||
draws them. This task draws the tree's single root leaf across the whole plot
|
||
area and lets the user drag signals onto it. Splitting arrives in Task 11.
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/PlotData.h`
|
||
- Create: `Client/udpscope/PlotData.cpp`
|
||
- Create: `Client/udpscope/PaneView.h`
|
||
- Create: `Client/udpscope/PaneView.cpp`
|
||
- Modify: `Client/udpscope/App.h`, `Client/udpscope/App.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt`
|
||
- Test: `Client/udpscope/tests/PlotDataTest.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SignalStore`, `Capture`, `Profile` (Task 6); `MinMaxDecimate`,
|
||
`Series`, `Color` (Task 1); `PaneNode`, `Assignment` (Task 2).
|
||
- Produces:
|
||
|
||
```cpp
|
||
struct TraceData {
|
||
Series raw; // every sample in range — statistics use this
|
||
Series draw; // decimated to the plot budget
|
||
bool found = false;
|
||
};
|
||
|
||
bool FetchLiveTrace(const SignalStore& store, const std::string& name,
|
||
double t0, double t1, size_t maxPoints, TraceData& out);
|
||
bool FetchCaptureTrace(const Capture& cap, const std::string& name,
|
||
double t0, double t1, size_t maxPoints, TraceData& out);
|
||
Color PaletteColor(size_t index);
|
||
|
||
struct PaneContext {
|
||
SignalStore* store = nullptr;
|
||
const Capture* capture = nullptr; // nullptr => live
|
||
double x0 = 0.0, x1 = 1.0;
|
||
size_t maxPoints = 4000u;
|
||
};
|
||
class PaneView {
|
||
public:
|
||
void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
|
||
PaneContext& ctx);
|
||
};
|
||
```
|
||
|
||
**Why `raw` is kept alongside `draw`:** spec §8.4 requires min/max/pp/mean/RMS
|
||
to be computed from undecimated data. Computing them from the decimated
|
||
series would report the extremes of the drawn envelope but a mean and RMS
|
||
weighted by bucket rather than by sample. Task 14 reads `TraceData::raw`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `Client/udpscope/tests/PlotDataTest.cpp`:
|
||
|
||
```cpp
|
||
#include "PlotData.h"
|
||
#include <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 1–10.
|
||
|
||
Run it against the demo streamer as in Task 9, then drag `Sine1` from the
|
||
list onto the plot. Expected: a 1 Hz sine scrolling right to left, smooth
|
||
(not a staircase), with the legend entry right-clickable for colour, width
|
||
and remove. Drag a second signal on and both draw with different colours.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
|
||
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
|
||
Client/udpscope/App.h Client/udpscope/App.cpp \
|
||
Client/udpscope/tests/PlotDataTest.cpp Client/udpscope/CMakeLists.txt
|
||
git commit -m "feat(udpscope): trace fetching, min/max decimation and the first live plot"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Splittable pane grid
|
||
|
||
Draw the whole tree instead of just the root leaf: every leaf gets its rect
|
||
from `PaneTree::layout()`, hovering one reveals four inset split handles and
|
||
a close ✕, and the shared borders drag to re-proportion the split.
|
||
|
||
**Files:**
|
||
- Modify: `Client/udpscope/PaneTree.h`, `Client/udpscope/PaneTree.cpp`
|
||
(deferred command application)
|
||
- Modify: `Client/udpscope/PaneView.h`, `Client/udpscope/PaneView.cpp`
|
||
(tree drawing and interaction)
|
||
- Modify: `Client/udpscope/App.cpp`
|
||
- Test: `Client/udpscope/tests/PaneCommandTest.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PaneTree`, `PaneNode`, `Rect`, `Orient`, `Handle`,
|
||
`kMinPaneSize`, `kSplitterGrab`, `kHandleSize` (Task 2); `PaneView`,
|
||
`PaneContext` (Task 10).
|
||
- Produces:
|
||
|
||
```cpp
|
||
// PaneTree.h
|
||
struct PaneCommand {
|
||
enum class Kind { None, Split, Close, SetRatio };
|
||
Kind kind = Kind::None;
|
||
PaneNode* target = nullptr;
|
||
Orient orient = Orient::Columns; // Split only
|
||
double ratio = 0.5; // SetRatio only
|
||
};
|
||
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd);
|
||
|
||
// PaneView.h
|
||
void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx);
|
||
```
|
||
|
||
**Why commands are deferred:** `layout()` hands out raw `PaneNode*`. Splitting
|
||
or closing during the walk re-parents nodes and invalidates those pointers
|
||
mid-frame. Collect at most one command per frame and apply it after the walk.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `Client/udpscope/tests/PaneCommandTest.cpp`:
|
||
|
||
```cpp
|
||
#include "PaneTree.h"
|
||
#include <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 1–11.
|
||
|
||
Run against the demo streamer and walk this checklist:
|
||
|
||
1. Hover a pane: five handles appear (four edge midpoints, ✕ top-right).
|
||
2. Click the right handle: two side-by-side panes, the new one empty.
|
||
3. Drag a signal onto the new pane: it plots there and not in the first.
|
||
4. Click the bottom handle of the right pane: it splits into two rows.
|
||
5. Drag the border between the columns: both resize, the pointer stays on the
|
||
border, and neither pane goes below roughly 80 px.
|
||
6. Click ✕ on the bottom-right pane: it closes and its sibling takes the space.
|
||
7. Close panes until one is left, then click its ✕: nothing happens.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
|
||
Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
|
||
Client/udpscope/App.cpp Client/udpscope/tests/PaneCommandTest.cpp
|
||
git commit -m "feat(udpscope): splittable pane grid with inset handles and draggable splitters"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Shared X axis and per-trace vertical scale
|
||
|
||
Two pieces of scope behaviour that are pure arithmetic and therefore fully
|
||
testable: the X axis that every pane shares and that follows the newest
|
||
sample until the user pans, and the per-trace vertical scale in the three
|
||
modes the spec calls for.
|
||
|
||
Every pane's Y axis is a fixed ±4 divisions, exactly like a bench scope, and
|
||
each trace is mapped into division space by its own volts-per-division and
|
||
offset. `Auto` and `Range` compute those two numbers; `Manual` takes them from
|
||
the user. This is what makes traces with different units share a pane without
|
||
either being squashed.
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/Axes.h`
|
||
- Create: `Client/udpscope/Axes.cpp`
|
||
- Modify: `Client/udpscope/App.h`, `Client/udpscope/App.cpp`
|
||
- Modify: `Client/udpscope/PaneView.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt`
|
||
- Test: `Client/udpscope/tests/AxesTest.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `VScale`, `VMode` (Task 2); `SignalMeta`, `Series` (Task 1).
|
||
- Produces:
|
||
|
||
```cpp
|
||
constexpr double kDivisionsY = 8.0; // full pane height, ±4
|
||
constexpr double kUsableDivsY = 6.0; // data fills 6 of the 8
|
||
|
||
void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs);
|
||
double ToDivisions(double value, const VScale& vs);
|
||
|
||
class XAxisController {
|
||
public:
|
||
void setSpan(double sec);
|
||
double span() const;
|
||
void setLive(bool live);
|
||
bool live() const;
|
||
void followNewest(double newest);
|
||
void userRange(double x0, double x1);
|
||
double x0() const;
|
||
double x1() const;
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `Client/udpscope/tests/AxesTest.cpp`:
|
||
|
||
```cpp
|
||
#include "Axes.h"
|
||
#include <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 1–12.
|
||
|
||
Against the demo streamer:
|
||
|
||
1. Two panes, a signal in each: both scroll together.
|
||
2. Drag left in one pane: both stop following and pan together; View → Live
|
||
is now unchecked.
|
||
3. Tick View → Live: both snap back to the newest sample.
|
||
4. Set View → window to 0.05: both show 50 ms, right edge pinned.
|
||
5. Right-click a legend entry, set v-scale to `range`: the trace rescales to
|
||
the CONFIG full scale and the label shows the new value per division.
|
||
6. Set it to `manual` with a small per-div: the trace clips off the top and
|
||
bottom of its ±4 divisions, as a bench scope does.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/Axes.h Client/udpscope/Axes.cpp \
|
||
Client/udpscope/App.h Client/udpscope/App.cpp Client/udpscope/PaneView.h \
|
||
Client/udpscope/PaneView.cpp Client/udpscope/tests/AxesTest.cpp \
|
||
Client/udpscope/CMakeLists.txt
|
||
git commit -m "feat(udpscope): shared X axis with live follow and per-trace division scaling"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Trigger bar and capture display
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/CaptureLatch.h`
|
||
- Create: `Client/udpscope/CaptureLatch.cpp`
|
||
- Create: `Client/udpscope/tests/CaptureLatchTest.cpp`
|
||
- Create: `Client/udpscope/TriggerBar.cpp`
|
||
- Modify: `Client/udpscope/App.h`
|
||
- Modify: `Client/udpscope/App.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SignalStore::captureSeq()`, `SignalStore::readCapture()`,
|
||
`Capture` (Task 6); `Receiver::setTrigConfig/trigConfig/arm/disarm/rearm/trigStatus`
|
||
and `TrigStatus` (Task 7); `TrigConfig`, `Edge`, `TrigMode`, `TrigState`
|
||
(Task 5); `PaneContext::capture` (Task 10); `XAxisController` (Task 12).
|
||
- Produces: `class CaptureLatch` with `poll(const SignalStore&) -> bool`,
|
||
`showing()`, `setFollow(bool)`, `follow()`, `returnToLive()`,
|
||
`capture() -> const Capture&`, `x0()`, `x1()`, `seen() -> uint64_t`; and
|
||
`std::string TrigBadge(const TrigStatus&)`. Task 16 exports
|
||
`CaptureLatch::capture()` to CSV.
|
||
|
||
**Why a latch rather than reading the store directly in the draw call:**
|
||
the pane tree draws many leaves per frame and each one needs the *same*
|
||
capture, so the copy out of the store must happen once per frame, not once
|
||
per pane. The latch also owns the freeze decision — a user studying one
|
||
capture must not have it swapped out from under them by the next trigger —
|
||
and that decision is pure state machine, so it is unit-testable without a
|
||
window.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `Client/udpscope/tests/CaptureLatchTest.cpp`:
|
||
|
||
```cpp
|
||
#include "CaptureLatch.h"
|
||
|
||
#include <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 1–13.
|
||
|
||
Against the demo streamer:
|
||
|
||
```bash
|
||
source env.sh
|
||
"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" -l RealTimeLoader \
|
||
-f Test/Configurations/streamhub_demo.cfg -s Running -m StateMachine:START &
|
||
./Client/udpscope/build/UDPScope --port 44501
|
||
```
|
||
|
||
1. Drop a sine into a pane; the badge reads `IDLE`.
|
||
2. Pick that signal in the trigger bar, threshold `0`, window `0.02`,
|
||
pre `20`, `Norm`, press **Arm**: the badge goes `ARMED 0%` → `ARMED 100%`
|
||
within a fraction of a second, then flashes `TRIG'D` on each trigger.
|
||
3. The pane freezes onto a stable waveform whose rising edge sits 20 % in
|
||
from the left, and the capture counter climbs.
|
||
4. Untick **follow**: the waveform stops updating while the counter keeps
|
||
climbing. Tick it again: it jumps straight to the newest capture.
|
||
5. Press **Live**: the pane scrolls again and the **Live** button greys out.
|
||
6. Switch to `1x` and press **Arm**: exactly one capture appears, the badge
|
||
settles on `HELD`, and **Re-arm** produces the next one.
|
||
7. Set the threshold above the sine's amplitude: the badge stays
|
||
`ARMED 100%` and no new captures arrive.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/App.h Client/udpscope/App.cpp \
|
||
Client/udpscope/TriggerBar.cpp Client/udpscope/CMakeLists.txt
|
||
git commit -m "feat(udpscope): trigger bar with arm/disarm and capture display"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Cursors and measurements
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/Measure.h`
|
||
- Create: `Client/udpscope/Measure.cpp`
|
||
- Create: `Client/udpscope/tests/MeasureTest.cpp`
|
||
- Modify: `Client/udpscope/PaneView.h`
|
||
- Modify: `Client/udpscope/PaneView.cpp`
|
||
- Modify: `Client/udpscope/App.h`
|
||
- Modify: `Client/udpscope/App.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Series` (Task 1), `TraceData::raw` (Task 10), `PaneContext`
|
||
(Tasks 10/12), `VScale`/`ToDivisions` (Task 12).
|
||
- Produces: `struct Stats`, `ComputeStats(const Series&, double t0, double t1)`,
|
||
`bool SampleAt(const Series&, double t, double& v)`, `struct Cursors`
|
||
(`enabled`, `tA`, `tB`, `dt()`, `freq()`). Task 15 persists `Cursors`.
|
||
|
||
**Why statistics come from `TraceData::raw`:** the drawn series is a min/max
|
||
envelope, two points per bucket. Its extremes are correct, but a mean or RMS
|
||
over it weights each bucket equally instead of each sample, so a burst of
|
||
1000 samples in one bucket would count the same as a bucket holding two.
|
||
Spec §8.4 requires true statistics, so `ComputeStats` reads the undecimated
|
||
series.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `Client/udpscope/tests/MeasureTest.cpp`:
|
||
|
||
```cpp
|
||
#include "Measure.h"
|
||
|
||
#include <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 1–14.
|
||
|
||
Against the demo streamer:
|
||
|
||
1. View → Cursors: two vertical lines appear a quarter and three quarters of
|
||
the way across every pane, and the status bar shows `A`, `B`, `dt`, `1/dt`.
|
||
2. Drag cursor A in one pane: it moves in every pane and the readout follows.
|
||
3. Put the cursors one sine period apart: `1/dt` reads the configured
|
||
frequency of the demo sine.
|
||
4. With cursors on, each pane already lists `A`, `B` and `dV` per trace in the
|
||
trace colour; drag a cursor past the end of the data and that trace's three
|
||
values become `-`.
|
||
5. View → Measurements: min/max/pp/avg/rms are appended to the same lines.
|
||
6. With cursors on, narrow them to the top half of a sine: `avg` rises and
|
||
`pp` shrinks, confirming the statistics track the cursor span rather than
|
||
the visible range.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
|
||
Client/udpscope/App.h Client/udpscope/App.cpp
|
||
git commit -m "feat(udpscope): shared time cursors and per-pane measurements"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Session persistence
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/Settings.h`
|
||
- Create: `Client/udpscope/Settings.cpp`
|
||
- Create: `Client/udpscope/tests/SettingsTest.cpp`
|
||
- Modify: `Client/udpscope/App.h`
|
||
- Modify: `Client/udpscope/App.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PaneNode`, `Assignment`, `VScale`, `VMode`, `Orient`,
|
||
`PaneTree::setRoot` (Task 2); `TrigConfig`, `Edge`, `TrigMode` (Task 5);
|
||
`ReceiverOptions` (Task 7); `CliOptions` and its `set*` flags,
|
||
`DefaultConfigPath()` (Task 9); `Cursors` (Task 14).
|
||
- Produces: `struct Session { ReceiverOptions source; TrigConfig trigger;
|
||
Cursors cursors; std::unique_ptr<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 1–15.
|
||
|
||
Against the demo streamer:
|
||
|
||
```bash
|
||
./Client/udpscope/build/UDPScope --port 44501
|
||
```
|
||
|
||
1. Split into three panes, drop different signals in each, set one to manual
|
||
v-scale, turn cursors on, configure the trigger. Quit.
|
||
2. `cat ~/.config/udpscope/session.conf` shows the documented shape.
|
||
3. Relaunch with no arguments: the same three panes, signals, colours,
|
||
v-scales, cursors and trigger settings come back, and it reconnects to
|
||
port 44501 because the file recorded it.
|
||
4. Relaunch with `--port 44502`: the layout is restored but the source is the
|
||
command-line one; the status bar shows the new port.
|
||
5. Append `bananas` to the file and relaunch: the status bar reports
|
||
`unknown keyword 'bananas'` and the app opens with a single empty pane —
|
||
nothing half-applied.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/App.h Client/udpscope/App.cpp \
|
||
Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
|
||
Client/udpscope/tests/PaneTreeTest.cpp
|
||
git commit -m "feat(udpscope): restore and save the session layout"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: CSV export
|
||
|
||
**Files:**
|
||
- Create: `Client/udpscope/Export.h`
|
||
- Create: `Client/udpscope/Export.cpp`
|
||
- Create: `Client/udpscope/tests/ExportTest.cpp`
|
||
- Modify: `Client/udpscope/App.h`
|
||
- Modify: `Client/udpscope/App.cpp`
|
||
- Modify: `Client/udpscope/CMakeLists.txt`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Series` (Task 1), `PaneNode` (Task 2), `SignalStore` and
|
||
`Capture` (Task 6), `FetchLiveTrace`/`FetchCaptureTrace` (Task 10),
|
||
`CaptureLatch` (Task 13).
|
||
- Produces: `struct CsvTrace { std::string name; Series data; }`,
|
||
`std::string BuildCsv(const std::vector<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 1–16.
|
||
|
||
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 6–8 already store and override
|
||
those signals; nothing draws them yet and nothing lets the user flip the
|
||
interpretation. This closes both.
|
||
|
||
**Files:**
|
||
- Modify: `Client/udpscope/PlotData.h`
|
||
- Modify: `Client/udpscope/PlotData.cpp`
|
||
- Modify: `Client/udpscope/PaneView.cpp`
|
||
- Modify: `Client/udpscope/SignalList.cpp`
|
||
- Modify: `Client/udpscope/tests/PlotDataTest.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SignalStore::readProfile`, `Profile` (Task 6);
|
||
`Receiver::setProfileOverride`/`profileOverride` (Task 8);
|
||
`SignalMeta::isVectorProfile()` (Task 4); `PaneNode::profilePane` (Task 2);
|
||
`TraceData` (Task 10).
|
||
- Produces: `bool FetchProfileTrace(const SignalStore&, const std::string&,
|
||
TraceData&, double& stamp)`.
|
||
|
||
**Why a profile gets a whole pane rather than sharing one:** its X axis is
|
||
element index, not seconds. Mixing it with a time trace in the same pane would
|
||
put two incompatible units on one axis. `PaneNode::profilePane` already exists
|
||
for exactly this, and `PaneTree` already propagates it through splits and
|
||
closes; this task is the first code that reads it.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `Client/udpscope/tests/PlotDataTest.cpp`:
|
||
|
||
```cpp
|
||
TEST(FetchProfileTrace, PlotsTheVectorAgainstElementIndex) {
|
||
SignalStore store;
|
||
std::vector<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 1–17.
|
||
|
||
Against the demo streamer, whose `SineArrayGAM` produces a packed array:
|
||
|
||
1. Drop the array signal into a pane: it is unrolled onto the time axis and
|
||
looks like a continuous sine, which is the correct default.
|
||
2. Right-click it in the signal list, tick **plot against element index**: the
|
||
status bar confirms `vector profile`.
|
||
3. Drop it into an empty pane: the X axis now reads `element`, the trace has
|
||
exactly `NumberOfElements` points, and the legend shows the snapshot time
|
||
ticking forward.
|
||
4. Try to drop a scalar into that same pane: the drop is refused, because the
|
||
axes are incompatible.
|
||
5. Untick the override: dropping it into a fresh pane gives a time trace again.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
|
||
Client/udpscope/PaneView.cpp Client/udpscope/SignalList.cpp \
|
||
Client/udpscope/tests/PlotDataTest.cpp
|
||
git commit -m "feat(udpscope): index-plot vector profiles and toggle the array interpretation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Documentation and repository integration
|
||
|
||
**Files:**
|
||
- Create: `Docs/UDPScope.md`
|
||
- Create: `Client/udpscope/resources/udpscope.desktop`
|
||
- Create: `Client/udpscope/resources/icons/udpscope.svg`
|
||
- Modify: `Client/udpscope/CMakeLists.txt`
|
||
- Modify: `README.md`
|
||
- Modify: `CLAUDE.md`
|
||
- Modify: `ARCHITECTURE.md`
|
||
- Modify: `.gitignore`
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything built in Tasks 1–17. No new code.
|
||
|
||
- [ ] **Step 1: Write `Docs/UDPScope.md`**
|
||
|
||
Create `Docs/UDPScope.md`:
|
||
|
||
````markdown
|
||
# UDPScope
|
||
|
||
A bench oscilloscope that attaches directly to one `UDPStreamer`. No StreamHub,
|
||
no WebSocket, no browser: the UDPS datagrams go straight into the scope through
|
||
the standalone C client in `Common/Client/c`.
|
||
|
||
Use it when you want to look at a signal now — on a control-room machine, over
|
||
a lab network, on a host that has nothing installed. Use StreamHub instead when
|
||
you need several sources aggregated, history on disk, or more than one client
|
||
watching at once.
|
||
|
||
## Build
|
||
|
||
Needs SDL2 and OpenGL; ImGui, ImPlot and GoogleTest are fetched by CMake.
|
||
|
||
```bash
|
||
cd Client/udpscope
|
||
cmake -B build -DCMAKE_BUILD_TYPE=Release
|
||
cmake --build build -j
|
||
./build/udpscope_tests # unit tests
|
||
```
|
||
|
||
## Run
|
||
|
||
```bash
|
||
./build/UDPScope --host 192.168.1.10 --port 44500
|
||
```
|
||
|
||
| Option | Default | Meaning |
|
||
|---|---|---|
|
||
| `--host ADDR` | `127.0.0.1` | Streamer control address |
|
||
| `--port N` | `44500` | Streamer control port |
|
||
| `--multicast GROUP` | — | Join this group instead of unicast |
|
||
| `--iface ADDR` | — | Local interface IP for the multicast join |
|
||
| `--data-port N` | `0` | Data port when the streamer separates it |
|
||
| `--silence SEC` | `2.0` | Reconnect after this long without a packet |
|
||
| `--config PATH` | `$XDG_CONFIG_HOME/udpscope/session.conf` | Session file |
|
||
| `--max-mpts N` | `4000` | Drawn points per trace before decimation |
|
||
|
||
Long `--` options only, matching `Common/Client/c/example/udps_dump.c`. An
|
||
option given on the command line beats the session file; anything omitted comes
|
||
from the file.
|
||
|
||
## Panes
|
||
|
||
The plot area is a splittable grid. Hover a pane to reveal its handles:
|
||
|
||
- the four edge handles split it left/right/top/bottom;
|
||
- the ✕ closes it and gives its space back to its sibling;
|
||
- the border between two panes is a splitter you can drag.
|
||
|
||
Drag a signal from the list on the left into a pane to plot it. Right-click a
|
||
legend entry for colour, line width and vertical scale; the same menu removes
|
||
the trace.
|
||
|
||
## Vertical scale
|
||
|
||
Panes are eight divisions tall, ±4 about the centre line, so traces in
|
||
different units share a pane without lying about each other's amplitude. Each
|
||
trace has its own volts-per-division:
|
||
|
||
- **auto** — fits the samples currently on screen;
|
||
- **range** — uses the `range_min`/`range_max` the CONFIG packet carries;
|
||
- **manual** — you set per-division and offset, as on a bench scope.
|
||
|
||
The legend shows the value per division for each trace.
|
||
|
||
## Time axis
|
||
|
||
Every pane shares one X axis. In live mode it follows the newest sample; any
|
||
pan or zoom detaches it, and View → Live re-attaches. View → window sets the
|
||
span in seconds.
|
||
|
||
## Trigger
|
||
|
||
The trigger bar configures a client-side trigger: signal, edge, threshold,
|
||
hysteresis, window length, and where in that window the trigger point sits
|
||
(`pre %`).
|
||
|
||
| Mode | Behaviour |
|
||
|---|---|
|
||
| **Norm** | Re-arms after every capture; the display holds the last one until the next trigger |
|
||
| **1x** | Captures once and stays held until you press Re-arm |
|
||
|
||
The badge reads `IDLE`, `ARMED nn%`, `TRIG'D` or `HELD`. The percentage is the
|
||
pre-trigger window filling up: the scope refuses to arm until it holds enough
|
||
history to back-fill the part of the capture that precedes the trigger, so a
|
||
capture never starts mid-waveform.
|
||
|
||
Untick **follow** to study one capture while later ones go by; tick it again
|
||
and the newest capture appears at once. **Live** drops the capture and goes
|
||
back to the rolling display.
|
||
|
||
Known simplification: with `edge = both` and a non-zero hysteresis, re-arming
|
||
uses the rising-edge arm level for both directions. Set hysteresis to 0 if you
|
||
need symmetric behaviour on a noisy bipolar signal.
|
||
|
||
## Cursors and measurements
|
||
|
||
View → Cursors puts two draggable time cursors in every pane at once; the
|
||
status bar reads `A`, `B`, `dt` and `1/dt`. View → Measurements overlays
|
||
min/max/peak-to-peak/average/RMS per trace, taken between the cursors when they
|
||
are up and over the visible range otherwise.
|
||
|
||
All statistics come from the undecimated samples, never from the drawn
|
||
envelope, so a single-sample spike is counted even when it is not individually
|
||
visible.
|
||
|
||
## Array signals
|
||
|
||
A signal with `NumberOfElements > 1` is a packed burst by default: its elements
|
||
are unrolled onto the time axis using the accompanying time signal or the
|
||
declared sampling rate. A signal that is genuinely a vector — a spatial
|
||
profile, not a burst — is plotted against element index instead. Toggle the
|
||
interpretation from the signal list's context menu; the choice survives a
|
||
CONFIG re-send.
|
||
|
||
## Export
|
||
|
||
File → Export CSV writes long format:
|
||
|
||
```
|
||
signal,time_s,wallclock_s,value
|
||
Voltage,0.000000000,1756291200.123456,0.4981
|
||
```
|
||
|
||
`time_s` is measured from the start of the exported window; `wallclock_s` is
|
||
absolute. One row per sample per signal, because signals carry independent
|
||
timestamps and a wide format would need resampling. Export covers every
|
||
assigned signal, or just the pane under the pointer.
|
||
|
||
## Session file
|
||
|
||
Layout, colours, vertical scales, trigger settings, cursors and the source are
|
||
saved on exit and from File → Save Layout, to
|
||
`$XDG_CONFIG_HOME/udpscope/session.conf`. The format is line-based and
|
||
hand-editable; indentation is decorative. A malformed file is reported in the
|
||
status bar and ignored outright, never partially applied.
|
||
|
||
## Diagnostics
|
||
|
||
The status bar counts packets, frames, counter gaps, dropped fragments and
|
||
reconnects; the last three turn red as soon as they are non-zero.
|
||
|
||
`udpscope_rxprobe` is a headless build of the same receive path. When the GUI
|
||
shows nothing, run it to find out whether the problem is the network or the
|
||
scope:
|
||
|
||
```bash
|
||
./build/udpscope_rxprobe --host 192.168.1.10 --port 44500
|
||
```
|
||
|
||
Cross-check against the reference C client if they disagree:
|
||
|
||
```bash
|
||
cd Common/Client/c && make && ./udps_dump 192.168.1.10 44500
|
||
```
|
||
|
||
## Relationship to StreamHub
|
||
|
||
| | UDPScope | StreamHub clients |
|
||
|---|---|---|
|
||
| Sources | One streamer | Many, aggregated |
|
||
| Transport | UDPS direct | UDPS → hub → WebSocket |
|
||
| History | In-memory rings only | Disk-backed `.shist` files |
|
||
| Trigger | In the client | In the hub, shared by clients |
|
||
| Processes | One | Hub plus client |
|
||
|
||
The two share the wire format (`Common/UDP/UDPSProtocol.h`) and nothing else.
|
||
UDPScope reads `Client/streamhub/SignalBuffer.h` and the shared fonts
|
||
read-only; it never modifies anything under `Client/streamhub/`.
|
||
````
|
||
|
||
- [ ] **Step 1b: Add the desktop entry and icon**
|
||
|
||
Spec §12 asks for install rules covering the `.desktop` entry and icon, which
|
||
Tasks 1–17 left out because there was nothing to install them from. UDPScope
|
||
needs its own rather than reusing `Client/streamhub/resources/`, which is
|
||
read-only to this project.
|
||
|
||
Create `Client/udpscope/resources/udpscope.desktop`:
|
||
|
||
```ini
|
||
[Desktop Entry]
|
||
Type=Application
|
||
Name=UDPScope
|
||
GenericName=Signal Oscilloscope
|
||
Comment=Bench oscilloscope attached directly to a MARTe2 UDPStreamer
|
||
Exec=UDPScope
|
||
Icon=udpscope
|
||
Terminal=false
|
||
Categories=Science;Engineering;DataVisualization;
|
||
Keywords=MARTe2;oscilloscope;UDPS;signals;
|
||
StartupWMClass=UDPScope
|
||
```
|
||
|
||
Create `Client/udpscope/resources/icons/udpscope.svg` — a trace on a graticule,
|
||
in the same Catppuccin palette the app uses:
|
||
|
||
```xml
|
||
<?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"
|
||
```
|
||
|
||
---
|
||
|