Compare commits
122
Commits
0.2.1
...
4235ee6225
No files matched your search
@@ -1 +1,2 @@
|
|||||||
build/
|
build/
|
||||||
|
__pycache__/
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Final Fixes Report
|
|
||||||
|
|
||||||
## Fix 1 — `known_sources()` round-trip check
|
|
||||||
**File:** `src/layout_store.cpp`
|
|
||||||
|
|
||||||
Changed the port parse to capture the end pointer and check `*end != '\0'`, then added a round-trip guard: `sanitize(host) + "_" + std::to_string(port) != stem` rejects any filename that `layout_path()` could not have written. This prevents `foo_8000x.toml` from being listed as `foo:8000` (whose `×` would unlink `foo_8000.toml`) and `_8000.toml` from yielding an empty host (whose `sanitize("")` round-trips to `__8000`). Also fixed include order (`<cstdio>` before `<cstdlib>` — Fix 6) and updated the `multicast`/`interface` comment to state the TOML rule directly rather than pointing at an adjacent comment (Fix 5).
|
|
||||||
|
|
||||||
## Fix 2 — use-after-free: form outliving chooser, unguarded timeout
|
|
||||||
**File:** `src/source_dialog.cpp`
|
|
||||||
|
|
||||||
(a) Added form destruction at the top of `chooser_finish()`: null the pointer first (so `close-request` cannot re-enter), then `gtk_window_destroy()` the form. Any path that finishes the chooser while the form is up — a row click, Escape — previously left a window with four handlers bound to a dead stack frame.
|
|
||||||
|
|
||||||
(b) Stored the id from `g_timeout_add()` for `pick_probe` in `pick_probe_id` and called `g_source_remove(pick_probe_id)` after `g_main_loop_run()` returns (guarded on non-zero). A row picked before the timeout fires would otherwise leave an armed source on the default main context pointing at the gone frame.
|
|
||||||
|
|
||||||
## Fix 3 — false comment on empty-list placeholder
|
|
||||||
**File:** `src/source_dialog.cpp`
|
|
||||||
|
|
||||||
Replaced the false "Not a GtkListBoxRow, so it cannot be activated..." comment with the truth: GTK4 auto-wraps any non-row widget, but the wrapped row carries no `"source-index"` data, so `g_object_get_data()` returns null and `on_row_activated`'s bounds check refuses it.
|
|
||||||
|
|
||||||
## Fix 4 — unreachable duplicate branch in `pick_probe()`
|
|
||||||
**File:** `src/source_dialog.cpp`
|
|
||||||
|
|
||||||
Removed the second `row == nullptr` guard; the `c.known.empty()` guard above it was the only case that produced a null row from the real list. Two identical print statements for one cause made the probe output ambiguous.
|
|
||||||
|
|
||||||
## Fix 5 — cross-reference comment in `save_layout()`
|
|
||||||
**File:** `src/layout_store.cpp`
|
|
||||||
|
|
||||||
Replaced "for the same reason as `enabled` below" with the rule itself: a bare TOML key written after a `[[table]]` header belongs to that table, with `enabled` named as the historical example of where this lost a key.
|
|
||||||
|
|
||||||
## Fix 6 — include ordering
|
|
||||||
**File:** `src/layout_store.cpp`
|
|
||||||
|
|
||||||
Swapped `<cstdlib>` and `<cstdio>` back into alphabetical order.
|
|
||||||
|
|
||||||
## Fix 7 — CLAUDE.md probe text
|
|
||||||
**File:** `CLAUDE.md`
|
|
||||||
|
|
||||||
Fixed `picked nothing` → `picked nothing: the list is empty` to match the exact stderr output. Fix 4 did not change the emitted text, so no further adjustment needed.
|
|
||||||
|
|
||||||
## Fix 8 — dead-port run must not overwrite a configured session
|
|
||||||
**Files:** `src/ui.cpp`, `CLAUDE.md`, `README.md`
|
|
||||||
|
|
||||||
Added `if (app.model->signals().empty()) return;` in `save_layout_now()`, immediately after the `source_port == 0` guard. A run that receives no CONFIG has an empty signal list and must not overwrite a file that holds real signal names — doing so produces `signals = ["", ...]`, which the chooser's filter refuses, silently removing the source from the list. Also deleted all 116 flattened `~/.config/udpscope/*.toml` files (confirmed all had no named signal). Updated CLAUDE.md (chooser invariant section) and README.md (source-chooser paragraph) to document the rule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### 1. Build
|
|
||||||
```
|
|
||||||
cmake --build build
|
|
||||||
```
|
|
||||||
Output: clean, only the three pre-existing warnings (unused `output_built`, two missing-field-initializer on `MathNode`).
|
|
||||||
|
|
||||||
### 2. `/tmp/check_sources` (two new assertions added)
|
|
||||||
```
|
|
||||||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources
|
|
||||||
/tmp/check_sources
|
|
||||||
```
|
|
||||||
Output:
|
|
||||||
```
|
|
||||||
a session with no named signal is not listed ok
|
|
||||||
newest first ok
|
|
||||||
oldest last ok
|
|
||||||
the stored group comes back ok
|
|
||||||
the row and signal counts are the file's ok
|
|
||||||
the filename splits at the last underscore ok
|
|
||||||
forget_source reports success ok
|
|
||||||
forget_source removes exactly one ok
|
|
||||||
foo_8000x.toml is not listed (trailing 'x' in port) ok
|
|
||||||
_8000.toml is not listed (empty host does not round-trip) ok
|
|
||||||
all ok
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. `/tmp/check_layout_rt`
|
|
||||||
```
|
|
||||||
/tmp/check_layout_rt
|
|
||||||
```
|
|
||||||
Output: all 11 assertions `ok`.
|
|
||||||
|
|
||||||
### 4. End-to-end chooser + scope run
|
|
||||||
```
|
|
||||||
UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 ./build/udpscope
|
|
||||||
```
|
|
||||||
Relevant output:
|
|
||||||
```
|
|
||||||
[sources] picked 127.0.0.1:8000 multicast= interface=
|
|
||||||
[udpscope] layout loaded from /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
|
||||||
[udpscope] frames=817 packets=818 gaps=0 channels=1 span=1677.722s stride=1
|
|
||||||
[udpscope] layout over 476 frames: axes.x 135..147, axes.w 722..734, viewport.w 870..870, y-gutter 88..100, x-ticks 20..21, x-desync 0
|
|
||||||
[udpscope] trace ink: 406 paints checked, 0 collapsed, 0 staircase, 0 short, worst cover 2.5%
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Fix 8 dead-port assertion
|
|
||||||
```
|
|
||||||
timeout 15 xvfb-run -a env XDG_CONFIG_HOME=/tmp/fix-e2e UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --server 127.0.0.1 --port 8000
|
|
||||||
cat /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
|
||||||
```
|
|
||||||
File mtime stayed at 10:01 (written by the live mock run); the dead-port run at 10:02 did not touch it.
|
|
||||||
File contents: `signals = ["sine", "", "", ""]` — the configured signal name was preserved.
|
|
||||||
@@ -78,6 +78,55 @@ add_custom_command(
|
|||||||
COMMENT "Compiling udpscope help resources"
|
COMMENT "Compiling udpscope help resources"
|
||||||
VERBATIM)
|
VERBATIM)
|
||||||
|
|
||||||
|
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
|
||||||
|
# The third bundle, for the same reason as the other two: `.ui` files are data, so they are compiled
|
||||||
|
# in rather than looked for at runtime -- and `preprocess="xml-stripblanks"` makes a malformed one a
|
||||||
|
# build failure with a file and line number, instead of a window that silently refuses to open.
|
||||||
|
set(UDPSCOPE_UI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/ui)
|
||||||
|
file(GLOB UDPSCOPE_UI_FILES CONFIGURE_DEPENDS ${UDPSCOPE_UI_DIR}/*.ui)
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
|
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||||
|
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
|
--sourcedir=${UDPSCOPE_UI_DIR}
|
||||||
|
--c-name=udpscope_ui
|
||||||
|
--generate-source
|
||||||
|
${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml
|
||||||
|
DEPENDS ${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml ${UDPSCOPE_UI_FILES}
|
||||||
|
COMMENT "Compiling udpscope UI resources"
|
||||||
|
VERBATIM)
|
||||||
|
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
|
||||||
|
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
|
||||||
|
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon and
|
||||||
|
# help bundles are small enough per file never to need it), so the build stays at its four warnings.
|
||||||
|
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
|
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
||||||
|
|
||||||
|
# --- the UI files: an opt-in check, never part of `cmake --build` ---------------
|
||||||
|
# The interface is hand-written GtkBuilder XML, and nothing in the build sees a mistake in it: a
|
||||||
|
# property a class does not have is a `Gtk-ERROR` when the window is built, and `Gtk-ERROR` *aborts
|
||||||
|
# the process* -- so a typo takes the application down rather than failing to open a dialog.
|
||||||
|
# `xml-stripblanks` catches malformed XML and nothing else, and the probes catch the rest only for the
|
||||||
|
# windows they open.
|
||||||
|
#
|
||||||
|
# blueprint-compiler type-checks every property and child type against the GIR, and it reads XML as
|
||||||
|
# well as blueprint (`decompile` turns one into the other), so `tools/check_ui.py` round-trips each
|
||||||
|
# file through it. The XML stays the source of truth and stays hand-written; nothing here is needed to
|
||||||
|
# build the application, which is the whole point of not converting the files.
|
||||||
|
#
|
||||||
|
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
|
||||||
|
# blueprint-compiler is not a build dependency.
|
||||||
|
find_program(UDPSCOPE_PYTHON NAMES python3)
|
||||||
|
if(UDPSCOPE_PYTHON)
|
||||||
|
add_custom_target(test-ui
|
||||||
|
COMMAND ${UDPSCOPE_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py ${UDPSCOPE_UI_DIR}
|
||||||
|
DEPENDS ${UDPSCOPE_UI_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py
|
||||||
|
COMMENT "Checking the UI files with blueprint-compiler"
|
||||||
|
VERBATIM)
|
||||||
|
else()
|
||||||
|
message(WARNING "python3 was not found: the test-ui target is not available")
|
||||||
|
endif()
|
||||||
|
|
||||||
# --- build info: version, commit and timestamp compiled into the binary -------
|
# --- build info: version, commit and timestamp compiled into the binary -------
|
||||||
# Written at configure time so add_executable can name the file, and again on every
|
# Written at configure time so add_executable can name the file, and again on every
|
||||||
# build so the commit and the timestamp belong to the binary that is running rather
|
# build so the commit and the timestamp belong to the binary that is running rather
|
||||||
@@ -100,15 +149,21 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
|
|||||||
add_executable(udpscope
|
add_executable(udpscope
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
${UDPSCOPE_BUILD_INFO}
|
${UDPSCOPE_BUILD_INFO}
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/config.cpp
|
src/config.cpp
|
||||||
src/channel.cpp
|
src/channel.cpp
|
||||||
src/model.cpp
|
src/model.cpp
|
||||||
|
src/condition.cpp
|
||||||
src/trigger.cpp
|
src/trigger.cpp
|
||||||
|
src/capture.cpp
|
||||||
|
src/waveform.cpp
|
||||||
|
src/event_store.cpp
|
||||||
src/acquisition.cpp
|
src/acquisition.cpp
|
||||||
src/plot_host.cpp
|
src/plot_host.cpp
|
||||||
src/export.cpp
|
src/export.cpp
|
||||||
|
src/search.cpp
|
||||||
src/layout_store.cpp
|
src/layout_store.cpp
|
||||||
src/source_dialog.cpp
|
src/source_dialog.cpp
|
||||||
src/measure.cpp
|
src/measure.cpp
|
||||||
@@ -116,7 +171,19 @@ add_executable(udpscope
|
|||||||
src/status_text.cpp
|
src/status_text.cpp
|
||||||
src/shortcuts.cpp
|
src/shortcuts.cpp
|
||||||
src/help_window.cpp
|
src/help_window.cpp
|
||||||
|
src/settings.cpp
|
||||||
|
src/ui_prefs.cpp
|
||||||
src/ui.cpp
|
src/ui.cpp
|
||||||
|
src/ui_probe.cpp
|
||||||
|
src/ui_widgets.cpp
|
||||||
|
src/ui_events.cpp
|
||||||
|
src/ui_status.cpp
|
||||||
|
src/ui_measure.cpp
|
||||||
|
src/ui_cursors.cpp
|
||||||
|
src/ui_math.cpp
|
||||||
|
src/ui_layout.cpp
|
||||||
|
src/ui_trigger.cpp
|
||||||
|
src/ui_signals.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A GTK4 oscilloscope-style viewer for a UDPStreamer source: it receives one stream
|
A GTK4 oscilloscope-style viewer for a UDPStreamer source: it receives one stream
|
||||||
with [`deps/udpsclient`](deps/udpsclient), buffers it in two identical large
|
with [`deps/udpsclient`](deps/udpsclient), buffers it in two identical large
|
||||||
circular buffers, and draws it with [`deps/plotter`](deps/plotter).
|
circular buffers, and draws it with [`deps/plotter`](https://git.ferrarinas.it/martino/plotter).
|
||||||
|
|
||||||
`SPECS.md` is the requirement document; this file describes what was built.
|
`SPECS.md` is the requirement document; this file describes what was built.
|
||||||
|
|
||||||
@@ -26,7 +26,37 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
|
|||||||
(size of the sample buffer, default 200), `--max-window` (largest time
|
(size of the sample buffer, default 200), `--max-window` (largest time
|
||||||
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
|
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
|
||||||
`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
|
`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
|
||||||
added and removed from the **Layout editor** afterwards).
|
added and removed from the **Layout editor** afterwards), `--relative-time` (see below),
|
||||||
|
`--capture-packets N` (see "Packet capture" below).
|
||||||
|
|
||||||
|
**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The
|
||||||
|
buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about
|
||||||
|
a second per gigabyte, mostly first-touch page faults --- and it happens on the producer thread
|
||||||
|
holding the lock the frame tick also takes. It is therefore a real freeze and not a slow paint. Two
|
||||||
|
things follow. First, a rebuild whose plan describes the storage already allocated **clears** the
|
||||||
|
store instead of allocating a new one, which is what most structural changes are: startup used to ask
|
||||||
|
for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at `--buffer-mb 1000`), and
|
||||||
|
every enable of a signal is applied in one batch rather than re-planning per signal. Second, the
|
||||||
|
window is **presented before** the buffer is planned and carries a notice naming what it is waiting
|
||||||
|
for --- `connecting to 127.0.0.1:8000 — planning the 4000 MB buffer` --- because the remaining second
|
||||||
|
or so at a multi-gigabyte buffer is unavoidable, and a blank window that says nothing is the part of
|
||||||
|
it that reads as a hang. Each allocation also names itself on stderr
|
||||||
|
(`[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms`), so a run that prints a column of them
|
||||||
|
is a re-plan that should have been coalesced.
|
||||||
|
|
||||||
|
**And a loading window covers the gap the status line cannot.** That notice only exists once there is
|
||||||
|
a window to put it in, and a run started from the **source chooser** has none: the chooser destroys
|
||||||
|
its own window the moment a row is clicked, and what follows --- the first allocation, the wait for
|
||||||
|
the stream's CONFIG or the read of a recording, and then the whole UI being built --- runs with no
|
||||||
|
main loop at all. The screen was simply empty for it, which at a multi-gigabyte buffer is the ten
|
||||||
|
seconds that read as "the dialog disappeared and nothing happened". A small window now stands in that
|
||||||
|
gap, raised before the buffer is planned and taken down only once the real window has been presented.
|
||||||
|
It names the wait in words --- `Connecting to 127.0.0.1:8000` over `planning the 4000 MB sample
|
||||||
|
buffer`, or `Opening rt.npy` over `reading the recording` --- because nothing can animate while the
|
||||||
|
work runs: its spinner is frozen for the duration, a progress bar would have nothing honest to show,
|
||||||
|
and it carries no close button for an application that could not yet service one. A source named with
|
||||||
|
`--server` or a file named with `--open` never sees it: those runs never had a window to lose, and the
|
||||||
|
notice after the present is the right place for them.
|
||||||
|
|
||||||
Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name,
|
Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name,
|
||||||
element count, rate, time mode and time-signal index -- because a signal that
|
element count, rate, time mode and time-signal index -- because a signal that
|
||||||
@@ -108,6 +138,14 @@ export's second column reads — and it fixed a latent bug on the way, the expor
|
|||||||
having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before
|
having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before
|
||||||
the first latch.
|
the first latch.
|
||||||
|
|
||||||
|
**An axis with nothing bound to it is still `[-window, 0]`.** "No samples yet" is the state every
|
||||||
|
structural change leaves behind — opening an event or a waveform re-plans the rings, which drops
|
||||||
|
their history, and the axis is then framed before the first sample of the new view lands. Framing it
|
||||||
|
at `[0, window]` there (which is what it did) offsets the view by a whole window under the x link
|
||||||
|
group's follow rule, so the two or three frames after every open drew the time base one screen to the
|
||||||
|
left of where the following frames drew it: a trace that jumps sideways and comes back, on every open.
|
||||||
|
The empty axis is now drawn in the same place as the full one.
|
||||||
|
|
||||||
Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate.
|
Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate.
|
||||||
A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows
|
A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows
|
||||||
under it, which is a bench scope's roll behaviour and is free here, the cursors already being
|
under it, which is a bench scope's roll behaviour and is free here, the cursors already being
|
||||||
@@ -207,6 +245,23 @@ to reject noise with, and counting both edges halves the period.
|
|||||||
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
||||||
signal in this window, and a level in volts would have to be typed for every signal.
|
signal in this window, and a level in volts would have to be typed for every signal.
|
||||||
|
|
||||||
|
Two more options ride on it, both at their old behaviour by default:
|
||||||
|
|
||||||
|
| option | what it is | unit | off |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `noise` | the hysteresis band's half-width, as the next section describes | % of the window's peak-to-peak | `0` disables the band; the default `10` is the band the app has always counted with |
|
||||||
|
| `hf` | a one-pole low-pass over the window, before *any* statistic | Hz corner | `0` is off |
|
||||||
|
|
||||||
|
The band is clamped to half the room between the split and the nearer extreme, so a 50 % band cannot invert
|
||||||
|
the thresholds. `hf` is the **one** place where a readout does not quote a stored sample: with `hf > 0` the
|
||||||
|
extremes, the mean and the levels come from a filtered copy of the window, so a ruler can sit at a level the
|
||||||
|
drawn trace -- which is raw -- never reaches. That is what filtering *is*; it must not be "fixed" by snapping
|
||||||
|
the value back to the nearest sample, and with `hf = 0` the old rule holds exactly.
|
||||||
|
|
||||||
|
Both cost: a filtered window disables the two bucket fast paths, because a bucket is an aggregate of *raw*
|
||||||
|
samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every
|
||||||
|
measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five.
|
||||||
|
|
||||||
### Why a crossing is counted with hysteresis
|
### Why a crossing is counted with hysteresis
|
||||||
|
|
||||||
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
|
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
|
||||||
@@ -343,17 +398,380 @@ array of per-node structs was measured and bought **nothing** — at six nodes t
|
|||||||
L1-resident either way. It was kept because a node's state belonging on one cache line is the
|
L1-resident either way. It was kept because a node's state belonging on one cache line is the
|
||||||
right shape for a graph that grows, not because it showed up.
|
right shape for a graph that grows, not because it showed up.
|
||||||
|
|
||||||
|
### What the ingest filter and a still frame cost
|
||||||
|
|
||||||
|
Two costs that are paid once per *wire sample* and once per *tick* respectively, and so are the
|
||||||
|
largest two the application has that are not proportional to anything the user asked for.
|
||||||
|
|
||||||
|
**The ingest filter's group test.** Decimation at ingest is an average, never a stride, so the
|
||||||
|
filter reads every sample by design — and the test for "is this sample the last of its group"
|
||||||
|
was a modulo: `tick % period == period - 1`, once per channel per sample, plus once more for the
|
||||||
|
shared clock. `Partition::time_stride` is chosen at *runtime* (`plan_partition()` doubles it from
|
||||||
|
1 until the window fits), so that modulo is not a compile-time strength reduction but a real
|
||||||
|
64-bit `div` — and the code's own comment claimed the opposite. Both factors are powers of two
|
||||||
|
(every per-channel `stride[i]` is 1 by design), so the period minus one is a **mask** and the test
|
||||||
|
is `(tick & mask) == mask`. The masks are built where the accumulators are sized, keyed on the
|
||||||
|
partition, so a re-plan cannot leave them addressing the previous partition's groups; the
|
||||||
|
reserved-hole path got the same treatment, because a hole's samples go through the same
|
||||||
|
accumulators and the two phases have to agree sample for sample. The shared clock's test is now
|
||||||
|
computed once per sample and reused by the time ring, the channel loop and the math push — it was
|
||||||
|
three tests of the same thing.
|
||||||
|
|
||||||
|
**A frozen frame.** The tick queued a GL render unconditionally, sixty times a second, for the
|
||||||
|
whole life of the process. In `Held` and `Paused` that is the same picture again: the rows are
|
||||||
|
bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring, not the
|
||||||
|
series on screen. On llvmpipe, or on a battery, re-drawing a frozen event at 60 Hz is the entire
|
||||||
|
cost of the application. The gate is now two parts, and the second is what makes the first safe to
|
||||||
|
keep coarse: a *signature* of what a frozen view can be re-framed by — a new snapshot, a pan or
|
||||||
|
zoom (the group's range, where both the gestures and the pin write), a row added or removed —
|
||||||
|
which renders the moment it changes, and a **floor** that renders anyway every four ticks (~15 Hz),
|
||||||
|
so anything the signature does not name is late rather than lost. A gate that can only be opened by
|
||||||
|
a list of state is a gate that goes wrong the first time the list is one item short. A change opens
|
||||||
|
the gate for three frames rather than one, because the figure lays itself out per *render* and its
|
||||||
|
left gutter is a monotone floor that takes two passes to converge. The layout editor is excluded
|
||||||
|
outright: its buttons are placed from the rects the last frame was laid out with, so there a
|
||||||
|
skipped frame is a stale *widget* and not just a stale picture. Interaction needs none of this —
|
||||||
|
`PlotArea` queues its own render from the pointer, scroll and drag handlers, so a crosshair and a
|
||||||
|
cursor drag stay immediate whatever the gate decides. `[pause-probe]`'s `skips=` field reports it,
|
||||||
|
cumulative: 0 on the live line, non-zero on the paused one.
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
**Statistics** under the measurements list adds a second line to every measurement: `n`, the
|
||||||
|
mean, the standard deviation, and the range. The observations are the pane's own **recomputes**
|
||||||
|
— one per refresh, not one per sample — so `n` is a count of *windows measured*, which is what
|
||||||
|
makes "the mean frequency over the last four hundred windows" the claim it reads as. The pane
|
||||||
|
refreshes at most ten times a second while the window scrolls and exactly once when it settles,
|
||||||
|
so a settled window contributes one observation and then stops.
|
||||||
|
|
||||||
|
The accumulator is Welford's, not a sum of squares: a measurement whose values sit at 1e9 with
|
||||||
|
a spread of 1e-3 is an ordinary case here (an absolute stream time is one), and the naive form
|
||||||
|
loses that spread entirely.
|
||||||
|
|
||||||
|
**Reset** clears every accumulator. So does editing, adding or deleting a measurement, a CONFIG
|
||||||
|
that renames the signals, and a change of the display window — each of those changes *what is
|
||||||
|
being measured*, and a mean across the change describes nothing. A pause, a resume, a trigger
|
||||||
|
latch, a pan and a zoom do **not** reset: they change which samples are in view, which is
|
||||||
|
exactly what a statistic over many windows is meant to average. The toggle itself does not
|
||||||
|
reset either, so turning the line off to read the plot and back on again keeps the history.
|
||||||
|
|
||||||
|
## Resequencing and relative timing
|
||||||
|
|
||||||
|
**A shaded band marks samples the stream says exist and have not arrived.** When a packet is
|
||||||
|
received that reveals a gap in the counter, a run of NaN samples is reserved for the missing
|
||||||
|
packets; the span is shaded in the plot and the counters in the status bar show it. The band
|
||||||
|
carries three states:
|
||||||
|
|
||||||
|
- **Solid** while the hole still lacks packets.
|
||||||
|
- **Faint** once the late packets arrive and repair the values — the data is now correct, but
|
||||||
|
the record remains that this region arrived out of order.
|
||||||
|
- **Absent** once that part of the stream scrolls out of the ring's buffer.
|
||||||
|
|
||||||
|
Two things are **not** repaired when a late packet arrives: **math channels** and **the trigger**.
|
||||||
|
A backfilled slot's value is corrected in the ring and in the pyramid, so the raw trace and the
|
||||||
|
readouts are right. The math engine and the trigger already ran over the placeholder, however, and
|
||||||
|
are not replayed: a math trace keeps its gap across a repaired hole, and if the trigger latched
|
||||||
|
adjacent to one, that latch was on the data the engine actually saw.
|
||||||
|
|
||||||
|
**Known limitations.** The fade is all-or-nothing at the hole level — a hole with one packet still
|
||||||
|
missing shades solidly, even if all but one slot are already repaired. And the two tints are not
|
||||||
|
yet configurable; the preferences dialog in `todo.md` is where that would land. Do not use a
|
||||||
|
command-line flag for either; none exists.
|
||||||
|
|
||||||
|
**`--relative-time` makes the x axis follow the sample count and the declared rate**, rather than
|
||||||
|
the stream's own clock. The packet counter says exactly how many samples each gap spans, so a
|
||||||
|
reserved hole is a precise arithmetic range. In stream mode the same hole is estimated from the
|
||||||
|
declared cadence, which is the existing glitch-bridge mechanism reused.
|
||||||
|
|
||||||
|
The history cost of relative mode is measured rather than computed:
|
||||||
|
|
||||||
|
| mode | retained span |
|
||||||
|
| --- | --- |
|
||||||
|
| stream time (default) | **83.886 s** |
|
||||||
|
| `--relative-time` | **41.943 s** |
|
||||||
|
|
||||||
|
Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`.
|
||||||
|
Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and
|
||||||
|
not the 75% a per-byte calculation suggests. The reason is the floor: relative mode keeps a second
|
||||||
|
f64 ring for absolute time beside the stream clock's, so a tick's cost goes from
|
||||||
|
`8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is
|
||||||
|
floored to a **power of two** (`src/model.cpp:83`, `:110`), so a third more weight does not cost a
|
||||||
|
third of the span; it costs a whole halving. The measured figures above are the correct ones; a
|
||||||
|
per-byte computation gives the wrong answer for this reason.
|
||||||
|
|
||||||
|
## Packet capture
|
||||||
|
|
||||||
|
**`--capture-packets N`** stores the last N raw datagrams on disk for forensic
|
||||||
|
analysis — the bytes as `recv()` returned them, before the client validates the
|
||||||
|
magic, reassembles fragments or resequences counters. It is a debugging aid for
|
||||||
|
*wire* behaviours (a counter that skips, a datagram that arrives twice, a fragment
|
||||||
|
that never completes, a CONFIG whose header does not parse), not a feature of the
|
||||||
|
instrument: it is off unless the flag is given, and it adds nothing to the status
|
||||||
|
bar's glance tier.
|
||||||
|
|
||||||
|
The ring lives at `~/.cache/udpscope/<host>-<port>/capture.bin`, the previous
|
||||||
|
capture is kept one deep as `capture-prev.bin`, and it can be toggled at any time
|
||||||
|
from the status bar's detail popover (the **Capture packets** button, insensitive
|
||||||
|
until connected). At the default 1000 slots the file is **~62.6 MiB**, reserved up
|
||||||
|
front, which is why the size is stated in the tooltip and in the popover row.
|
||||||
|
|
||||||
|
Three things it deliberately is not, and the reasons are in
|
||||||
|
`docs/superpowers/specs/2026-09-29`'s sibling design note:
|
||||||
|
|
||||||
|
- **It cannot cost a packet.** The capture is an `mmap` and a `memcpy` on the
|
||||||
|
producer thread between two `recv()` calls; there is no queue, no lock and no
|
||||||
|
second copy. The one exception is the toggle itself: `open()` — a rename, a
|
||||||
|
create, a `posix_fallocate` and an `mmap` — runs on the producer thread when you
|
||||||
|
click, and measured ~1–3 ms here. The popover row shows the measured `open` cost
|
||||||
|
in the probe line; a reading far above that is a filesystem finding.
|
||||||
|
- **It is not interpreted.** `tools/capture_to_pcap.py` reads the file back and
|
||||||
|
writes a classic pcap, so wireshark does the interpreting:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/capture_to_pcap.py ~/.cache/udpscope/127.0.0.1-8000/capture.bin out.pcap
|
||||||
|
# 1000 slots, 16 stored, 0 torn, 0 truncated, 0 empty -> 16 packets, 1.0 MiB, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The counts it prints are a diagnostic in their own right: `torn` above zero means
|
||||||
|
the capture died mid-copy (a crash is exactly what the on-disk ring exists to
|
||||||
|
survive), and `truncated` means a datagram longer than the client's receive
|
||||||
|
buffer, which should be impossible.
|
||||||
|
- **It does not see the multicast CONFIG channel.** In multicast mode CONFIG
|
||||||
|
arrives over TCP, and the capture is of the UDP socket only. A malformed
|
||||||
|
*multicast* CONFIG is out of reach.
|
||||||
|
|
||||||
|
## Captured events
|
||||||
|
|
||||||
|
Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an event you did not have
|
||||||
|
time to look at is still there:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.cache/udpscope/events/192.168.1.40-8000/
|
||||||
|
20261009-123250-0001.npy # the event, in the export format
|
||||||
|
20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge
|
||||||
|
20261009-123250-0001.bmp # a picture of it, drawn from the samples
|
||||||
|
```
|
||||||
|
|
||||||
|
The **left pane** is opened on demand from the toolbar's event-history button, is collapsed by
|
||||||
|
default, and its **divider can be dragged**: it starts at 240 pixels and remembers the width you last
|
||||||
|
chose, and while it is closed it takes no room at all. Both the button and the pane exist while **recording is on**, or while an event is on
|
||||||
|
screen: with the cache disabled and nothing being examined there is nothing to show, and a visible
|
||||||
|
empty column beside the plot is what *off* must not look like. Recording is switched in the **trigger
|
||||||
|
panel**, with the trigger's other options rather than in the pane whose contents it decides, and it is
|
||||||
|
**off by default** -- writing every latch to disk is the user's decision about their disk.
|
||||||
|
|
||||||
|
Each row carries **its own picture** beside the numbers -- time, duration, signal count, rate -- 138
|
||||||
|
pixels wide whatever the pane's width, and as tall as that event's own proportions make it: the
|
||||||
|
preview is one 44-pixel strip per signal, so a one-signal event is a short row and a six-signal one a
|
||||||
|
tall one (capped, so that a row stays a row). **Hovering it shows the whole preview at full size**,
|
||||||
|
through a tooltip -- the small picture says *which* event this is, the zoom says what is in it.
|
||||||
|
Clicking a row **replays** it: the same loader `--open` uses, on the event's file. The click **leaves
|
||||||
|
the list alone** -- your scroll position and the pair you had selected for a comparison both survive
|
||||||
|
it, because marking which event is on screen rewrites the rows' titles in place instead of rebuilding
|
||||||
|
the list. **The layout does
|
||||||
|
not change** -- the rows, which signal sits in which slot, the colours, the row count, the time window,
|
||||||
|
the cursors and the measurements all stay exactly where they are, and now read the stored event instead
|
||||||
|
of the stream; that is what "show me that event" asks for, and rebinding the rows to the file would
|
||||||
|
rearrange the user's work under a click. What changes is the data, and the trigger is left, because a
|
||||||
|
file has no edge to wait for. Cursors, measurements, math channels and another export all work on a
|
||||||
|
stored event. **Live** takes you back to the stream.
|
||||||
|
|
||||||
|
Two buttons under the pane's header read the **selection**:
|
||||||
|
|
||||||
|
- *Compare* puts the two selected events in **two plots side by side**, each with **the layout you
|
||||||
|
already have** -- the same rows, the same signals in the same slots, the same colours -- one showing
|
||||||
|
one event and one the other. Their axes are **synchronised**: zoom or pan a row in either plot and the
|
||||||
|
same row of the other follows.
|
||||||
|
- *Accumulate* overlays up to four selected events in the **same traces**, translucent, so where they
|
||||||
|
agree the trace is brighter. The events' copies take the free slots of the row that already holds
|
||||||
|
their signal (a row holds eight traces), so your own traces never move.
|
||||||
|
|
||||||
|
Both need events with the same signals and the same length, which is what makes a shared axis honest;
|
||||||
|
the build checks it and says so in the status bar when it does not hold.
|
||||||
|
|
||||||
|
A group is a file view like a replay: the acquisition is stopped (a live producer would push into a
|
||||||
|
model whose channel count no longer matches the stream), the trigger is left, and *Live* returns.
|
||||||
|
|
||||||
|
A replay leaves trigger mode, because a file has no edge to wait for -- an armed trigger keeps a
|
||||||
|
*frozen capture* on screen (`display_mode()` stays `Held` while the model holds a snapshot), and a
|
||||||
|
held display draws that snapshot rather than the file that was just loaded. The events pane stays
|
||||||
|
open through it: it is available while a stored event is on screen.
|
||||||
|
|
||||||
|
**Going back to *Live* puts the live view back as it was.** Opening a file changes two things that
|
||||||
|
are not the user's choice -- the time window becomes the event's own span, because that is what
|
||||||
|
framing an event means, and trigger mode goes off for the reason above -- and neither reason survives
|
||||||
|
the way back. So the settings the live view had are taken on the way in and restored on the way out:
|
||||||
|
the time window, trigger mode, the trigger's channel, level, condition, pre-trigger share and
|
||||||
|
hold-off. The trigger's channel is remembered by **name**, since a comparison renames its channels
|
||||||
|
(`square (A)`) and the way back has to find the stream's own. The restore waits for the first CONFIG
|
||||||
|
of the restarted client, which is a frame or two, and is the last word on the window -- it was
|
||||||
|
previously the *saved* session's window that came back, which is to say, somebody else's.
|
||||||
|
|
||||||
|
A related repair: **the session file is no longer written while a stored event is on screen.** A
|
||||||
|
comparison rebinds every plot slot to its own renamed channels, and the automatic save wrote those
|
||||||
|
names into the session -- so the next *Live* re-applied a layout that named no live signal, and the
|
||||||
|
scope came back with an empty plot. Nothing is lost by skipping: a file view configured nothing about
|
||||||
|
the stream. The explicit *Save as* is unaffected; that one is an answer to a question the user asked.
|
||||||
|
|
||||||
|
Three things worth knowing:
|
||||||
|
|
||||||
|
- **The samples are the export format**, so an event is readable by everything that reads an
|
||||||
|
export: `--open`, the chooser's Waveforms page, and `numpy`/`pandas`.
|
||||||
|
- **The picture is drawn from the data**, one strip per signal with min/max per pixel column and the
|
||||||
|
trigger edge as a brighter line -- not captured from the window: the plot is a `GtkGLArea`, and a
|
||||||
|
GL area's ink is not in the widget's paint tree, so a snapshot of it is empty. A data-drawn
|
||||||
|
preview is also the honest one: it shows the samples, not the zoom the window happened to be at.
|
||||||
|
It is a plain 24-bit BMP, written bottom-up with **blue first** — both are BMP's own order, and
|
||||||
|
writing RGB there is what made every external viewer show the traces in the wrong colour.
|
||||||
|
- **The picture is decoded synchronously** (`gdk_texture_new_from_filename()` into
|
||||||
|
`gtk_picture_set_paintable()`), not through `gtk_picture_set_filename()`: that path uses the image
|
||||||
|
loader's sandboxed, asynchronous decoder, which here fails silently -- the picture keeps a
|
||||||
|
paint-able and an allocation, so a probe reports success while the pane shows an empty rectangle.
|
||||||
|
It is then **scaled once to the row's size** and cached by file: a `GtkPicture`'s natural size is
|
||||||
|
its content, so the stored 480-pixel preview given straight to a row claims the row's whole width
|
||||||
|
and squeezes the text out of it -- a size request on the picture is a floor, not a cap.
|
||||||
|
- **A picture that is missing or will not decode is drawn again from the samples.** The `.npy` is the
|
||||||
|
event; the `.bmp` is only a picture of it, so a write that failed, a truncated file or a cache from
|
||||||
|
an older build is repaired rather than shown as a blank row. Once per row: a second failure is a
|
||||||
|
real one.
|
||||||
|
- **A replay stops the acquisition** (a live producer would overwrite the loaded samples within a
|
||||||
|
frame), so the status bar names the file until *Live* starts a fresh client and thread. The
|
||||||
|
replayed samples age out of the ring as the stream refills it.
|
||||||
|
- **Opening an event, a comparison or going back to *Live* is one re-plan of the buffer**, not one
|
||||||
|
per channel. Each of those switches rebuilds the signal set, and a rebuild that moves the plan
|
||||||
|
allocates --- and so touches --- the whole of `--buffer-mb`; enabling a comparison's eight channels
|
||||||
|
one at a time was eight of those, which is most of what made the switch feel slow. They are now
|
||||||
|
enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of
|
||||||
|
allocating a new one.
|
||||||
|
- The switch in the pane's header stops the recording; the cache is only useful if it was already
|
||||||
|
on when the event you wanted happened, which is why it defaults to on.
|
||||||
|
|
||||||
|
## Opening an exported waveform
|
||||||
|
|
||||||
|
An export can be opened again — to look at a run you no longer have the source of, and to analyse it
|
||||||
|
with everything the scope offers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
udpscope --open ~/udpscope-20261009-113200.npy
|
||||||
|
```
|
||||||
|
|
||||||
|
or start udpscope with no `--server` and use the chooser's **Waveforms** page, which lists the files
|
||||||
|
opened before (newest first, with the ones that have since been deleted marked *missing* rather than
|
||||||
|
left to fail on the click) and has an **Open…** button for a new one.
|
||||||
|
|
||||||
|
A waveform is a *source that never arrives*, and the design follows from that: there is no socket, no
|
||||||
|
acquisition thread, and no starvation notice — the status bar's rate is the file's own stored rate and
|
||||||
|
its Stream row names the file. Everything else is the same machinery a live run uses: one signal and
|
||||||
|
channel per column, **one row per column** (so a four-signal file opens as four stacked traces, with
|
||||||
|
three free slots on every row to drag more onto), the samples pushed into the same ring, the display
|
||||||
|
epoch pinned to the file's last stamp so the axis reads `[-window, 0]` exactly as the export did — which means cursors, measurements,
|
||||||
|
math channels, the trigger's search and another export all work on it unchanged. The signal names come
|
||||||
|
from the file: a `.npy` written by `write_npy()` is a structured array whose *dtype* names its columns
|
||||||
|
(`time`, `abs_seconds`, then one field per signal), so there is no sidecar to lose.
|
||||||
|
|
||||||
|
Only `.npy` is read: it is the format the item asked for and the one that carries names. A `.csv`
|
||||||
|
export is for other tools.
|
||||||
|
|
||||||
|
**A recording longer than the buffer can hold at its own rate is resampled, never cut short.** The
|
||||||
|
file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-mb`: 20 s of ten
|
||||||
|
channels at 500 kS/s is 480 MB of rings, which a 200 MB buffer cannot hold. The loader therefore reads
|
||||||
|
the decimation the plan chose and pushes one stored sample per group of file rows, carrying the
|
||||||
|
group's **mean** — the same anti-aliased filter the live ingest applies, so a long event opens as the
|
||||||
|
whole recording at a lower sample rate instead of as a fraction of itself. It used to push every row
|
||||||
|
regardless, which overran the ring and left the file as **its own tail**: a 20 s event opened showing
|
||||||
|
only its last 4 s, with a correct-looking 20 s header line above it. The load line now names both
|
||||||
|
numbers, `samples=31250 rows=1000000 decim=32`, so what is on screen is never in doubt; give the run a
|
||||||
|
larger `--buffer-mb` (the source's configuration carries one) to see such a file sample for sample.
|
||||||
|
|
||||||
|
## Named configurations
|
||||||
|
|
||||||
|
What you arranged is remembered per data source, and a source can hold **several named
|
||||||
|
configurations** — different experiments on the same streamer want different plots, different
|
||||||
|
measurements, and sometimes a different buffer. Each is one file under the source's own
|
||||||
|
directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/udpscope/192.168.1.40_8000/
|
||||||
|
active # the configuration this source is on, one line
|
||||||
|
bench.toml # a configuration, plus its note
|
||||||
|
emi-test.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
A configuration is the whole session: the window, the rows and their signals, the calibration,
|
||||||
|
the trace colours, which signals were enabled, the math channels with their graphs, the
|
||||||
|
measurements — **and the settings that can only be decided at startup**, which is why they live
|
||||||
|
here rather than only on the command line:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty"
|
||||||
|
buffer_mb = 8
|
||||||
|
relative_time = false
|
||||||
|
capture_packets = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The **buffer size** is the one that matters most: two sources on one machine rarely want the same
|
||||||
|
ring, and the value is applied before the model exists, which is the only moment it can be — by
|
||||||
|
whichever of the chooser's rows opened the session, **and by `apply_stored_settings()` when the
|
||||||
|
source was named on the command line**. A saved host's settings are read either way: opening
|
||||||
|
`udpscope --server 10.0.0.5 --port 8000` gets that source's stored buffer size, clock and capture
|
||||||
|
ring, and a flag the user actually typed wins over the file (`--buffer-mb`, `--relative-time`,
|
||||||
|
`--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running
|
||||||
|
app applies everything that can change live (layout, plots, math, measurements, enables) and says
|
||||||
|
so in the status line about the rest (buffer, clock, packet capture): they take effect on the next
|
||||||
|
start.
|
||||||
|
|
||||||
|
The **chooser** (start with no `--server`) lists one row per configuration, with its note and its
|
||||||
|
settings, so two configurations of one source are told apart before they are opened. Its **New
|
||||||
|
host…** form takes the address *and* those settings — the configuration's name and note, the buffer
|
||||||
|
size, and relative time — so a host can be set up before it has ever streamed; the same form opens
|
||||||
|
from a row's pencil button to **edit** an existing configuration, pre-filled from its file, and
|
||||||
|
editing the name or the address is a *move* (the old file is deleted, so the list never shows the
|
||||||
|
same session twice). Connector settings are stored even for a configuration with no signals yet,
|
||||||
|
which is why those rows exist at all. The app's
|
||||||
|
menu has **Configurations…**: the same list for the running source, with *Save this session* —
|
||||||
|
a name and a note — and a delete button per row. Opening one saves the current session first, so
|
||||||
|
switching is never the gesture that loses what it switches from.
|
||||||
|
|
||||||
|
A session written before configurations existed is imported on first use as the configuration
|
||||||
|
named `default`: copied into the directory, remembered as active, and the old file renamed to
|
||||||
|
`<host>_<port>.toml.imported` rather than deleted, so the import can be undone by hand.
|
||||||
|
|
||||||
|
## Preferences
|
||||||
|
|
||||||
|
`~/.config/udpscope/settings.toml`, written by **Preferences…** -- in the overflow menu or the source
|
||||||
|
chooser -- and read at start-up. It holds the event cache's directory, how many events to keep (`0`
|
||||||
|
keeps every one), whether relative time is on, and the interface's theme. Whether anything is cached at
|
||||||
|
all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
|
||||||
|
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
|
||||||
|
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
|
||||||
|
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
|
||||||
|
chosen. A change
|
||||||
|
applies as it is made — there is no Apply button, and a **Reset to defaults** row at the end of the
|
||||||
|
dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a
|
||||||
|
subtitle under it where the setting needs explaining, and the control on the right. A key that is
|
||||||
|
absent means its default, so the file only mentions what changed, and a missing file is a first run
|
||||||
|
rather than an error. The directory field shows the directory in use: the default
|
||||||
|
is filled in rather than described, and typing it back leaves the setting unset, so it goes on
|
||||||
|
following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder
|
||||||
|
button beside it, which writes what it found into the same field -- the field stays the one value, and
|
||||||
|
there is nothing to keep in step.
|
||||||
|
|
||||||
|
The same dialog is reachable from the source chooser, the one window that exists before a run is
|
||||||
|
planned. From the app the cache directory and relative time take effect on the next start; from the
|
||||||
|
chooser they reach the run about to start. A preference applied there also beats a source's stored
|
||||||
|
configuration, exactly as `--relative-time` does: it is the more recent statement of intent, and it
|
||||||
|
works in both directions, so turning relative time off in the dialog is not undone by the row you click
|
||||||
|
afterwards.
|
||||||
## The session layout file
|
## The session layout file
|
||||||
|
|
||||||
What you arranged is remembered per data source, in
|
A configuration's file holds the display
|
||||||
`~/.config/udpscope/<host>_<port>.toml` (`$XDG_CONFIG_HOME` is honoured; the
|
|
||||||
host's unsafe characters are replaced with `_`). The file holds the display
|
|
||||||
window, the row layout and which signal each of a row's slots plots, and the
|
window, the row layout and which signal each of a row's slots plots, and the
|
||||||
per-signal gain and offset:
|
per-signal gain and offset:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
window_s = 2
|
window_s = 2
|
||||||
plots = 1
|
plots = 1
|
||||||
|
measure_stats = false
|
||||||
|
|
||||||
[[plot]]
|
[[plot]]
|
||||||
signals = ["Ch1", "", "", ""]
|
signals = ["Ch1", "", "", ""]
|
||||||
@@ -364,7 +782,8 @@ gain = 2
|
|||||||
offset = 0.5
|
offset = 0.5
|
||||||
```
|
```
|
||||||
|
|
||||||
It is written (400 ms after the last change, and flushed on close) whenever the
|
It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means
|
||||||
|
`false`). It is written (400 ms after the last change, and flushed on close) whenever the
|
||||||
window, the layout, a drop or a calibration changes, and applied once, when the
|
window, the layout, a drop or a calibration changes, and applied once, when the
|
||||||
first CONFIG names the signals -- signal *names* are the identity, because a
|
first CONFIG names the signals -- signal *names* are the identity, because a
|
||||||
CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the
|
CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the
|
||||||
@@ -488,6 +907,37 @@ fallback when the stream provides no usable time signal (it is then the sample's
|
|||||||
via the arrival estimate); it is never used when the stream's own time signal
|
via the arrival estimate); it is never used when the stream's own time signal
|
||||||
carries a span.
|
carries a span.
|
||||||
|
|
||||||
|
**One bad packet is bridged; only a run of them changes the clock.** A real device's
|
||||||
|
clock glitches, and a packet whose stamps jump far ahead of the previous packet's last
|
||||||
|
is a fault rather than a gap — so a jump of more than **256 packet spans** is rejected
|
||||||
|
alongside the three checks that were always there (a stamp that is not finite, a packet
|
||||||
|
with no span at all, a packet that does not advance past the previous one). A rejected
|
||||||
|
packet is **bridged**: its stamps are replaced by a continuation of the accepted timeline
|
||||||
|
at the declared cadence, so the next good packet is weighed against an un-poisoned
|
||||||
|
anchor and is accepted normally. Only **four consecutive** rejected packets abandon the
|
||||||
|
declared time signal for the arrival clock, because that is what a signal which is not a
|
||||||
|
time base at all looks like. The declared unit (`ns`, `us`, `ms`) is resolved *before*
|
||||||
|
these checks rather than after, since they weigh the stamps against a cadence in seconds.
|
||||||
|
|
||||||
|
Believing a glitch instead is what produced *"a big spike in one channel and the
|
||||||
|
acquisition seems to reset"*. The stamps pass through a monotonic clamp, so a glitched
|
||||||
|
value poisoned the mark and pinned every later packet flat at it — the stream froze at one
|
||||||
|
x and never recovered — and the glitch also condemned the *next*, perfectly good packet
|
||||||
|
(now below the poisoned anchor, so it failed the advance test), which threw the whole
|
||||||
|
stream onto the arrival clock. `tools/mock_udpstreamer.py --time-glitch-at <s>
|
||||||
|
--time-glitch-by <s>` injects the fault; with a 10 s hold-off the two latches either side
|
||||||
|
of it are 11.2 s apart, where before the fix they were 1.2 s apart.
|
||||||
|
|
||||||
|
**Switching to the arrival clock does not drop history.** `arrival_epoch_` is derived from
|
||||||
|
the monotonic mark, so the arrival clock *continues* from it and the two timelines share a
|
||||||
|
zero by construction — the recorded stamps are therefore still on one timeline and are
|
||||||
|
kept. They used to be wiped on the reasoning that the old stamps belonged to a dead clock;
|
||||||
|
they do not, and that wipe is what made a single glitching packet look like *"the live
|
||||||
|
stream restarts from 0 with no change from the user"*. The same continuity is why
|
||||||
|
`TriggerEngine` needs no rebase across the switch: it holds four absolute stamps
|
||||||
|
(hold-off, pending edge, last trigger, last sample) and they stay valid. Anything that
|
||||||
|
makes the clock genuinely *discontinuous* would break that and would have to reset them.
|
||||||
|
|
||||||
**Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the
|
**Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the
|
||||||
edge/hold-off/normal-single state machine on the selected channel, and once the
|
edge/hold-off/normal-single state machine on the selected channel, and once the
|
||||||
requested post-trigger share of the *event span* (the event-window control, not the
|
requested post-trigger share of the *event span* (the event-window control, not the
|
||||||
@@ -577,6 +1027,23 @@ after a rebuild — a CONFIG, enabling the signal, or a calibration change all d
|
|||||||
history — which is exactly when the first event used to latch on an almost empty pre
|
history — which is exactly when the first event used to latch on an almost empty pre
|
||||||
window and draw half a plot.
|
window and draw half a plot.
|
||||||
|
|
||||||
|
**The engine identifies its trigger channel by name, never by index**, and that is what
|
||||||
|
makes the hold-off hold. `TriggerEngine::configure()` drops an open capture when the
|
||||||
|
trigger moves to a different channel, and it is re-called on *every* packet. The config's
|
||||||
|
channel field is an index into the channel list, and that index is not stable: every
|
||||||
|
rebuild of the list renumbers it — enabling a signal that sorts before the trigger's own
|
||||||
|
is enough — and the model then re-resolves the trigger by name and writes the new index
|
||||||
|
back. Keying the reset on the index therefore made any such rebuild abandon the capture
|
||||||
|
**and set no hold-off**, so the next edge inside the post window became t0: with a 1 s
|
||||||
|
window, a 10 % pre share and a 10 s hold-off, an edge 0.6 s into a capture stole it. That
|
||||||
|
needed no glitch and no user action beyond enabling a channel. A deliberate re-point still
|
||||||
|
resets, because the name changes with the source. Since the rebuild also replaces the
|
||||||
|
buffer, the surviving capture's pre side is short and the snapshot reports its own `pre_s`
|
||||||
|
— which may be **negative** when the new ring starts after the edge. That is a faithful
|
||||||
|
reading rather than a defect: `[-pre_s, post_s]` still describes the samples the snapshot
|
||||||
|
holds. `UDPSCOPE_PROBE_TRIGRENUM_MS` drives the whole sequence through the real widgets
|
||||||
|
and prints `t0_held=1` as the assertion.
|
||||||
|
|
||||||
The event is published on the sample that latched it, but only after every channel of
|
The event is published on the sample that latched it, but only after every channel of
|
||||||
that sample has reached its ring: the cut is clamped to the shortest value ring, so
|
that sample has reached its ring: the cut is clamped to the shortest value ring, so
|
||||||
publishing from the middle of the channel loop dropped the newest sample — the very one
|
publishing from the middle of the channel loop dropped the newest sample — the very one
|
||||||
@@ -604,6 +1071,69 @@ zooming in past the shift showed nothing at all. The self-check now prints `data
|
|||||||
`drawn=` side by side for the bound trace so the contract is checked rather than
|
`drawn=` side by side for the bound trace so the contract is checked rather than
|
||||||
assumed.
|
assumed.
|
||||||
|
|
||||||
|
### Qualified trigger conditions
|
||||||
|
|
||||||
|
The level-and-edge trigger is one of four shapes the **Shape** dropdown offers, and all four
|
||||||
|
are the *same* trigger: the arming, the hold-off, the pre/post event window and the single/auto
|
||||||
|
modes are unchanged, and only the question "did an event happen at this sample" differs.
|
||||||
|
|
||||||
|
- **edge** — a crossing of the level in the chosen direction. The original behaviour, bit for
|
||||||
|
bit.
|
||||||
|
- **pulse width** — a crossing *and* its return, qualified by how long the excursion lasted:
|
||||||
|
`<`, `>`, `within` or `outside` a pair of widths. The event is timed at the **closing** edge,
|
||||||
|
because that is the first sample at which the width is known. `<` a width shorter than the
|
||||||
|
signal's own pulses is the glitch trigger: nothing but a runt satisfies it.
|
||||||
|
- **window** — the signal entering or leaving a band between two levels. A band is two levels,
|
||||||
|
so the panel refuses a zero-width one: it reduces the condition to an exact float-equality
|
||||||
|
test against the level, which an ordinary signal never satisfies and a signal sitting *at*
|
||||||
|
the level satisfies on every transition — a ±1 square against a band of `[1, 1]` fired 19
|
||||||
|
times in a standalone check. Neither answer is a band, which is why the second spin is
|
||||||
|
clamped an increment away rather than the detector special-cased.
|
||||||
|
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
|
||||||
|
the condition re-arms only when a crossing returns.
|
||||||
|
|
||||||
|
**Noise rejection on the trigger's own input.** A *band* and a *reject* are offered beside the level.
|
||||||
|
`band` is a share of the channel's own span either side of the level -- a second, undecided threshold, so a
|
||||||
|
sample inside it decides nothing -- and `reject` is a one-pole low-pass corner in Hz, one state per
|
||||||
|
configured condition, recomputed when the corner or the sample interval changes. **Off is `band = 0` and
|
||||||
|
`reject = 0`**, which is bit for bit the trigger that was there before them.
|
||||||
|
|
||||||
|
They apply to **edge** and **window** and *not* to **pulse width** or **dropout**. That is a deliberate
|
||||||
|
deviation from the design spec, which asked for every condition type: a pulse's width is measured from a
|
||||||
|
crossing to its return, so a band there would change the *measured width* rather than reject noise (the
|
||||||
|
documented pulse-width readings move with it), and a dropout is a measured *absence*, which a band cannot
|
||||||
|
describe -- there is no level to compare.
|
||||||
|
|
||||||
|
The band and the reject are verified through `BufferSearch` hit counts rather than through
|
||||||
|
`[condition-probe]` latch times, also deliberately: `probe-readings.md` states that a `t0` is **not** an
|
||||||
|
assertion, being a stream stamp rebased by whichever rebuild last dropped history -- it read `1.7103`,
|
||||||
|
`1.6975` and `1.8719` on three consecutive runs of the same leg. The counts, and what each must read, are in
|
||||||
|
`probe-readings.md`.
|
||||||
|
|
||||||
|
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
|
||||||
|
width to a window trigger. The condition is **not** persisted in the session file — the session
|
||||||
|
has never persisted the trigger — so a restart comes up on an edge trigger.
|
||||||
|
|
||||||
|
### Searching the buffer
|
||||||
|
|
||||||
|
**Search the buffer**, at the foot of the trigger panel, runs the trigger's *own* condition over
|
||||||
|
the whole retained ring and lists every place it holds. There is no second condition editor: a
|
||||||
|
second place to spell the same question would be a second place to get it wrong.
|
||||||
|
|
||||||
|
The scan runs on the GTK thread in a few milliseconds of work per frame, taking the model's
|
||||||
|
mutex for one chunk at a time, so neither the frame rate nor the producer stalls on it. Up to
|
||||||
|
10 000 hits are kept; past that the scan stops and says so. If the stream overtakes the oldest
|
||||||
|
samples mid-scan the search skips forward to what is still there and reports `the stream
|
||||||
|
overtook the oldest samples` rather than returning a hit at a time that no longer exists.
|
||||||
|
|
||||||
|
The arrows step through the hits. A step **pauses** the view on the hit, centred, exactly as the
|
||||||
|
Pause button does — so the frozen window is pinned and navigable within its own data, and
|
||||||
|
resuming returns to the live view. Up to 16 of the hits **in the visible window** are marked on
|
||||||
|
every row at once, nearest the centre first and the selected one always among them in its own
|
||||||
|
colour; when there are more in view than there are marks the panel says `16 of 38 marks shown —
|
||||||
|
zoom in to see the rest`, which is a true statement about the picture rather than a silent
|
||||||
|
truncation.
|
||||||
|
|
||||||
**The live view had two clipping bugs of its own**, both wearing the same face as that
|
**The live view had two clipping bugs of its own**, both wearing the same face as that
|
||||||
one: the older part of the trace blank, the newest part fine, and the crosshair still
|
one: the older part of the trace blank, the newest part fine, and the crosshair still
|
||||||
reading values in the blank part. Neither is a data bug — the samples were always there.
|
reading values in the blank part. Neither is a data bug — the samples were always there.
|
||||||
@@ -692,16 +1222,41 @@ row's `×` forgets a source by deleting its session file, with no confirmation:
|
|||||||
confirmation, and what it destroys is one layout the app rewrites on the next clean exit. A session
|
confirmation, and what it destroys is one layout the app rewrites on the next clean exit. A session
|
||||||
is **not written** when no CONFIG arrived during the run — otherwise a source whose streamer was
|
is **not written** when no CONFIG arrived during the run — otherwise a source whose streamer was
|
||||||
down during one launch would silently vanish from the chooser list because its file was flattened
|
down during one launch would silently vanish from the chooser list because its file was flattened
|
||||||
to empty signal names, which the filter refuses.
|
to empty signal names, which the filter refuses — nor while a **stored event is on screen**, for
|
||||||
|
the same reason stated the other way round: a file view configured nothing about the stream, and a
|
||||||
|
comparison's renamed slots written into the session came back as an empty plot.
|
||||||
|
|
||||||
**Math channels.** A math channel is derived from the others by a graph of operations and is then
|
**Math channels.** A math channel is derived from the others by a graph of operations and is then
|
||||||
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
|
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
|
||||||
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
|
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
|
||||||
another math channel can be its source. The operations are `channel` (a source), `gain + offset`,
|
another math channel can be its source. The operations are `channel` (a source), `gain + offset`,
|
||||||
`abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the
|
`abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the
|
||||||
stored rate), `sum`, `difference`, `product` and `ratio`; the arithmetic ops take two inputs, which
|
stored rate), `de-noise` (a running median followed by a one-pole), `sum`, `difference`, `product`
|
||||||
|
and `ratio`; the arithmetic ops take two inputs, which
|
||||||
is what makes branching — the sum of a raw and a filtered channel — expressible at all.
|
is what makes branching — the sum of a raw and a filtered channel — expressible at all.
|
||||||
|
|
||||||
|
`de-noise` is **last in the dropdown on purpose**: a node's op is stored in the session as its index
|
||||||
|
through `math_op_at()`, so an op inserted among the others would reinterpret every node in every file written
|
||||||
|
before it.
|
||||||
|
|
||||||
|
**`de-noise` is one node, not a median the user chains into a low pass.** Its window is `med_n` samples, odd
|
||||||
|
and 3..9 (the dialog offers only those, and the engine rounds to it anyway, so a hand-edited file loads), and
|
||||||
|
an impulse shorter than half the window cannot move the median at all; the one-pole at `fc` Hz then takes the
|
||||||
|
hash the median leaves behind, and `fc = 0` is the median alone. The order is the design: a pole *before* a
|
||||||
|
median feeds the impulse into the filter's state for its whole settling time, which is the thing the node
|
||||||
|
exists to avoid. It costs a per-sample insertion sort of at most nine doubles into a local, on the producer
|
||||||
|
thread where every other op runs -- `math cost:` reads **103 ns/slot** with a de-noise channel in the probe's
|
||||||
|
three graphs.
|
||||||
|
|
||||||
|
**A gap is a gap in a filtered trace, never the end of it.** A hole the resequencer reserved is
|
||||||
|
NaN in the source, and the filters deliberately do not integrate it: the hole shows as a hole in
|
||||||
|
the filtered trace, the filter's history stays the last real samples it saw, and the trace picks
|
||||||
|
up again after it. (Integrating it poisoned the biquad's state for ever — every later sample
|
||||||
|
NaN — which is what a dropped packet used to do to every low pass, systematically in relative
|
||||||
|
time, where a counter gap reserves NaN slots by construction.) The exit dump's
|
||||||
|
`math '<name>': finite=N/M` line is where that reads: `finite` must be about `M` minus the hole
|
||||||
|
share, and `finite=0` beside a non-zero `hole_slots` is the defect back.
|
||||||
|
|
||||||
The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge
|
The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge
|
||||||
and its output the square on its right. Click a box to select it and its parameters appear in the
|
and its output the square on its right. Click a box to select it and its parameters appear in the
|
||||||
form beside the canvas; **drag from an output square to an input square to wire them** (the old
|
form beside the canvas; **drag from an output square to an input square to wire them** (the old
|
||||||
@@ -1230,10 +1785,17 @@ signal if needed, binds it to that row's first free trace slot, and rebuilds the
|
|||||||
figure so the legend name is right (there is one widget, so the row a drop lands
|
figure so the legend name is right (there is one widget, so the row a drop lands
|
||||||
on is `PlotArea::row_at()`). The status line reports the outcome of a drop
|
on is `PlotArea::row_at()`). The status line reports the outcome of a drop
|
||||||
(`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action
|
(`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action
|
||||||
that changes nothing still says why. Each plot's y axis has a right-click menu
|
that changes nothing still says why. Each plot has a right-click menu in the same
|
||||||
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
|
compact row style as the app's own menu — the label at one left column, a shortcut flush right,
|
||||||
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
|
18 px rows — holding **Y axis: auto scale**, **Y axis: fixed range...**, **Fit X axis**, **Fit
|
||||||
it says so instead). The two fits are the same pair the overflow menu and the `f` key
|
all** (the `f` key's action, so it shows `F`), and **Remove signal**, which swaps the menu to a
|
||||||
|
second view — one entry per trace on that plot, plus **All traces on this plot** — with a **Back**
|
||||||
|
row to return (a plot with nothing on it says so instead). The removals are a view of their own
|
||||||
|
rather than a flat list because as a flat list they were the same long sentence repeated once per
|
||||||
|
trace, which is most of the menu spent on one verb; and they *replace* the menu instead of floating
|
||||||
|
beside it as a submenu, because a second popover is a second thing to dismiss and a `GtkMenuButton`
|
||||||
|
that owns one toggles itself out of sync as soon as anything else closes it — its row stopped
|
||||||
|
opening at all on the second click. The two fits are the same pair the overflow menu and the `f` key
|
||||||
offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
|
offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
|
||||||
window and leaves every y scale exactly as the user set it, **Fit all** does that and
|
window and leaves every y scale exactly as the user set it, **Fit all** does that and
|
||||||
auto-scales the y axes too. Neither changes the time window itself — see below.
|
auto-scales the y axes too. Neither changes the time window itself — see below.
|
||||||
@@ -1826,20 +2388,22 @@ now — they are listed so they are not mistaken for anything else:
|
|||||||
progress. What the spec was protecting still holds — a *held* event is bound to its
|
progress. What the spec was protecting still holds — a *held* event is bound to its
|
||||||
snapshot alone, and the axis is the event's own window from the edge onwards rather
|
snapshot alone, and the axis is the event's own window from the edge onwards rather
|
||||||
than a sliding live view.
|
than a sliding live view.
|
||||||
- **A plot holds at most four signals** (`RowSet::kSlotsPerRow`), because a `Plot`
|
- **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot`
|
||||||
cannot give up a trace and the slots are therefore created up front.
|
cannot give up a trace and the slots are therefore created up front.
|
||||||
- **One toggle drives both cursor orientations.** The vertical markers and the
|
- **One toggle drives both cursor orientations.** The vertical markers and the
|
||||||
horizontal ruler exist separately but share one show/hide and one reset.
|
horizontal ruler exist separately but share one show/hide and one reset.
|
||||||
- **The time reference is the first *enabled* channel's signal.** A CONFIG whose
|
- **The time reference is the first *enabled* channel's signal.** A CONFIG whose
|
||||||
time signal belongs to a signal that is disabled contributes no stream time, and
|
time signal belongs to a signal that is disabled contributes no stream time, and
|
||||||
the app falls back to arrival-based timing (with a warning on stderr).
|
the app falls back to arrival-based timing (with a warning on stderr).
|
||||||
- **The choice of time source is sticky, and a change of source empties the rings.**
|
- **The choice of time source is sticky.** It is decided once (and re-decided on a
|
||||||
It is decided once (and re-decided on a CONFIG) and then kept: choosing per frame
|
CONFIG) and then kept: choosing per frame let a stream whose declared time signal
|
||||||
let a stream whose declared time signal fails one check and passes the next
|
fails one check and passes the next alternate between two timelines, so one ring held
|
||||||
alternate between two timelines, so one ring held stamps from both and the x axis,
|
stamps from both and the x axis, the crosshair and an export read nonsense — a plateau
|
||||||
the crosshair and an export read nonsense — a plateau of one timestamp, then a jump
|
of one timestamp, then a jump to another epoch. The switch keeps the recorded history,
|
||||||
to another epoch. On the switch the recorded history is dropped rather than left
|
because the arrival clock is anchored to continue from the monotonic mark and the two
|
||||||
half on each clock. The stamps themselves are spaced by the stream's *measured*
|
timelines therefore share a zero (see "Switching to the arrival clock does not drop
|
||||||
|
history" above); it used to empty the rings, which is a worse reading of the same
|
||||||
|
concern. The stamps themselves are spaced by the stream's *measured*
|
||||||
arrival cadence whenever the declared rate disagrees with it, because a CONFIG's
|
arrival cadence whenever the declared rate disagrees with it, because a CONFIG's
|
||||||
rate is a claim the stream may not keep (one that declared 1 MS/s and delivered a
|
rate is a claim the stream may not keep (one that declared 1 MS/s and delivered a
|
||||||
hundred samples every fifth of a second made every frame span microseconds while
|
hundred samples every fifth of a second made every frame span microseconds while
|
||||||
@@ -1875,6 +2439,15 @@ stall test. Calling that one "connected, but no data is arriving" blamed a secon
|
|||||||
that does not exist; the usual cause is a streamer that is not running, or one serving a
|
that does not exist; the usual cause is a streamer that is not running, or one serving a
|
||||||
different port.
|
different port.
|
||||||
|
|
||||||
|
**A link loss does not cost the acquisition.** When the stream comes back and its CONFIG
|
||||||
|
names the same signals as before — the common case, because whatever knocked the link out
|
||||||
|
did not reconfigure the producer — the reconnect keeps the buffer, its history, the
|
||||||
|
trigger's state and any held event, and the outage appears as the gap in the data that it
|
||||||
|
was. Only a CONFIG that actually *changes* the stream re-plans the buffer, which is the
|
||||||
|
one case where dropping history is honest. The mock can reproduce the loss itself:
|
||||||
|
`tools/mock_udpstreamer.py --blackout-at 5 --blackout 3.5` goes silent past the client's
|
||||||
|
silence timeout and then resumes, which is the EMI case end to end.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
||||||
|
|||||||
@@ -36,3 +36,18 @@
|
|||||||
- the time should be computed from the time associated with the signal (not from the client but from the streamed data)
|
- the time should be computed from the time associated with the signal (not from the client but from the streamed data)
|
||||||
- in trigger mode data visualisation should not be refreshed till next event, that's why 2 identical buffer, one is the currently
|
- in trigger mode data visualisation should not be refreshed till next event, that's why 2 identical buffer, one is the currently
|
||||||
viewed event, the other used to acquire live data for next event
|
viewed event, the other used to acquire live data for next event
|
||||||
|
|
||||||
|
## Deviations from the spec
|
||||||
|
|
||||||
|
The following were built differently from what was asked for, each for a reason `README.md` states
|
||||||
|
in full:
|
||||||
|
|
||||||
|
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. The retained history
|
||||||
|
in this mode is **41.943 s** against the default **83.886 s** (measured at 100 kS/s, 4 channels,
|
||||||
|
`--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
|
||||||
|
cost from 24 to 32 bytes — a third more — and `Partition::time_capacity` is floored to a power
|
||||||
|
of two, so a third more weight costs a full halving. The design document and the implementation
|
||||||
|
plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same
|
||||||
|
reason.
|
||||||
|
Absolute stream time is preserved in a separate ring and used for the export's `abs_seconds`
|
||||||
|
column, exactly as `todo.md` requires.
|
||||||
Vendored
+1
-1
Submodule deps/plotter updated: cc908efa36...9790becb33.
Vendored
+315
-30
@@ -33,6 +33,13 @@
|
|||||||
/* Tunable limits */
|
/* Tunable limits */
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
/** Datagrams held waiting for an earlier counter. */
|
||||||
|
#define UDPS_REORDER_SLOTS 64
|
||||||
|
/** Total bytes the reorder ring may hold, so a fast stream cannot grow it without bound. */
|
||||||
|
#define UDPS_REORDER_BYTES (4u * 1024u * 1024u)
|
||||||
|
/** How long a gap is given to resolve before it is committed, by default. */
|
||||||
|
#define UDPS_REORDER_WINDOW_S_DEFAULT 0.020
|
||||||
|
|
||||||
/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */
|
/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */
|
||||||
#define UDPS_MAX_SLOTS 4
|
#define UDPS_MAX_SLOTS 4
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@
|
|||||||
#define UDPS_FRAG_MASK_BYTES (UDPS_MAX_FRAGMENTS / 8)
|
#define UDPS_FRAG_MASK_BYTES (UDPS_MAX_FRAGMENTS / 8)
|
||||||
|
|
||||||
/** Largest datagram we can receive, header included. */
|
/** Largest datagram we can receive, header included. */
|
||||||
#define UDPS_RX_BUF_BYTES (65535 + 17)
|
#define UDPS_RX_BUF_BYTES UDPS_MAX_DATAGRAM_BYTES
|
||||||
|
|
||||||
/** An incomplete reassembly older than this is abandoned. */
|
/** An incomplete reassembly older than this is abandoned. */
|
||||||
#define UDPS_SLOT_STALE_S 2.0
|
#define UDPS_SLOT_STALE_S 2.0
|
||||||
@@ -151,6 +158,16 @@ typedef struct {
|
|||||||
double first_seen;
|
double first_seen;
|
||||||
} udps_slot_t;
|
} udps_slot_t;
|
||||||
|
|
||||||
|
/** One slot in the reorder ring: a raw payload held until its counter is next. */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t *buf; /**< owned; grown on demand to the payload's length */
|
||||||
|
size_t cap;
|
||||||
|
size_t len;
|
||||||
|
uint32_t counter;
|
||||||
|
double recv_time;
|
||||||
|
int used;
|
||||||
|
} udps_held_t;
|
||||||
|
|
||||||
struct udps_client {
|
struct udps_client {
|
||||||
udps_client_config_t cfg;
|
udps_client_config_t cfg;
|
||||||
/* Owned copies: the caller's strings need not outlive the create call. */
|
/* Owned copies: the caller's strings need not outlive the create call. */
|
||||||
@@ -181,12 +198,20 @@ struct udps_client {
|
|||||||
|
|
||||||
uint8_t *rxbuf;
|
uint8_t *rxbuf;
|
||||||
|
|
||||||
uint32_t last_counter;
|
udps_held_t held[UDPS_REORDER_SLOTS];
|
||||||
|
size_t held_bytes; /**< payload bytes currently held */
|
||||||
|
int held_count;
|
||||||
|
uint32_t next_counter; /**< the counter the sequence is waiting for */
|
||||||
|
double gap_deadline; /**< wall time the current gap gives up at; 0 = no gap */
|
||||||
|
|
||||||
int have_counter;
|
int have_counter;
|
||||||
|
|
||||||
udps_stats_t stats;
|
udps_stats_t stats;
|
||||||
char err[256];
|
char err[256];
|
||||||
|
|
||||||
|
udps_datagram_cb datagram_cb; /**< NULL until set; fires inside poll(). */
|
||||||
|
void *datagram_user;
|
||||||
|
|
||||||
udps_config_cb on_config;
|
udps_config_cb on_config;
|
||||||
udps_data_cb on_data;
|
udps_data_cb on_data;
|
||||||
udps_event_cb on_event;
|
udps_event_cb on_event;
|
||||||
@@ -475,43 +500,23 @@ static int decode_config(udps_client_t *c, const uint8_t *pl, size_t len) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
/* Decodes and delivers one DATA payload whose ordering has already been resolved
|
||||||
uint32_t counter, double recv_time) {
|
* by the caller. `lost` counts how many counters the sequence skipped over
|
||||||
|
* before this packet, and `late` marks it as having lost the race past the
|
||||||
|
* reorder window (so the consumer already has a hole where it belongs). */
|
||||||
|
static int deliver_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||||
|
uint32_t counter, double recv_time, uint32_t lost, uint8_t late) {
|
||||||
uint32_t nsamples = 1u;
|
uint32_t nsamples = 1u;
|
||||||
size_t off = 8u;
|
size_t off = 8u;
|
||||||
size_t total = 0u;
|
size_t total = 0u;
|
||||||
size_t written = 0u;
|
size_t written = 0u;
|
||||||
uint32_t i;
|
uint32_t i;
|
||||||
uint32_t lost = 0u;
|
|
||||||
udps_frame_t frame;
|
udps_frame_t frame;
|
||||||
|
|
||||||
if (c->num_sigs == 0u) {
|
if (c->num_sigs == 0u) {
|
||||||
return 0; /* DATA before CONFIG: nothing to decode against. */
|
return 0; /* DATA before CONFIG: nothing to decode against. */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Order the sequence before spending anything on the payload.
|
|
||||||
*
|
|
||||||
* Reassembly completes in arrival order, not counter order, so a packet
|
|
||||||
* delayed or duplicated on the wire surfaces after a newer one has already
|
|
||||||
* been delivered. Its samples carry an older time base: they land on top
|
|
||||||
* of data the consumer already has and leave the span they should have
|
|
||||||
* filled empty. Nothing in the payload distinguishes such a packet from a
|
|
||||||
* good one, only the counter does.
|
|
||||||
*
|
|
||||||
* The counter is a wrapping uint32, so it is ordered by the signed
|
|
||||||
* difference; comparing the values directly would call the first packet
|
|
||||||
* after the wrap stale and reject the stream from then on. */
|
|
||||||
if (c->have_counter) {
|
|
||||||
int32_t delta = (int32_t)(counter - c->last_counter);
|
|
||||||
if (delta <= 0) {
|
|
||||||
c->stats.stale_packets++;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
lost = (uint32_t)delta - 1u;
|
|
||||||
c->stats.counter_gaps += lost;
|
|
||||||
}
|
|
||||||
c->last_counter = counter;
|
|
||||||
c->have_counter = 1;
|
|
||||||
if (len < 8u) {
|
if (len < 8u) {
|
||||||
return fail(c, "DATA payload too short (%lu bytes)", (unsigned long)len);
|
return fail(c, "DATA payload too short (%lu bytes)", (unsigned long)len);
|
||||||
}
|
}
|
||||||
@@ -554,10 +559,12 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
|||||||
}
|
}
|
||||||
|
|
||||||
c->stats.frames_delivered++;
|
c->stats.frames_delivered++;
|
||||||
|
c->stats.counter_gaps += lost;
|
||||||
|
|
||||||
if (c->on_data != NULL) {
|
if (c->on_data != NULL) {
|
||||||
frame.counter = counter;
|
frame.counter = counter;
|
||||||
frame.lost = lost;
|
frame.lost = late ? 0u : lost;
|
||||||
|
frame.late = late;
|
||||||
frame.hrt = rd_u64(pl);
|
frame.hrt = rd_u64(pl);
|
||||||
frame.recv_time = recv_time;
|
frame.recv_time = recv_time;
|
||||||
frame.publish_mode = c->publish_mode;
|
frame.publish_mode = c->publish_mode;
|
||||||
@@ -570,6 +577,245 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Finds a free slot in the reorder ring and stores the payload there.
|
||||||
|
*
|
||||||
|
* A duplicate counter (same datagram arriving twice on the wire) replaces the
|
||||||
|
* existing slot so one counter never consumes two slots and both get delivered.
|
||||||
|
* The search for that slot therefore runs *before* the capacity checks, and the
|
||||||
|
* checks are applied only on the path that needs a new slot: a duplicate at a
|
||||||
|
* full ring consumes nothing and must still be accepted.
|
||||||
|
* Returns non-zero and holds nothing when there is no room (ring full or byte
|
||||||
|
* budget exhausted); `order_data` below says what the caller then does. */
|
||||||
|
static int hold_packet(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||||
|
uint32_t counter, double recv_time) {
|
||||||
|
int i;
|
||||||
|
int slot = -1;
|
||||||
|
|
||||||
|
/* Duplicate counter: reuse the existing slot to avoid double delivery. */
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (c->held[i].used && c->held[i].counter == counter) {
|
||||||
|
slot = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot >= 0) {
|
||||||
|
/* The replacement releases the length already held, so the budget is
|
||||||
|
* weighed against what the slot will cost rather than what it adds. */
|
||||||
|
if (c->held_bytes - c->held[slot].len + len > UDPS_REORDER_BYTES) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c->held_count >= UDPS_REORDER_SLOTS) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (c->held_bytes + len > UDPS_REORDER_BYTES) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (!c->held[i].used) {
|
||||||
|
slot = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensure_cap((void **)&c->held[slot].buf, &c->held[slot].cap, len) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* If this is a duplicate replacement, subtract the old length first. */
|
||||||
|
if (c->held[slot].used) {
|
||||||
|
c->held_bytes -= c->held[slot].len;
|
||||||
|
c->held_count--;
|
||||||
|
}
|
||||||
|
memcpy(c->held[slot].buf, pl, len);
|
||||||
|
c->held[slot].len = len;
|
||||||
|
c->held[slot].counter = counter;
|
||||||
|
c->held[slot].recv_time = recv_time;
|
||||||
|
c->held[slot].used = 1;
|
||||||
|
c->held_bytes += len;
|
||||||
|
c->held_count++;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Walks the held ring and delivers every entry whose counter matches the
|
||||||
|
* expected sequence, in order. The first delivery carries `first_lost` (so
|
||||||
|
* a caller that committed a gap can report the correct count); every
|
||||||
|
* subsequent delivery in the same drain pass carries lost = 0.
|
||||||
|
*
|
||||||
|
* Clears the gap deadline once the ring empties. */
|
||||||
|
static int drain_held(udps_client_t *c, uint32_t first_lost) {
|
||||||
|
int found;
|
||||||
|
int i;
|
||||||
|
int rc;
|
||||||
|
uint32_t lost;
|
||||||
|
|
||||||
|
lost = first_lost;
|
||||||
|
do {
|
||||||
|
found = 0;
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (c->held[i].used &&
|
||||||
|
(int32_t)(c->held[i].counter - c->next_counter) == 0) {
|
||||||
|
uint32_t cnt = c->held[i].counter;
|
||||||
|
double rt = c->held[i].recv_time;
|
||||||
|
const uint8_t *pl = c->held[i].buf;
|
||||||
|
size_t len = c->held[i].len;
|
||||||
|
/* Release the slot before delivering so deliver_data can call
|
||||||
|
* back into the client without hitting a stale used entry. */
|
||||||
|
c->held_bytes -= len;
|
||||||
|
c->held_count--;
|
||||||
|
c->held[i].used = 0;
|
||||||
|
c->next_counter = cnt + 1u;
|
||||||
|
c->stats.late_resequenced++;
|
||||||
|
rc = deliver_data(c, pl, len, cnt, rt, lost, 0);
|
||||||
|
lost = 0u;
|
||||||
|
if (rc != 0) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
found = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (found);
|
||||||
|
|
||||||
|
if (c->held_count == 0) {
|
||||||
|
c->gap_deadline = 0.0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gives up on the current gap: finds the lowest held counter (by signed delta,
|
||||||
|
* not raw value, so a wrap mid-gap is handled correctly), advances
|
||||||
|
* next_counter to it, then drains. The first delivery out of that drain
|
||||||
|
* carries the gap's lost count; the rest carry 0.
|
||||||
|
*
|
||||||
|
* With nothing held it just clears the deadline and returns. */
|
||||||
|
static int commit_gap(udps_client_t *c) {
|
||||||
|
int i;
|
||||||
|
int best = -1;
|
||||||
|
uint32_t lost;
|
||||||
|
|
||||||
|
if (c->held_count == 0) {
|
||||||
|
c->gap_deadline = 0.0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (!c->held[i].used) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (best < 0 ||
|
||||||
|
(int32_t)(c->held[i].counter - c->held[best].counter) < 0) {
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lost = (uint32_t)(int32_t)(c->held[best].counter - c->next_counter);
|
||||||
|
c->next_counter = c->held[best].counter;
|
||||||
|
return drain_held(c, lost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orders the sequence before spending anything on the payload.
|
||||||
|
*
|
||||||
|
* The counter is a wrapping uint32, so it is ordered by the signed difference;
|
||||||
|
* comparing the values directly would call the first packet after the wrap stale
|
||||||
|
* and reject the stream from then on.
|
||||||
|
*
|
||||||
|
* Three cases. A packet that *continues* the sequence is decoded now and costs no
|
||||||
|
* latency at all -- that is the common case and it must stay free. A packet that
|
||||||
|
* runs ahead of the sequence means an earlier one is missing, so it is held and the
|
||||||
|
* gap is given `reorder_window_s` to resolve; a reorder on the wire resolves in
|
||||||
|
* milliseconds, and holding is what turns it back into order. A packet behind the
|
||||||
|
* sequence lost that race: the consumer already has a hole where it belongs, so it
|
||||||
|
* is delivered flagged rather than discarded, which is the whole point -- a dropped
|
||||||
|
* packet cannot fill the hole it left.
|
||||||
|
*
|
||||||
|
* The full ring is specified here, in one place. A packet that needs a slot and
|
||||||
|
* finds none gives up on the current gap -- `commit_gap()` delivers the lowest held
|
||||||
|
* run and so frees at least one slot -- counts the event in
|
||||||
|
* `stats.reorder_overflows`, and the decision is then taken again, once, against
|
||||||
|
* the sequence that commit left behind. That re-decision is why this is a bounded
|
||||||
|
* loop and not a straight line: the commit advances `next_counter` to the lowest
|
||||||
|
* held counter, which may put it past the arriving packet, and the packet is then
|
||||||
|
* behind the sequence rather than ahead of it. Whatever happens, the arriving
|
||||||
|
* packet is held or delivered; it is never dropped, because nothing else records it
|
||||||
|
* and the consumer would reserve a hole for it that nothing could ever backfill.
|
||||||
|
* Past the cap it is delivered *in order* and not flagged late: a packet ahead of
|
||||||
|
* the sequence has no reserved hole, the consumer looks a late packet's hole up by
|
||||||
|
* counter and ignores one it cannot place, so advancing `next_counter` to it and
|
||||||
|
* reporting the counters it skipped as `lost` is what reserves their hole. */
|
||||||
|
static int order_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||||
|
uint32_t counter, double recv_time) {
|
||||||
|
int32_t delta;
|
||||||
|
int attempt;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (c->cfg.reorder_window_s <= 0.0) {
|
||||||
|
/* Resequencing off: byte-for-byte the behaviour before this task. `delta`
|
||||||
|
* is measured against the *next* counter rather than the last, which is the
|
||||||
|
* same test shifted by one -- `delta < 0` here is `delta <= 0` there. */
|
||||||
|
uint32_t lost = 0u;
|
||||||
|
if (c->have_counter) {
|
||||||
|
delta = (int32_t)(counter - c->next_counter);
|
||||||
|
if (delta < 0) {
|
||||||
|
c->stats.stale_packets++;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
lost = (uint32_t)delta;
|
||||||
|
}
|
||||||
|
c->have_counter = 1;
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, lost, 0);
|
||||||
|
}
|
||||||
|
if (!c->have_counter) {
|
||||||
|
c->have_counter = 1;
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||||
|
}
|
||||||
|
for (attempt = 0; attempt < 2; attempt++) {
|
||||||
|
delta = (int32_t)(counter - c->next_counter);
|
||||||
|
if (delta == 0) {
|
||||||
|
rc = deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
if (delta < 0) {
|
||||||
|
/* Behind the sequence. */
|
||||||
|
c->stats.late_delivered++;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||||
|
}
|
||||||
|
if (hold_packet(c, pl, len, counter, recv_time) == 0) {
|
||||||
|
if (c->gap_deadline == 0.0) {
|
||||||
|
c->gap_deadline = recv_time + c->cfg.reorder_window_s;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
/* No room: give up on the gap rather than growing the ring, and say so
|
||||||
|
* in a counter, so an undersized window is a reading and not a mystery. */
|
||||||
|
c->stats.reorder_overflows++;
|
||||||
|
rc = commit_gap(c);
|
||||||
|
if (rc != 0) {
|
||||||
|
/* The consumer refused a delivery; nothing is freed and nothing more
|
||||||
|
* may be delivered, so the arriving packet is its caller's problem. */
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* The attempt cap is spent and the ring still has no room (the byte budget
|
||||||
|
* cannot fit this one even with a slot free). Deliver it rather than drop it. */
|
||||||
|
delta = (int32_t)(counter - c->next_counter);
|
||||||
|
if (delta < 0) {
|
||||||
|
c->stats.late_delivered++;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||||
|
}
|
||||||
|
rc = deliver_data(c, pl, len, counter, recv_time, (uint32_t)delta, 0);
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
/* Fragment reassembly */
|
/* Fragment reassembly */
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
@@ -579,6 +825,14 @@ static void slot_reset_all(udps_client_t *c) {
|
|||||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||||
c->slots[i].active = 0;
|
c->slots[i].active = 0;
|
||||||
}
|
}
|
||||||
|
/* A reconnect starts a new sequence; payloads held from the previous one
|
||||||
|
* must not be delivered into it. */
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
c->held[i].used = 0;
|
||||||
|
}
|
||||||
|
c->held_count = 0;
|
||||||
|
c->held_bytes = 0;
|
||||||
|
c->gap_deadline = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void gc_slots(udps_client_t *c, double now) {
|
static void gc_slots(udps_client_t *c, double now) {
|
||||||
@@ -596,7 +850,7 @@ static void deliver_payload(udps_client_t *c, uint8_t type, const uint8_t *pl,
|
|||||||
if (type == UDPS_PKT_CONFIG) {
|
if (type == UDPS_PKT_CONFIG) {
|
||||||
(void)decode_config(c, pl, len);
|
(void)decode_config(c, pl, len);
|
||||||
} else {
|
} else {
|
||||||
(void)decode_data(c, pl, len, counter, recv_time);
|
(void)order_data(c, pl, len, counter, recv_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,6 +1160,7 @@ static int do_connect(udps_client_t *c) {
|
|||||||
c->last_data = now_mono();
|
c->last_data = now_mono();
|
||||||
c->last_keepalive = c->last_data;
|
c->last_keepalive = c->last_data;
|
||||||
c->have_counter = 0;
|
c->have_counter = 0;
|
||||||
|
c->next_counter = 0u;
|
||||||
slot_reset_all(c);
|
slot_reset_all(c);
|
||||||
if (c->ever_connected) {
|
if (c->ever_connected) {
|
||||||
c->stats.reconnects++;
|
c->stats.reconnects++;
|
||||||
@@ -981,6 +1236,7 @@ void udps_client_config_init(udps_client_config_t *cfg) {
|
|||||||
cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */
|
cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */
|
||||||
cfg->recv_buffer_bytes = 4u * 1024u * 1024u;
|
cfg->recv_buffer_bytes = 4u * 1024u * 1024u;
|
||||||
cfg->max_packet_bytes = 1u * 1024u * 1024u;
|
cfg->max_packet_bytes = 1u * 1024u * 1024u;
|
||||||
|
cfg->reorder_window_s = UDPS_REORDER_WINDOW_S_DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void copy_str(char *dst, size_t cap, const char *src) {
|
static void copy_str(char *dst, size_t cap, const char *src) {
|
||||||
@@ -992,6 +1248,15 @@ static void copy_str(char *dst, size_t cap, const char *src) {
|
|||||||
dst[cap - 1u] = '\0';
|
dst[cap - 1u] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||||
|
udps_datagram_cb cb, void *user) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client->datagram_cb = cb;
|
||||||
|
client->datagram_user = user;
|
||||||
|
}
|
||||||
|
|
||||||
udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
||||||
udps_client_t *c;
|
udps_client_t *c;
|
||||||
|
|
||||||
@@ -1048,6 +1313,9 @@ void udps_client_destroy(udps_client_t *c) {
|
|||||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||||
free(c->slots[i].payload);
|
free(c->slots[i].payload);
|
||||||
}
|
}
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
free(c->held[i].buf);
|
||||||
|
}
|
||||||
free(c->sigs);
|
free(c->sigs);
|
||||||
free(c->valbuf);
|
free(c->valbuf);
|
||||||
free(c->vals);
|
free(c->vals);
|
||||||
@@ -1151,6 +1419,14 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
|||||||
}
|
}
|
||||||
budget = clamp_deadline(budget, c->cfg.silence_timeout_s, now - c->last_data);
|
budget = clamp_deadline(budget, c->cfg.silence_timeout_s, now - c->last_data);
|
||||||
|
|
||||||
|
/* A held packet must flush on time even when nothing further arrives: at 10 pkt/s
|
||||||
|
* the next arrival is 100 ms away, which is five times the whole budget. */
|
||||||
|
if (c->gap_deadline > 0.0) {
|
||||||
|
double left = c->gap_deadline - now_wall();
|
||||||
|
if (left < 0.0) { left = 0.0; }
|
||||||
|
if (left < budget) { budget = left; }
|
||||||
|
}
|
||||||
|
|
||||||
FD_ZERO(&rset);
|
FD_ZERO(&rset);
|
||||||
maxfd = -1;
|
maxfd = -1;
|
||||||
if (c->udp_fd >= 0 && c->udp_fd < FD_SETSIZE) {
|
if (c->udp_fd >= 0 && c->udp_fd < FD_SETSIZE) {
|
||||||
@@ -1204,6 +1480,11 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
|||||||
}
|
}
|
||||||
drained++;
|
drained++;
|
||||||
c->last_data = now_mono();
|
c->last_data = now_mono();
|
||||||
|
/* The raw datagram, before anything reads out of rxbuf: this is the
|
||||||
|
* only place a malformed or duplicate datagram is still visible. */
|
||||||
|
if (c->datagram_cb != NULL) {
|
||||||
|
c->datagram_cb(c->rxbuf, (size_t)n, now_wall(), c->datagram_user);
|
||||||
|
}
|
||||||
if ((size_t)n >= UDPS_HEADER_SIZE) {
|
if ((size_t)n >= UDPS_HEADER_SIZE) {
|
||||||
handle_packet(c, c->rxbuf, (size_t)n);
|
handle_packet(c, c->rxbuf, (size_t)n);
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1502,10 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
|||||||
}
|
}
|
||||||
gc_slots(c, now_wall());
|
gc_slots(c, now_wall());
|
||||||
|
|
||||||
|
if (c->gap_deadline > 0.0 && now_wall() >= c->gap_deadline) {
|
||||||
|
(void)commit_gap(c);
|
||||||
|
}
|
||||||
|
|
||||||
if (c->cfg.silence_timeout_s > 0.0 &&
|
if (c->cfg.silence_timeout_s > 0.0 &&
|
||||||
(now - c->last_data) >= c->cfg.silence_timeout_s) {
|
(now - c->last_data) >= c->cfg.silence_timeout_s) {
|
||||||
do_disconnect(c, "server went silent");
|
do_disconnect(c, "server went silent");
|
||||||
|
|||||||
Vendored
+59
@@ -150,6 +150,13 @@ typedef struct {
|
|||||||
* range the next frame claims.
|
* range the next frame claims.
|
||||||
*/
|
*/
|
||||||
uint32_t lost;
|
uint32_t lost;
|
||||||
|
/**
|
||||||
|
* 1 when this frame's counter is behind the sequence already delivered: it lost
|
||||||
|
* the race past the reorder window and the consumer has a hole where it belongs.
|
||||||
|
* @c lost is 0 for such a frame -- it is not missing anything, it *is* what was
|
||||||
|
* missing -- so a consumer sizes its backfill from @c counter, not from @c lost.
|
||||||
|
*/
|
||||||
|
uint8_t late;
|
||||||
uint64_t hrt; /**< Producer's high-resolution timer at send. */
|
uint64_t hrt; /**< Producer's high-resolution timer at send. */
|
||||||
double recv_time; /**< Wall-clock seconds (CLOCK_REALTIME) at arrival. */
|
double recv_time; /**< Wall-clock seconds (CLOCK_REALTIME) at arrival. */
|
||||||
uint8_t publish_mode; /**< UDPS_PUBLISH_*. */
|
uint8_t publish_mode; /**< UDPS_PUBLISH_*. */
|
||||||
@@ -180,6 +187,30 @@ typedef struct {
|
|||||||
* time base older than data already handed over.
|
* time base older than data already handed over.
|
||||||
*/
|
*/
|
||||||
uint64_t stale_packets;
|
uint64_t stale_packets;
|
||||||
|
/**
|
||||||
|
* DATA packets that were held waiting for an earlier counter and subsequently
|
||||||
|
* delivered via @c drain_held(). This includes a packet released by the
|
||||||
|
* reorder window expiring: that packet arrives alongside a non-zero @c lost
|
||||||
|
* and therefore alongside a gap, so @c late_resequenced is not by itself a
|
||||||
|
* count of gaps avoided. Compare @c late_delivered, which counts packets
|
||||||
|
* that lost the race past an already-expired window entirely.
|
||||||
|
*/
|
||||||
|
uint64_t late_resequenced;
|
||||||
|
/**
|
||||||
|
* DATA packets delivered behind the sequence because the reorder window had
|
||||||
|
* already closed on them. The consumer has a hole for these and may fill it;
|
||||||
|
* before the window existed they were counted in @c stale_packets and dropped.
|
||||||
|
*/
|
||||||
|
uint64_t late_delivered;
|
||||||
|
/**
|
||||||
|
* Times a DATA packet needed a slot in the reorder ring and found none, so the
|
||||||
|
* current gap was committed early to make room. Every such packet is still held
|
||||||
|
* or delivered -- none is dropped -- but the window it was given was shorter
|
||||||
|
* than @c reorder_window_s asked for. A non-zero reading says the ring is
|
||||||
|
* undersized for the stream's reordering depth, which is a reading and not a
|
||||||
|
* mystery; it is the only place that shows up.
|
||||||
|
*/
|
||||||
|
uint64_t reorder_overflows;
|
||||||
uint64_t reconnects;
|
uint64_t reconnects;
|
||||||
} udps_stats_t;
|
} udps_stats_t;
|
||||||
|
|
||||||
@@ -206,6 +237,20 @@ typedef void (*udps_data_cb)(const udps_frame_t *frame, void *user);
|
|||||||
/** Called on connection state changes and on recoverable errors. */
|
/** Called on connection state changes and on recoverable errors. */
|
||||||
typedef void (*udps_event_cb)(udps_event_t event, const char *detail, void *user);
|
typedef void (*udps_event_cb)(udps_event_t event, const char *detail, void *user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largest datagram recv() can deliver on the client's socket, and therefore the
|
||||||
|
* most a raw-datagram observer ever sees in one call.
|
||||||
|
*/
|
||||||
|
#define UDPS_MAX_DATAGRAM_BYTES (65535 + 17)
|
||||||
|
|
||||||
|
/** Called for every datagram as recv() returned it, before the magic check,
|
||||||
|
* reassembly or resequencing: the bytes a capture has to store, and the only
|
||||||
|
* place malformed or duplicate datagrams are still visible. Runs on the polling
|
||||||
|
* thread, inside udps_client_poll(), like every other callback. `bytes` is
|
||||||
|
* valid until the callback returns. */
|
||||||
|
typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len,
|
||||||
|
double recv_time, void *user);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Transport configuration.
|
* @brief Transport configuration.
|
||||||
*
|
*
|
||||||
@@ -226,6 +271,14 @@ typedef struct {
|
|||||||
double keepalive_interval_s;/**< Unicast ACK period. 0 disables. */
|
double keepalive_interval_s;/**< Unicast ACK period. 0 disables. */
|
||||||
uint32_t recv_buffer_bytes; /**< SO_RCVBUF; large bursts need a large value. */
|
uint32_t recv_buffer_bytes; /**< SO_RCVBUF; large bursts need a large value. */
|
||||||
uint32_t max_packet_bytes; /**< Ceiling on one reassembled payload. */
|
uint32_t max_packet_bytes; /**< Ceiling on one reassembled payload. */
|
||||||
|
/**
|
||||||
|
* How long a packet may be held waiting for an earlier counter, in seconds.
|
||||||
|
*
|
||||||
|
* A *contiguous* arrival is never held, so this is the latency a gap costs and
|
||||||
|
* not a latency the stream pays. 0 disables resequencing entirely and restores
|
||||||
|
* the drop-on-late behaviour. Default 0.020.
|
||||||
|
*/
|
||||||
|
double reorder_window_s;
|
||||||
} udps_client_config_t;
|
} udps_client_config_t;
|
||||||
|
|
||||||
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
||||||
@@ -247,6 +300,12 @@ void udps_client_set_callbacks(udps_client_t *client,
|
|||||||
udps_event_cb on_event,
|
udps_event_cb on_event,
|
||||||
void *user);
|
void *user);
|
||||||
|
|
||||||
|
/** Installs the raw-datagram observer. Separate from
|
||||||
|
* udps_client_set_callbacks(), whose signature has call sites and which a
|
||||||
|
* capture hook is no reason to change. May be NULL. */
|
||||||
|
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||||
|
udps_datagram_cb cb, void *user);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Drives the client: connects if needed, then waits for and processes
|
* @brief Drives the client: connects if needed, then waits for and processes
|
||||||
* packets for at most @p timeout_ms milliseconds.
|
* packets for at most @p timeout_ms milliseconds.
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ window change moves the trigger with it.
|
|||||||
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
||||||
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
||||||
|
|
||||||
|
Each plot has its own **right-click menu**, in the same compact row style as the app's menu:
|
||||||
|
**Y axis: auto scale**, **Y axis: fixed range...**, **Fit X axis**, **Fit all** (`f`), and
|
||||||
|
**Remove signal** — which swaps the menu to one entry per trace on that plot plus *All traces on
|
||||||
|
this plot*, with a **Back** row to return. That is how a trace comes off a row without going through
|
||||||
|
the layout editor.
|
||||||
|
|
||||||
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
|
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
|
||||||
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
|
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
|
||||||
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
||||||
|
|||||||
@@ -34,16 +34,63 @@ deliberate: the entry then always launches exactly the binary that was last buil
|
|||||||
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
||||||
file's basename is that same string, which is what associates a running window with the entry.
|
file's basename is that same string, which is what associates a running window with the entry.
|
||||||
|
|
||||||
|
The interface files can be checked without building anything or opening a window:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target test-ui # or: make -C build test-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
That round-trips every `.ui` through `blueprint-compiler` — `decompile` turns it into blueprint, and
|
||||||
|
compiling *that* type-checks every property and child type against the GIR — printing one line per file.
|
||||||
|
It exists because the alternative is what happens otherwise: a property a class does not have is a
|
||||||
|
`Gtk-ERROR` when the window is built, and `Gtk-ERROR` **aborts the process**, so a typo in a `.ui` takes
|
||||||
|
the application down rather than failing to open a dialog. `preprocess="xml-stripblanks"` catches
|
||||||
|
malformed XML and nothing else, and the probes catch the rest only for the windows they open.
|
||||||
|
|
||||||
|
The XML stays the source of truth and stays hand-written: this is a check, not a compiler, and nothing
|
||||||
|
here is needed to *build* the application. `blueprint-compiler` and `python3` are found at configure
|
||||||
|
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
|
||||||
|
prints the linter's advice, which is advice and deliberately not a gate.
|
||||||
|
|
||||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
|
||||||
|
`--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000,
|
||||||
|
the value is required, and the popover button uses 1000 when the flag was absent).
|
||||||
|
The flag is **not** persisted in the session file: persistence of the enable state
|
||||||
|
belongs with `todo.md` item 3, alongside the relative-time toggle.
|
||||||
|
|
||||||
|
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
||||||
|
|
||||||
|
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the
|
||||||
|
start of every square period from stream second `S` onwards — a runt pulse of a known
|
||||||
|
width, which is what a `<` pulse-width trigger and a glitch search are checked against.
|
||||||
|
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it
|
||||||
|
stops crossing zero: the dropout trigger's input.
|
||||||
|
- `--spike-every S` / `--spike-amp A` / `--spike-width N` inject an alternating-sign
|
||||||
|
impulse train onto `sine` only — the other four signals (`time`, `square`, `ramp`,
|
||||||
|
`noise`) are untouched, which is what lets a probe compare a spiked channel against a
|
||||||
|
clean one in the same run. Every `S` seconds a spike of `N` samples at amplitude `A` is
|
||||||
|
added to `sine`; the sign alternates per period because a one-signed train is a DC offset
|
||||||
|
a median would partly track rather than reject. `N` is in **samples**, not seconds, so its
|
||||||
|
duration follows `--rate`. `0` (the default for both `S` and `A`) injects nothing, so
|
||||||
|
every existing invocation is unchanged. **`--batch` must stay at or below 1000** with or
|
||||||
|
without these options: 2000 samples × 5 signals × `f64` is 80 kB and exceeds UDP's
|
||||||
|
~65507-byte payload limit, and the mock dies on its first `sendto` with
|
||||||
|
`OSError: [Errno 90] Message too long` while the run still exits 0.
|
||||||
|
|
||||||
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
||||||
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
||||||
|
|
||||||
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
||||||
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
||||||
vendored C99 source pair compiled into a static lib.
|
vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained one
|
||||||
|
additive entry point for the packet capture — `udps_client_set_datagram_cb()`, firing
|
||||||
|
inside `udps_client_poll()` immediately after the UDP `recv()` returns, before the
|
||||||
|
magic check or reassembly — plus the public `UDPS_MAX_DATAGRAM_BYTES` the capture
|
||||||
|
sizes its slots from. A separate setter, not a fourth parameter on
|
||||||
|
`udps_client_set_callbacks()`: that signature has call sites, and a capture hook is no
|
||||||
|
reason to change them.
|
||||||
|
|
||||||
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
|
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
|
||||||
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
|
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
|
||||||
@@ -62,6 +109,39 @@ handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), s
|
|||||||
handler clears either pointer without knowing which dialog it is. Presenting either on this
|
handler clears either pointer without knowing which dialog it is. Presenting either on this
|
||||||
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||||
|
|
||||||
|
`deps/udpsclient` is a vendored C99 source pair and its diff from the upstream source
|
||||||
|
is intentional. Five things were changed:
|
||||||
|
|
||||||
|
1. **A reorder ring of reassembled payloads.** When a packet arrives with a higher counter
|
||||||
|
than expected, the raw datagram is held in a fixed-size ring rather than decoded immediately.
|
||||||
|
A deadline (`now + reorder_window_s`, default 0.020 s) is set for the gap; the deadline is
|
||||||
|
served by a third `clamp_deadline()` call on the `select()` budget
|
||||||
|
(`udps_client.c:1145-1152`) so a quiet stream does not wait for the next arrival.
|
||||||
|
2. **Late delivery replacing the outright discard.** A packet that arrives past the window — or
|
||||||
|
when the ring is drained by the deadline — is delivered to the callback flagged `late` rather
|
||||||
|
than discarded with `stats.stale_packets++; return 0`. That flag is what makes backfill
|
||||||
|
possible at all; without it the samples are gone before udpscope can act.
|
||||||
|
3. **The third `clamp_deadline()`.** The existing pair handles the keepalive and the silence
|
||||||
|
timeout; the reorder deadline is a third call to the same helper, which already understood
|
||||||
|
the pattern.
|
||||||
|
4. **`reorder_window_s = 0` as the switch.** `udps_client_config_t::reorder_window_s`,
|
||||||
|
defaulted to `UDPS_REORDER_WINDOW_S_DEFAULT` (0.020) in `udps_client_config_init()`,
|
||||||
|
is the only knob. Setting it to zero disables resequencing and restores the previous
|
||||||
|
discard-on-late behaviour exactly — so a reported regression is bisectable without reverting
|
||||||
|
the source. **There is no command-line flag for the reorder window.** It is not surfaced on
|
||||||
|
udpscope's command line; do not document a `--reorder-window-ms`.
|
||||||
|
5. **The full ring never drops the arriving packet, and says so in a counter.** A packet that
|
||||||
|
needs a slot and finds none commits the current gap (which frees at least one, since
|
||||||
|
`commit_gap()` always delivers) and the ordering decision is taken once more against the
|
||||||
|
sequence that commit left behind — a bounded retry, because the commit can advance
|
||||||
|
`next_counter` past the arriving counter and turn an ahead-of-sequence packet into a behind-
|
||||||
|
of-sequence one. Past the retry it is delivered *in order*, with the counters it skipped as
|
||||||
|
its `lost`, and never flagged `late`: a packet ahead of the sequence has no reserved hole and
|
||||||
|
`Acquisition::backfill_from()` silently ignores a late packet it cannot place, so flagging it
|
||||||
|
would be the same silent loss by another route. `stats.reorder_overflows` counts the event,
|
||||||
|
so an undersized ring is a reading rather than a mystery; it is surfaced in the exit dump and
|
||||||
|
the `[reseq-probe]` line as `overflows=`.
|
||||||
|
|
||||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||||
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
||||||
@@ -85,7 +165,16 @@ because a tick a hair under a rung's top rounds to 1000 at three significant fig
|
|||||||
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
||||||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
||||||
`format_time_never_lets_a_field_reach_its_radix`. The minor
|
`format_time_never_lets_a_field_reach_its_radix`. The box-zoom selection was the one gesture with no
|
||||||
|
feedback at all: the controller tracked the rect and nothing drew it. The drawing went upstream with
|
||||||
|
the rest — `Plot::set_selection_rect()` (the plot carries a rect the host hands it and learns nothing
|
||||||
|
about pointers), `Controller::selection_preview()` (the mask decides which axis is *not* zoomed, and
|
||||||
|
the preview is widened along it, because the swept sliver of a horizontal Ctrl drag is the gesture and
|
||||||
|
the band across the plot is what the release will apply) and `Renderer::draw_selection_frame()` — and
|
||||||
|
it carries three tests: `a_ctrl_box_zoom_previews_the_band_it_will_zoom`,
|
||||||
|
`the_selection_frame_is_carried_and_taken_off` and `the_box_zoom_frame_lands_exactly_where_it_says`,
|
||||||
|
the last of which reads the headless framebuffer back and compares the same pixels with and without
|
||||||
|
the frame. The minor
|
||||||
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
||||||
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
||||||
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
||||||
|
|||||||
@@ -12,6 +12,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
`plan_partition()` rather than recomputing the factor, because two expressions for that
|
`plan_partition()` rather than recomputing the factor, because two expressions for that
|
||||||
decision is how the buffer and the view end up disagreeing about the stored rate. It
|
decision is how the buffer and the view end up disagreeing about the stored rate. It
|
||||||
takes `mutex()` itself — do not call it holding the lock.
|
takes `mutex()` itself — do not call it holding the lock.
|
||||||
|
- **A rebuild that would plan the *same* storage clears the store instead of allocating one.**
|
||||||
|
`rebuild_locked()` compares the new `Partition` against the current one with
|
||||||
|
`Partition::same_storage_as()` — every field that decides an allocation: `time_capacity`,
|
||||||
|
`time_stride`, `abs_time`, `base_dt` and the per-channel `stride`/`value_capacity` vectors — and
|
||||||
|
on a match calls `BufferStore::reset()` rather than constructing a new one. `base_dt` is in the
|
||||||
|
comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples
|
||||||
|
under a changed `base_dt` would re-date the whole ring.
|
||||||
|
**Construction is the cost.** A `RingBuffer<T>` holds a `std::vector<std::atomic<T>>`, which is
|
||||||
|
value-initialised, so making a store touches every byte of `--buffer-mb` — about a second per
|
||||||
|
gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an
|
||||||
|
`invalidate()` and an epoch back to zero. It must leave nothing of the old history behind, which
|
||||||
|
is why it clears the time ring, the absolute-time ring, every value ring, and both the published
|
||||||
|
window and the pyramid of every channel: a reused store that kept a stale published window would
|
||||||
|
hand the renderer samples the new plan does not describe.
|
||||||
|
`reset()` runs on the consumer thread under `ScopeModel::mutex()`, which is what makes a
|
||||||
|
`RingBuffer::clear()` safe here at all — the producer holds that same mutex for the whole of
|
||||||
|
`on_data()`.
|
||||||
|
Every allocation that *is* made says so: `[udpscope] buffer allocated: N channels, S s span,
|
||||||
|
T ms` on stderr. A start used to emit fifteen of those; two is a real start, and a count that
|
||||||
|
climbs again is this rule having stopped applying.
|
||||||
|
- **Enables are applied in one batch, because each one re-plans.** `set_signals_enabled()` takes
|
||||||
|
the mutex once, moves every signal in the list, and re-plans and bumps the generation **once** —
|
||||||
|
and not at all when nothing actually moved, which is the common case for a restored session
|
||||||
|
re-enabling what is already on. Calling `set_signal_enabled()` in a loop is a buffer rebuild per
|
||||||
|
signal; `apply_layout()` and the waveform loader collect their wanted signals into one vector for
|
||||||
|
that reason, and `apply_layout()` records the session's span **first**, with no channels bound, so
|
||||||
|
the `apply_time_window()` that follows re-plans nothing.
|
||||||
- **The shared clock is decimated to fit the requested window; channels never are.** A
|
- **The shared clock is decimated to fit the requested window; channels never are.** A
|
||||||
window too long to store at the wire rate used to be refused (the control was capped to
|
window too long to store at the wire rate used to be refused (the control was capped to
|
||||||
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
|
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
|
||||||
@@ -36,6 +63,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
|
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
|
||||||
narrower than a group is now filtered away rather than aliased, which is the correct
|
narrower than a group is now filtered away rather than aliased, which is the correct
|
||||||
trade and is why the status line's `rate` field is worth reading.
|
trade and is why the status line's `rate` field is worth reading.
|
||||||
|
- **A file is ingested too, so the loader decimates by the same factor.** `load_waveform_into_model()`
|
||||||
|
sets the target span to the recording's own length and enables every column, and the plan that
|
||||||
|
comes back may well decimate: 20 s of 10 channels at 500 kS/s is 480 MB of rings, and the budget is
|
||||||
|
`--buffer-mb`. The loader therefore reads `partition().time_stride` **after** the enables and pushes
|
||||||
|
one slot per that many rows, carrying the group's mean at the group's mean stamp — the packet path's
|
||||||
|
filter, written a second time because a file does not arrive in packets. Pushing every row instead
|
||||||
|
overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event
|
||||||
|
opened as its last 4 s, `stored=10000000` against `published=2097152` in the channel dump, and a
|
||||||
|
trace that looks like a short recording rather than a truncated one. The `waveform loaded:` line
|
||||||
|
carries `rows=` and `decim=` beside `samples=` for exactly this reason — `samples * decim == rows`
|
||||||
|
is the arithmetic that says the whole file is in the buffer.
|
||||||
|
- **One bad packet is bridged; only a run of them changes the time source.** The stream-time
|
||||||
|
checks in `build_stamps()` reject three things: stamps that are not finite, a packet with no
|
||||||
|
span at all, and a packet whose stamps do not advance past the previous packet's last. A
|
||||||
|
fourth was added because a real device's clock glitches: a packet whose stamps jump **more
|
||||||
|
than 256 packet spans** ahead is a fault, not a gap, and must be rejected — letting it
|
||||||
|
through poisons `last_stamp_`, and the monotonic clamp (`max(stamps[k] - epoch,
|
||||||
|
last_stamp_)`) then pins every later packet flat at the glitched value, so the whole stream
|
||||||
|
freezes at one x and never recovers. A rejected packet is **bridged**: its stamps are
|
||||||
|
replaced by a continuation of the accepted timeline at the declared cadence, so neither the
|
||||||
|
monotonic mark nor `last_stream_anchor_` is poisoned and the next good packet is accepted
|
||||||
|
normally. Believing the glitch instead is what condemned the *next*, perfectly good packet
|
||||||
|
(it now sits below the poisoned anchor, so it fails the advance test) and threw the whole
|
||||||
|
stream onto the arrival clock. Only **four consecutive** rejected packets abandon the
|
||||||
|
declared time signal, because that is what a signal which is not a time base at all looks
|
||||||
|
like; a first packet with nothing to bridge from still falls back immediately. The unit
|
||||||
|
scale ('ns', 'us', 'ms') is therefore resolved **before** these checks rather than after:
|
||||||
|
they weigh the stamps against a cadence in seconds, and on a 'ns' stream an unscaled
|
||||||
|
comparison is off by 1e9, so every packet would read as an implausible jump. The guard is
|
||||||
|
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
|
||||||
|
[verification.md](verification.md)).
|
||||||
|
- **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from
|
||||||
|
`last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two
|
||||||
|
timelines share a zero by construction — the recorded stamps are therefore still on one
|
||||||
|
timeline and are kept. The fallback used to call `reset_live_history_locked()` on the
|
||||||
|
reasoning that the old stamps belonged to a dead clock; they do not, and that wipe is what
|
||||||
|
made a single glitching packet look like "the live stream restarted from 0 with no action
|
||||||
|
from the user". The continuity is also what makes a rebase of `TriggerEngine` unnecessary:
|
||||||
|
the engine holds four absolute stamps (`holdoff_until_s_`, `pending_start_s_`,
|
||||||
|
`last_trigger_s_`, `last_t_s_`) and they stay valid across the change. Anything that makes
|
||||||
|
the clock *discontinuous* breaks that and has to reset them.
|
||||||
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
|
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
|
||||||
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
|
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
|
||||||
is not in the buffer would be cut from data that does not contain it.
|
is not in the buffer would be cut from data that does not contain it.
|
||||||
@@ -66,6 +134,40 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one
|
the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one
|
||||||
value). Three steps at 2, 2.5 and
|
value). Three steps at 2, 2.5 and
|
||||||
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||||||
|
- **Append is not the only write.** A backfill amends slots that a reservation wrote as NaN, and
|
||||||
|
the amend is split across threads for a reason:
|
||||||
|
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)` is a single relaxed
|
||||||
|
store, bounds-checked against `[produced_ - capacity, produced_)`. The same race class as a
|
||||||
|
push, and an `f32` cannot tear. Measurements and export read the ring directly, so they see
|
||||||
|
a repair immediately and no second notification is needed.
|
||||||
|
- **Pyramid amend — consumer (GTK main thread), inside `ChannelBuffer::acquire()`.** The
|
||||||
|
producer collects amended values in a plain `std::vector<Amend>` member and the consumer
|
||||||
|
drains it after its own append pass. `acquire()` is called under `model.mutex()`, and the
|
||||||
|
producer holds that same mutex for all of `on_data`, so the vector is correctly serialised
|
||||||
|
with no lock-free queue. The reason the pyramid amend cannot also be on the producer: a
|
||||||
|
bucket is a 32-byte non-atomic aggregate that the renderer reads **unlocked at paint time**,
|
||||||
|
so a producer writing it could be caught mid-bucket — a `count` that does not match its
|
||||||
|
`sum`, producing a wrong mean from a structure whose readers trust `count == 0` to mean
|
||||||
|
*absent*. On the consumer that cannot happen.
|
||||||
|
- **A hole is stamped at reservation; backfill changes values only.** The f64 time ring is
|
||||||
|
never amended. `neighbours()' binary search requires a monotonic array, and `window_x0_`
|
||||||
|
must remain valid; pre-stamping the hole at the declared cadence (exact in relative mode,
|
||||||
|
the bridge mechanism in stream mode) is what keeps both invariants, and it is why a late
|
||||||
|
packet's counter, not its arrival stamp, is what addresses the hole. The reservation cap
|
||||||
|
(`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost
|
||||||
|
is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100,
|
||||||
|
2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256.
|
||||||
|
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
|
||||||
|
the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin`
|
||||||
|
describe a buffer that is gone; it therefore clears `holes_` and bumps
|
||||||
|
`ScopeModel::plan_generation()`, which the producer compares against the value it last saw
|
||||||
|
and resets `tick_` on. That is what keeps `tick_ / period == produced()`, the relation
|
||||||
|
`backfill_from()` derives a slot index from — without it the amend falls outside the ring
|
||||||
|
and repairs nothing while the hole is still marked repaired, a band saying the opposite of
|
||||||
|
the truth. The generation is the signal and the stride/channel-count comparison is not: a
|
||||||
|
reconnect with an identical CONFIG rebuilds the store leaving both unchanged. `slot_base_`
|
||||||
|
is **not** reset — it is relative mode's clock origin, samples since the stream began, and
|
||||||
|
relative time must not jump backwards when the user retypes the window.
|
||||||
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||||
the published window to the value ring's base and both rings keep sliding afterwards —
|
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||||
their capacities are equal, so there is no slack — which means the oldest few samples
|
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||||
@@ -110,3 +212,93 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
|
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
|
||||||
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
|
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
|
||||||
symptom and deliberately not fixed here.
|
symptom and deliberately not fixed here.
|
||||||
|
|
||||||
|
## The packet capture sits at `recv()`, and its file is reserved, not sparse
|
||||||
|
|
||||||
|
Every wire behaviour a forensic capture exists to answer — a counter that skips, a datagram that
|
||||||
|
arrives twice, a fragment that never completes, a CONFIG whose header does not parse — is
|
||||||
|
invisible by the time a `udps_frame_t` reaches `Acquisition::on_data`: the client has already
|
||||||
|
validated, reassembled, resequenced and dropped. The capture therefore hooks
|
||||||
|
`udps_client_set_datagram_cb()`, which the client fires immediately after the UDP `recv()`
|
||||||
|
returns and before anything reads out of `rxbuf`, and it stores the bytes without interpreting
|
||||||
|
them. The capture is of the *UDP socket*: in multicast mode CONFIG arrives over TCP and is out
|
||||||
|
of reach.
|
||||||
|
|
||||||
|
The ring file is reserved with `posix_fallocate` rather than left sparse because the stores are
|
||||||
|
`memcpy`s into an `mmap`: a store into a page the filesystem cannot back raises **SIGBUS**, which
|
||||||
|
kills udpscope outright. An out-of-space condition has to be an error at the moment the user
|
||||||
|
toggles — reported in the popover row, the button back off — not a crash ten minutes into the
|
||||||
|
run the capture was switched on for. Within the reserved file, a slot's trailing `seq` word is
|
||||||
|
published with a release store *after* its bytes are down, so a slot carrying a non-zero `seq`
|
||||||
|
is complete and no `fsync` is needed for the file to be readable after a crash: the kernel's
|
||||||
|
dirty pages are the file, and a half-copied slot still reads `seq == 0`.
|
||||||
|
|
||||||
|
## A reconnect with an unchanged CONFIG keeps the buffer
|
||||||
|
|
||||||
|
A disconnect -- EMI makes it a silence timeout, not a polite DISCONNECT -- must not cost the
|
||||||
|
acquisition. What historically did was the reconnect's CONFIG: `ScopeModel::set_signals()` rebuilt
|
||||||
|
the store on *every* CONFIG, and a rebuild is what drops history, resets `tick_`, retires the
|
||||||
|
holes and invalidates a held event. Now the incoming CONFIG is compared against the stream the
|
||||||
|
model already has -- names, shapes, rates, ranges, time modes, time-signal indices -- and an
|
||||||
|
**identical CONFIG returns early**: the ring keeps its history, the trigger keeps its state and
|
||||||
|
its held event, and the outage shows as the gap in the data that it was. Only a CONFIG that
|
||||||
|
*changes* the stream re-plans.
|
||||||
|
|
||||||
|
The producer's own state is per *connection*, and carrying it across a drop makes recovery read
|
||||||
|
as failure: `Acquisition::reset_session_state()` (on DISCONNECTED, and again on the next CONFIG)
|
||||||
|
clears the counter mark, the stream-time anchor and the smoothed arrival interval, so the first
|
||||||
|
frame of the new session re-anchors instead of reading as late, as a glitch or as an outage. The
|
||||||
|
arrival clock's *anchor* deliberately stays -- stamps are small seconds the ring's timeline is
|
||||||
|
already scaled against, and re-anchoring mid-stream would move x. The clock *choice* is re-derived
|
||||||
|
per connection (`time_source_ = 0` on CONFIG) for the same reason: a session that dropped may
|
||||||
|
come back with a different answer.
|
||||||
|
|
||||||
|
Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's
|
||||||
|
chosen origin, and must not jump backwards because the link blipped.
|
||||||
|
|
||||||
|
## There may be no store at all
|
||||||
|
|
||||||
|
`rebuild_locked()` calls `store_.reset()` for a stream with **no channels** — the model's state until the
|
||||||
|
first CONFIG arrives — so `store_` is legitimately null, and `ScopeModel::live()` dereferences it. Every
|
||||||
|
path reachable without a stream has to ask first: **pausing a scope that is not connected** is one (the
|
||||||
|
button works, and freezing a view means taking a window from the rings), and it crashed inside
|
||||||
|
`live_window_locked()` on a null store. The guards belong at the entry points the UI can reach
|
||||||
|
(`live_window_locked()`, `publish_pause_view()`), not in `live()`, which cannot express "nothing there"
|
||||||
|
through a reference. The paths that only run for a *bound* trace — the series' gather, the SlotSeries'
|
||||||
|
`shared_channel()` — are safe by construction: a slot with no channel binds no series at all.
|
||||||
|
`UDPSCOPE_PROBE_PAUSE_MS` against a port nothing is listening on is the check.
|
||||||
|
|
||||||
|
## A waveform is a source that never arrives
|
||||||
|
|
||||||
|
`--open FILE` (and the chooser's Waveforms page) puts an exported `.npy` into the model instead of a
|
||||||
|
stream: one signal and channel per column, the samples pushed through `push_loaded_slot()` — the
|
||||||
|
producer's own order (values, the shared clock, the absolute clock, then the derived channels), under
|
||||||
|
the model's lock — and the display epoch pinned to the file's last stamp so the axis reads
|
||||||
|
`[-window, 0]` exactly as the export did. Everything downstream is unchanged, which is the point:
|
||||||
|
cursors, measurements, math channels and another export work on a file because they work on the
|
||||||
|
rings, not on the wire.
|
||||||
|
|
||||||
|
Three things follow, and each is a place the live assumptions would otherwise leak:
|
||||||
|
|
||||||
|
- **Nothing is ever starved.** There is no socket and no source, so the starvation notice is skipped
|
||||||
|
while a waveform is open, and the popover's Stream row names the file rather than reporting a
|
||||||
|
connection that has not answered.
|
||||||
|
- **The loader must not hold the model's mutex.** Every setter it needs (`set_signals`,
|
||||||
|
`set_signal_enabled`, `set_target_span`) takes that same non-recursive lock; the first version
|
||||||
|
wrapped the whole load in it and deadlocked on the first call. The per-slot push is a model method
|
||||||
|
for the same reason.
|
||||||
|
- **The signal names come from the file.** A `.npy` written by `write_npy()` is a structured array
|
||||||
|
whose dtype names its fields, so an export is self-describing and there is no sidecar to lose.
|
||||||
|
`.csv` is not read: it carries the names too, but as text, and the item asked for the binary one.
|
||||||
|
- **A waveform has no source, and the app must not invent one.** The address the command line
|
||||||
|
*defaults* to (`127.0.0.1:8000`) is not one: leaving it in place made a file-mode run load — and
|
||||||
|
then *save* — that source's session, which overwrote the arrangement the file was opened with
|
||||||
|
(every drop then refused, the row full) and could clobber a real session. `App::source_port` is 0
|
||||||
|
in file mode, which is what makes `save_layout_now()` refuse and the first-CONFIG layout load skip
|
||||||
|
— the same two things that make a source-less run safe.
|
||||||
|
- **One row per column.** Four columns to one row fills the default single row and then refuses
|
||||||
|
every drop ("plot 1 already holds four signals"), which is a loaded file the user cannot plot
|
||||||
|
anything *else* on or rearrange. One per row (sized in `main.cpp`, before the UI exists, as
|
||||||
|
`--plots` does) leaves three free slots each, shows a four-signal file as four stacked traces, and
|
||||||
|
keeps the rest of the columns draggable. Sizing the rows from inside the activation instead leaves
|
||||||
|
the panels built for the old count and the app hangs on the first relayout.
|
||||||
@@ -18,6 +18,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
||||||
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
||||||
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
||||||
|
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
|
||||||
|
added in the middle of either silently shifts every argument after it: add at the end of the widget group
|
||||||
|
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
|
||||||
|
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
|
||||||
|
says so as an out-of-bounds warning on `rows`.
|
||||||
|
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
|
||||||
|
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
|
||||||
|
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
|
||||||
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
||||||
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
||||||
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
||||||
@@ -42,12 +50,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
||||||
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
||||||
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
||||||
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
|
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the
|
||||||
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
||||||
measurement ruler, and the stock names those buttons used to carry said something else
|
measurement ruler, and the stock names those buttons used to carry said something else
|
||||||
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
||||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math,events}-symbolic.svg`
|
||||||
are the four action icons; beside them in `src/icons/scalable/apps/` are the two
|
are the five action icons; beside them in `src/icons/scalable/apps/` are the two
|
||||||
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
|
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
|
||||||
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
|
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
|
||||||
application's own icon and is where the **in-process** window icon resolves from; it is also
|
application's own icon and is where the **in-process** window icon resolves from; it is also
|
||||||
@@ -156,6 +164,251 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
||||||
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
||||||
with a silent gap where its screenshots should be — which is what the first version did.
|
with a silent gap where its screenshots should be — which is what the first version did.
|
||||||
|
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
|
||||||
|
Putting the events pane in a paned is what makes its width the user's to drag -- but with the
|
||||||
|
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
|
||||||
|
while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that
|
||||||
|
says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before*
|
||||||
|
zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is
|
||||||
|
still needed, or the minimum would clamp even a zero position.
|
||||||
|
- **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child
|
||||||
|
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
|
||||||
|
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
|
||||||
|
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
|
||||||
|
window. Every vertical revealer in the side panel hides this, because there the leftover goes down
|
||||||
|
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
|
||||||
|
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
|
||||||
|
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
|
||||||
|
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
|
||||||
|
choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the
|
||||||
|
list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four
|
||||||
|
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
|
||||||
|
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
|
||||||
|
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
|
||||||
|
widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler
|
||||||
|
that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a
|
||||||
|
widget.
|
||||||
|
- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.**
|
||||||
|
A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it
|
||||||
|
out of the row -- the box gives a non-expanding child its natural size and grows it further when
|
||||||
|
the expanding child has nothing left to take. Give the picture a **frame** whose size request equals
|
||||||
|
the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and
|
||||||
|
scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather
|
||||||
|
than 300.
|
||||||
|
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
|
||||||
|
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
|
||||||
|
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
|
||||||
|
read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it.
|
||||||
|
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
|
||||||
|
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
|
||||||
|
picture keeps a *paint-able* and takes an allocation, so a probe that checks
|
||||||
|
`gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event
|
||||||
|
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
|
||||||
|
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
|
||||||
|
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
|
||||||
|
alone asks for no height at all in a box.
|
||||||
|
- **A cache that is the *only* source of a widget's content must be created by the thing that reads
|
||||||
|
it.** The event pane's thumbnails come from an `EventThumbCache` hung off the list box with
|
||||||
|
`g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all.
|
||||||
|
Nothing ever created it — so for as long as the pane existed, **every** row came up without a
|
||||||
|
picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache
|
||||||
|
there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first
|
||||||
|
use. Owned by the **list**, because nothing else has the pane's lifetime: textures of a list that
|
||||||
|
no longer exists are so much held memory, and the destroy notify is where they are unref'd.
|
||||||
|
- **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and
|
||||||
|
the stored preview is 480: the small one says *which* event this is, and the zoom says what is in
|
||||||
|
it. GTK's own tooltip (`gtk_widget_set_has_tooltip` + `query-tooltip` + `gtk_tooltip_set_custom`)
|
||||||
|
is shown, placed, dismissed **and destroyed** by GTK, and takes no pointer grab. A popover per row
|
||||||
|
would be a parented widget to unparent on every rebuild of the pane — one defect below — and a
|
||||||
|
grab to lose, for a thing with no interaction in it. Two details: `can_shrink` must be FALSE on the
|
||||||
|
tooltip's picture or the tooltip is given the smallest size its content allows (the opposite of the
|
||||||
|
point), and the path handed to the handler has to be **owned by the closure**
|
||||||
|
(`g_signal_connect_data` with a destroy func), since the record it came from is a loop variable.
|
||||||
|
- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go
|
||||||
|
through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there
|
||||||
|
drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed
|
||||||
|
memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu
|
||||||
|
entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore
|
||||||
|
defers the unparent by one idle and holds a reference across it
|
||||||
|
(`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the
|
||||||
|
emission that dismissed it.
|
||||||
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
||||||
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
||||||
Caps Lock is on.
|
Caps Lock is on.
|
||||||
|
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live
|
||||||
|
in `src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
|
||||||
|
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||||
|
|
||||||
|
`preprocess="xml-stripblanks"` catches a *malformed* file at build time -- which is why it is on
|
||||||
|
every file in every bundle -- and that includes an XML rule easy to break by hand: **a comment may
|
||||||
|
not contain a double hyphen**, so prose written the way every C++ comment in this project is written
|
||||||
|
(` -- like this -- `) fails the build with a caret under the offending character. Use an em dash in a
|
||||||
|
UI file.
|
||||||
|
|
||||||
|
What it cannot catch is a file that is valid XML but wrong for GTK, and that is what
|
||||||
|
`cmake --build build --target test-ui` is for: it round-trips every file through
|
||||||
|
`blueprint-compiler`, which type-checks properties and child types against the GIR -- for *every*
|
||||||
|
file, rather than for the windows a probe happens to open (see
|
||||||
|
[build-and-run.md](build-and-run.md)). Without that check, `GtkBuilder` reports such a file with a
|
||||||
|
**`Gtk-ERROR`, which aborts the process** by default, so a window built from bad markup takes the
|
||||||
|
application down rather than failing to appear.
|
||||||
|
|
||||||
|
Two more paid for: a `GIcon` property takes an **object**, not a name
|
||||||
|
(`<property name="icon">foo</property>` is read as a reference to another object and fails with
|
||||||
|
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`); and a `.ui` big enough to
|
||||||
|
exceed 4095 characters needs `-Wno-overlength-strings` on its generated source, because
|
||||||
|
glib-compile-resources writes each file as one C string literal and the warning fires on generated
|
||||||
|
*data*.
|
||||||
|
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
|
||||||
|
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
|
||||||
|
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
|
||||||
|
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
|
||||||
|
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
|
||||||
|
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
|
||||||
|
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
|
||||||
|
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
|
||||||
|
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
|
||||||
|
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
|
||||||
|
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
|
||||||
|
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
|
||||||
|
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
|
||||||
|
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
|
||||||
|
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
|
||||||
|
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
|
||||||
|
cancellable will do.
|
||||||
|
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
|
||||||
|
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
|
||||||
|
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
|
||||||
|
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
|
||||||
|
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
|
||||||
|
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
|
||||||
|
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
|
||||||
|
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
|
||||||
|
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
|
||||||
|
draws them only as a titlebar.
|
||||||
|
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
|
||||||
|
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
|
||||||
|
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
|
||||||
|
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
|
||||||
|
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
|
||||||
|
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
|
||||||
|
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
|
||||||
|
the probe that opens every window rather than by anything at build time.
|
||||||
|
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
|
||||||
|
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
|
||||||
|
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
|
||||||
|
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
|
||||||
|
means different things on the two sides. Both are plain members of their owners, on the same stack
|
||||||
|
frame, so nothing can outlive the frame that built it.
|
||||||
|
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
|
||||||
|
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
|
||||||
|
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
|
||||||
|
whose handlers point into a returned stack frame is how this project already earned one
|
||||||
|
use-after-free.
|
||||||
|
|
||||||
|
- **A `_` in a label is a mnemonic only on a widget that was asked for one.** GTK reads the underscore as
|
||||||
|
"the next letter is this widget's accelerator, and draw it underlined", so wherever the widget has not
|
||||||
|
been asked, GTK draws the underscore *literally* -- which is what `_Directory`, `_Server`, `Keep _events`
|
||||||
|
and eleven more titles did in the two preference forms, for as long as they existed. Two different
|
||||||
|
switches, FALSE by default in both cases: `GtkLabel`/`GtkButton` take `use-underline`, and the rows take
|
||||||
|
**`AdwPreferencesRow:use-underline`** ("whether an embedded underline in the title indicates a mnemonic").
|
||||||
|
The row classes that carry it are not all related -- `AdwEntryRow` has its own, while
|
||||||
|
`AdwSpinRow`/`AdwComboRow`/`AdwSwitchRow` get it through `AdwActionRow` -- so `row_underlines()` asks by
|
||||||
|
name on a `GObject` (`g_object_class_find_property`, then `g_object_get`) instead of casting to one row
|
||||||
|
type: casting a row whose type is only forward-declared in that translation unit does not even compile.
|
||||||
|
`test-ui` is what catches the property being set on a class that does not have it -- it validates every
|
||||||
|
property against the GIR, inherited ones included.
|
||||||
|
- **A mnemonic is unique per window, and the first match wins.** `_Cancel` and `_Connect` both claimed C in
|
||||||
|
one form, so `Alt+C` could only ever reach one of them and the other's marker was unreachable -- worse
|
||||||
|
than no mnemonic, because the underscore is there in the source for a reader to trust. `Conn_ect` freed
|
||||||
|
it. Rows on different pages of a preferences dialog may share a letter: an unshown page cannot be
|
||||||
|
activated.
|
||||||
|
|
||||||
|
- **A `GtkExpander`'s child is not in the widget-parent chain.** `gtk_widget_get_parent()` on the widget an
|
||||||
|
expander holds answers NULL, before *and* after the window is presented, while `gtk_expander_get_child()`
|
||||||
|
returns it -- measured all three ways (the `.ui`'s `child` property, a code `gtk_expander_set_child()`, a
|
||||||
|
code-made expander). So a probe that walks parents to prove a widget is inside a panel reads 0 for
|
||||||
|
everything behind an expander, on a panel that is entirely correct; the walk has to start at
|
||||||
|
`gtk_expander_get_child()`. That mistake cost a false regression, a "the property does not attach it"
|
||||||
|
conclusion drawn from it, and a pointless second root in the file before the throwaway program settled it.
|
||||||
|
Related: `gtk_expander_set_child()` asserts that its child has no parent yet, so a block the `.ui` has
|
||||||
|
already put in the panel cannot then be handed to an expander -- the file's `child` property is the right
|
||||||
|
way, and it works.
|
||||||
|
|
||||||
|
- **`--` is not allowed inside an XML comment, and a `.ui` comment is XML.** `<!-- ... -- ... -->` is a parse
|
||||||
|
error: `xml-stripblanks` fails the build, and `make test-ui` prints `FAIL <file>` with **no** message under
|
||||||
|
it, because blueprint-compiler's decompiler aborts on the malformed XML before it can report anything --
|
||||||
|
the bare `FAIL` line is that mistake and nothing else. The house style for a dash in prose is the em dash,
|
||||||
|
which is why no other `.ui` in `src/ui/` contains a double hyphen. It has cost two slices a debugging
|
||||||
|
round for one character.
|
||||||
|
|
||||||
|
- **A `.ui` file instantiates its objects once, so anything whose *count* is data cannot live in one.** The
|
||||||
|
layout editor's overlay buttons are one per row, plus one per boundary between them: a `GtkBuilder` would
|
||||||
|
hand back the first and nothing for the rest, and there is no "prototype the code copies" in the format.
|
||||||
|
They are therefore code, and what they share -- the icon, the two CSS classes that make a small round
|
||||||
|
on-screen-display control, and the size -- is one function (`layout_overlay_button()`) rather than three
|
||||||
|
copies. The same reason keeps the math editor's palette and parameter rows in code: their count follows the
|
||||||
|
model, not the file. `.ui` files are for the frame, and the frame is what has no count.
|
||||||
|
|
||||||
|
- **A code-added child of a header bar lands *after* the file's, so going first means using a slot.**
|
||||||
|
`gtk_header_bar_pack_start()` appends to the start group and there is no prepend. The time-window control is
|
||||||
|
code -- its menu's rows come from the buffer's limits -- and belongs at the bar's head, so `header-bar.ui`
|
||||||
|
declares an empty `window_control_slot` box as its first start child and the code appends the control into
|
||||||
|
it. Packing the control straight onto the bar would have put it to the right of everything the file packed,
|
||||||
|
and `[udpscope] header:` (`window_button_w=130 bar_w=756`) is the reading that shows such a move.
|
||||||
|
- **`<child type="end">` is added leftwards-from-the-right, in document order.** A header bar's end group
|
||||||
|
reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the
|
||||||
|
screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong
|
||||||
|
once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check.
|
||||||
|
|
||||||
|
- **A widget can end up in the wrong container, and only a *structural* read notices.** `math_body` was
|
||||||
|
written as a `<child>` of the *window* instead of the box above it when the math editor's frame moved into a
|
||||||
|
`.ui`. A `GtkWindow` takes exactly one default `<child>`, so nothing failed: the body -- with the canvas and
|
||||||
|
the parameter form inside it -- was left parentless, `visible` but never `mapped`, size `0x0`, drawing
|
||||||
|
nothing. The symptom was "the editor opens and the canvas is blank", with the window sized around its
|
||||||
|
remaining children. `check_ui` passes such a file; `xml-stripblanks` passes it; so does every probe that
|
||||||
|
reads *values* off the widgets inside. What catches it is a reading that walks *up* from the widget
|
||||||
|
(`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame
|
||||||
|
moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it
|
||||||
|
holds.
|
||||||
|
|
||||||
|
- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the
|
||||||
|
pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover
|
||||||
|
(a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is
|
||||||
|
no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be
|
||||||
|
measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up.
|
||||||
|
`on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own
|
||||||
|
surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own
|
||||||
|
surface and a click inside it never arrives; the button is left to its own click, whose toggle is what
|
||||||
|
closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab.
|
||||||
|
|
||||||
|
- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar
|
||||||
|
renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the
|
||||||
|
routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four
|
||||||
|
signals already" was read as no feedback at all. While a notice is fresh the label carries the stock
|
||||||
|
`.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries
|
||||||
|
worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not
|
||||||
|
read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on
|
||||||
|
another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit.
|
||||||
|
|
||||||
|
- **The status line cannot cover the gap before the window exists, so a window does.** Between the chooser
|
||||||
|
closing and `gtk_window_present()` there is no main loop at all: the chooser destroyed its own window,
|
||||||
|
then `main()` allocates the sample buffer, waits for the first CONFIG or reads a recording, and
|
||||||
|
`on_activate()` builds every panel -- at a few gigabytes of buffer that is the ten seconds a user reported
|
||||||
|
as "the dialog disappeared and nothing happened". `loading_show()`/`loading_hide()` (in
|
||||||
|
`source_dialog.cpp`, beside the chooser they continue) keep an `AdwWindow` on screen across it, raised in
|
||||||
|
`main()` **before** the model is constructed and taken down in `on_activate()` **after** the present --
|
||||||
|
either side is a frame of empty screen. Three rules it exists under:
|
||||||
|
- **It must be painted before the caller blocks.** `gtk_window_present()` only queues the frame, and
|
||||||
|
nothing after it runs a loop, so `loading_show()` ends in a nested `GMainLoop` quit by a timeout
|
||||||
|
(the chooser's own idiom). Iterating pending events is *not* enough: a window needs a frame-clock
|
||||||
|
cycle, and the frame clock only ticks while a loop runs.
|
||||||
|
- **It says what the wait is, in words.** The spinner is frozen for the whole of it -- no loop -- and a
|
||||||
|
progress bar would be a lie for the same reason, so the text carries the information: the address
|
||||||
|
being connected to or the file being opened, and under it the buffer size, which is the number that
|
||||||
|
explains the duration. It is `deletable=0` with no title buttons: a close button that cannot be
|
||||||
|
serviced until the work is over is worse than none.
|
||||||
|
- **Only the path that *asked* gets one.** `main()` raises it only when the chooser actually ran, which
|
||||||
|
is why every documented probe (all of them pass `--server`) is on exactly the path it was before --
|
||||||
|
and a `--server` run never had a window to lose, so the post-present notice is the right place for it.
|
||||||
|
`loading_hide()` is a no-op when none is up, so `run_ui()` needs no notion of which path it is on.
|
||||||
@@ -26,9 +26,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
drag pans, `f` fits, `Ctrl+0` resets**.
|
drag pans, `f` fits, `Ctrl+0` resets**.
|
||||||
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
||||||
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
||||||
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
`UDPSCOPE_PROBE_MATH_MS` defines three graphs -- the third a `Source` of the first channel into a
|
||||||
as much as a state check), and the dump prints each derived channel's extremes beside the
|
`DeNoise` node, so the editor's median row is built and synced on a baseline run -- and opens the
|
||||||
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
editor (a construction crash test as much as a state check), and the dump prints each derived
|
||||||
|
channel's extremes beside the source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||||
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
||||||
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
||||||
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
||||||
@@ -36,10 +37,42 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
||||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
||||||
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
||||||
|
- **A file view has no producer, so a structural change would leave it empty.** `set_math_channels()`
|
||||||
|
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream, whose producer
|
||||||
|
refills it from the next packet, and wrong for a file, which has nothing to refill it with. Adding a math
|
||||||
|
channel to an open waveform therefore emptied *every* channel -- `samples=0` on the sources too, which a user
|
||||||
|
reads as "the plot is broken" -- and the same emptiness follows any structural change in a file view.
|
||||||
|
`reload_file_view()` (in `ui.cpp`) re-reads the file after such a change, which is also what computes the
|
||||||
|
derived channels over it: the load's own pushes run the math once the set is in place. It fires only when the
|
||||||
|
buffer was *really* dropped -- `stored_slots() > 0` means the change found nothing to do -- and it says so
|
||||||
|
on stderr (`reloaded <path> after a structural change`), because a second read of a file is worth
|
||||||
|
accounting for. `set_math_channels()` in turn treats an identical list as no change at all, so a no-op
|
||||||
|
application (the session apply, a re-open) neither rebuilds nor drops nor re-reads; the comparison leaves the
|
||||||
|
nodes' `x`/`y` out on purpose, since those are the canvas's own and no op reads them -- a graph that only
|
||||||
|
*moved* is not a change, and rebuilding for it would drop the buffer to store two numbers. It is why `--open`
|
||||||
|
works at all with a session that defines math channels, since `main()` loads the file *before* the UI (and
|
||||||
|
so before the session's graphs) exists.
|
||||||
|
- **Every path that fills a store must skip a derived channel, not just the packet path.**
|
||||||
|
`ChannelInfo::math` states the rule for the ingest; `push_loaded_slot()` broke it, so loading a file pushed
|
||||||
|
its own value into each derived ring and `push_math_sample()` then pushed the computed one: two samples per
|
||||||
|
slot, i.e. a trace at double rate. The reading that catches it is the one already in this file --
|
||||||
|
`math '<name>': N samples vs M time slots` must be **equal** -- and a file view now has to be read as well
|
||||||
|
as a live one (`--open <the file>` with `UDPSCOPE_PROBE_MATH_MS`). An export writes every *plotted* channel,
|
||||||
|
so a file carries the derived ones as ordinary columns: those are real channels and are filled normally,
|
||||||
|
which is why the fix is in the push and not in the loader.
|
||||||
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
||||||
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
||||||
anywhere in 78--101 ns/slot is one reading of the same thing.
|
anywhere in 78--101 ns/slot is one reading of the same thing; the three-graph run reads **103**, the
|
||||||
The probe also asserts the new editor mechanics. Each line carries `(want ...)` and the reading
|
de-noise channel's per-sample median being the difference.
|
||||||
|
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
||||||
|
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
||||||
|
channel being edited, so a row that stopped being the one the code writes shows there.
|
||||||
|
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
|
||||||
|
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
|
||||||
|
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
|
||||||
|
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
|
||||||
|
`math_body` written as a child of the window instead of the box above it. Each line carries
|
||||||
|
`(want ...)` and the reading
|
||||||
taken from a real run is the reference:
|
taken from a real run is the reference:
|
||||||
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
||||||
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
||||||
@@ -208,6 +241,24 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
||||||
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
||||||
accident; the `delete node` button stays the only way to remove a node.
|
accident; the `delete node` button stays the only way to remove a node.
|
||||||
|
- **`MathOp` is append-only, because `math_op_at()` *is* the serialisation.** A session stores a node's op
|
||||||
|
as its index through that accessor, so an op inserted in the middle reinterprets every node in every file
|
||||||
|
written before it; `DeNoise` is therefore index 12, after `Div`, and sits last in the dropdown.
|
||||||
|
`math_op_uses_filter()` keeps its meaning of *a biquad* -- `math_op_uses_fc()` is the wider question
|
||||||
|
("does this op read `fc` at all") and is what the dialog's corner row asks.
|
||||||
|
- **The de-noise node's median window is *pooled* in `MathRuntime::median`, not held in `MathNodeRt`**, so a
|
||||||
|
node's state stays on one cache line; nine doubles per node would push every node in the graph, filter or
|
||||||
|
not, across two. `MathNodeRt::med_at` / `::med_n` / `::med_pos` / `::med_have` are that pool's slice,
|
||||||
|
length, write position and fill, and the pool is sized in `math_prepare()`.
|
||||||
|
- **Median *then* pole, and the order is the design.** The median kills an impulse outright -- one shorter
|
||||||
|
than half its window cannot move it at all -- and the pole takes the broadband hash it leaves behind. The
|
||||||
|
other order feeds the pole the impulse, which smears it over the pole's whole settling time and leaves the
|
||||||
|
median a wide bump it cannot reject.
|
||||||
|
- **The de-noise pole reuses `b0` and `z1`, deliberately.** `math_prepare()` carries `z1` across a
|
||||||
|
re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do -- while the
|
||||||
|
median window starts from rest, the same choice the biquads' `z2` makes. `median_n()` rounds *up* to odd and
|
||||||
|
caps at 9, and it is what enforces the window rather than the dialog: a session file holding 4 or 40 still
|
||||||
|
loads. The cap is what `double sorted[9]` in the step is sized for.
|
||||||
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
||||||
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
||||||
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||||
@@ -233,3 +284,39 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
||||||
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
||||||
argument to pay at this size.
|
argument to pay at this size.
|
||||||
|
|
||||||
|
## A gap is a gap in a filter's output, never the end of its state
|
||||||
|
|
||||||
|
A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2`
|
||||||
|
for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never
|
||||||
|
came back. Relative time made it systematic — a counter gap reserves NaN slots by construction,
|
||||||
|
where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and
|
||||||
|
the reading was unambiguous: the memoryless channels held 95% finite samples while every
|
||||||
|
`LowPass`/`HighPass`/`BandPass` channel held `finite=0`.
|
||||||
|
|
||||||
|
`biquad_step()` therefore returns NaN **without touching the state words** when its input is not
|
||||||
|
finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the
|
||||||
|
filter's history remains the last *real* samples it saw, and the trace resumes after the hole
|
||||||
|
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
|
||||||
|
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
|
||||||
|
`hole_slots` is this defect returned.
|
||||||
|
|
||||||
|
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
|
||||||
|
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
|
||||||
|
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
|
||||||
|
only the edited graphs are prepared again. `math_apply_locked()` computes the whole list on a recompute
|
||||||
|
(the graphs are walked in order, so a channel's inputs must be there) but writes only the channels in the
|
||||||
|
affected mask; the affected set is the edited channels plus whatever *names* them, since one channel's output
|
||||||
|
is another's input. Before this, every edit rebuilt the store and every channel in the window started again,
|
||||||
|
which is the "modifying one expression invalidates all the others" report.
|
||||||
|
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
|
||||||
|
while the edit runs -- and never 0.
|
||||||
|
|
||||||
|
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
|
||||||
|
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
|
||||||
|
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
|
||||||
|
`on_signal_switch()` disables the channels that read that signal -- **transitively**, since one math channel
|
||||||
|
may name another -- through the same path the pane's own switch uses (`MathChannel::enabled` and
|
||||||
|
`apply_math_channels()`), and takes their traces off the plots along with the signal's. The channels that do
|
||||||
|
not read it are untouched: `[enable-all-probe]`'s `math_on=[…]` is the half that reads this. Re-enabling the
|
||||||
|
signal does **not** switch them back on, for the same reason re-enabling does not restore the traces.
|
||||||
@@ -76,6 +76,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
the pyramid for nothing.
|
the pyramid for nothing.
|
||||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||||
anything.
|
anything.
|
||||||
|
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
|
||||||
|
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
|
||||||
|
sums still split at the bare level, because `Top` on a unit sine must read 2/pi (0.636620).
|
||||||
|
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
|
||||||
|
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
|
||||||
|
the last a different filter state.
|
||||||
|
- **A non-zero `hf_fc` disables *both* bucket fast paths**, with the single
|
||||||
|
`if (!filtered && reader.bucket_range(...))`. An empty `buckets` makes `bulk_from == bulk_to == n` *and*
|
||||||
|
leaves `lo_search` / `hi_search` at 0, so `pin()` becomes a no-op -- and that is the right answer, because a
|
||||||
|
bucket is an aggregate of unfiltered samples and cannot be mixed into a filtered walk in either direction.
|
||||||
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
|
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
|
||||||
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
|
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
|
||||||
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
|
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
|
||||||
@@ -108,3 +118,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
`enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion
|
`enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion
|
||||||
that the fast path is a fast path and not an approximation. The app's reading is
|
that the fast path is a fast path and not an approximation. The app's reading is
|
||||||
`[measures-probe]` against `--noise 0.1`.
|
`[measures-probe]` against `--noise 0.1`.
|
||||||
|
- **A statistic's observation is one *recompute*, not one sample.** The pane already refreshes on
|
||||||
|
a cadence (at most one pass per 100 ms while the window scrolls, exactly once when it
|
||||||
|
settles), and the accumulator is fed from that pass's result — so `n` counts windows measured.
|
||||||
|
Feeding it per sample would make `n` a sample count and the mean a mean of *samples*, which is
|
||||||
|
not what any of these measurements measure: a frequency over a window is not a property of a
|
||||||
|
sample. The reading that names this defect is `[stats-probe]`'s `n` in the thousands rather
|
||||||
|
than the tens.
|
||||||
|
- **The accumulator is Welford's, and the reason is the same one the time ring is `f64` for.** A
|
||||||
|
measurement's values are routinely large with a small spread — an absolute stream time is the
|
||||||
|
obvious case — and `Σx² - (Σx)²/n` cancels to noise there. The standalone check is that the
|
||||||
|
spread of `{2,4,4,4,5,5,7,9}` survives a `+1e9` offset unchanged.
|
||||||
|
- **What resets the statistics is "what is being measured changed", not "what is in view
|
||||||
|
changed".** An explicit Reset, an add/edit/delete (all three funnel through
|
||||||
|
`rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
|
||||||
|
*question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
|
||||||
|
not: they change which samples answer it, which is precisely what averaging over many windows
|
||||||
|
is for. The pane-wide toggle does not reset either — it only shows and hides labels that are
|
||||||
|
always created and always fed, so reading the plot for a moment does not cost the history.
|
||||||
@@ -4,6 +4,41 @@ How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state be
|
|||||||
|
|
||||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||||
|
|
||||||
|
- **A sidebar's size must be its own, never a divider's position.** `GtkPaned` keeps a child's size in
|
||||||
|
`position`, which is a number of pixels **of the window**: the sidebar's width *was* `window - 940`, so on
|
||||||
|
any window narrower than 940 px — a scaled display, where the logical width is half what the screen looks
|
||||||
|
like, or a window a manager capped — the sidebar came up **0 px wide**, and the only way to see it was to
|
||||||
|
drag the divider back. Every patch that kept the paned failed for the same reason (the position derived
|
||||||
|
from the paned's own width, re-parenting the column, the property form of its children, size requests):
|
||||||
|
the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in
|
||||||
|
GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` =
|
||||||
|
`max-sidebar-width` = 320 in **pixels**, so the width is its own at any window size, and `collapsed` (set
|
||||||
|
from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than
|
||||||
|
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=320` under a 2x scale is exactly
|
||||||
|
the case that used to be zero.
|
||||||
|
- **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split
|
||||||
|
view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading
|
||||||
|
it back as the user's wish is wrong: doing that made connecting a source leave the sidebar shut, because
|
||||||
|
the widget's own `false` was recorded as the user asking for it. A `GBinding` from the property to the
|
||||||
|
toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own
|
||||||
|
write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted`
|
||||||
|
holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own
|
||||||
|
`set_active`), `update_side_visibility()` is the single place that turns that wish plus "are there signals"
|
||||||
|
into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees —
|
||||||
|
which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The
|
||||||
|
no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed.
|
||||||
|
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
|
||||||
|
`sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently
|
||||||
|
falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no
|
||||||
|
error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`.
|
||||||
|
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
|
||||||
|
*function's order* rather than about the window's shape, which is why it is the one part of the
|
||||||
|
window skeleton that cannot live in `main-window.ui`. `gtk_paned_set_position()` clamps against what
|
||||||
|
its children ask for *now*: called before the sidebar's panels have been appended it is clamped
|
||||||
|
against an empty pane, and the divider lands wherever that left it. Moving the call into the `.ui`
|
||||||
|
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
|
||||||
|
taller figure.
|
||||||
|
|
||||||
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
|
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
|
||||||
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
|
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
|
||||||
thing that forces a rebuild. Cursors are parked outside the axis range instead of
|
thing that forces a rebuild. Cursors are parked outside the axis range instead of
|
||||||
@@ -104,6 +139,29 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
figure rebuild — which `assign()` already forces. A signal on no plot gets
|
figure rebuild — which `assign()` already forces. A signal on no plot gets
|
||||||
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
|
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
|
||||||
same height.
|
same height.
|
||||||
|
- **The invalid-span band is data-anchored and reserves no layout space.** `Plot::set_invalid_spans()`
|
||||||
|
draws a tinted quad over the span it names; unlike `Plot::set_x_note()` (the `t0` caption), which
|
||||||
|
is chrome drawn at a fixed corner of the axes regardless of the data, a band pans and zooms with
|
||||||
|
the samples it accuses. The two are the complementary cases: the caption is chrome that data does
|
||||||
|
not move, the band is annotation that data does. Both are alike in the second count: neither
|
||||||
|
reserves layout space and neither can be picked, so a hole cannot move the layout the labels were
|
||||||
|
measured into.
|
||||||
|
|
||||||
|
The band has a **one-device-pixel floor on its width**. A hole of one sample in a window of hundreds
|
||||||
|
of thousands is a hundredth of a column, and a fill rounded to zero would drop exactly the case
|
||||||
|
the band is most needed for — a sub-pixel hole is invisible as a gap in the polyline and would be
|
||||||
|
invisible as a band too without the floor.
|
||||||
|
|
||||||
|
The band carries **three states** and they are a property of the hole, not of the plot: solid
|
||||||
|
(`Style::invalid_fill`) while the hole still lacks packets, faint (`Style::invalid_repaired_fill`)
|
||||||
|
once `Hole::repaired()` is true, absent once the hole's slots age out of the ring and
|
||||||
|
`forget_holes_before()` drops it from the table. A repaired hole is **not** retired early: the band
|
||||||
|
is a record of what the window was missing, and retiring it on repair would erase the report the
|
||||||
|
moment it became explicable — the faint tier is how the display says "this was missing but arrived
|
||||||
|
late". `RowSet` sends one `plot::InvalidSpan` per hole to every row — a gap is in the stream, not
|
||||||
|
in one signal — and only in `Live` and `Capturing`, because a `Held` or `Paused` snapshot carries
|
||||||
|
its own stamps that the live hole table does not address.
|
||||||
|
|
||||||
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
|
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
|
||||||
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
|
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
|
||||||
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
|
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
|
||||||
@@ -115,3 +173,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
|
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
|
||||||
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
|
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
|
||||||
chip's own markup, since asking the resolver twice would assert nothing.
|
chip's own markup, since asking the resolver twice would assert nothing.
|
||||||
|
|
||||||
|
- **Switching a signal off takes its traces off every plot.** The switch means "do not acquire this", and a
|
||||||
|
trace left behind draws nothing: it holds one of the four slots a plot has, reads `--`, and blocks the drop
|
||||||
|
that would fill it with something live. `on_signal_switch()` clears every slot by the **signal name** the
|
||||||
|
slot remembers -- never the channel index, which is exactly what the enable change renumbers -- and says what
|
||||||
|
it did in a notice, because a trace that vanishes silently is its own kind of confusing. The follow-ups (a
|
||||||
|
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
|
||||||
|
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
|
||||||
|
leaving them off.
|
||||||
@@ -32,6 +32,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
`crosshair_interpolated_mode_blends_the_two_samples` and
|
`crosshair_interpolated_mode_blends_the_two_samples` and
|
||||||
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
||||||
y=..` agreeing with the pane's first value.
|
y=..` agreeing with the pane's first value.
|
||||||
|
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
|
||||||
|
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
|
||||||
|
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
|
||||||
|
ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it must
|
||||||
|
not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is
|
||||||
|
the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the
|
||||||
|
window's **first sample** and never from history before the window, so a measurement never depends on data
|
||||||
|
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
|
||||||
|
from earlier samples.
|
||||||
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
||||||
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
||||||
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
||||||
*overrides*, so a stream's new signals still take the positional palette and a saved session does
|
*overrides*, so a stream's new signals still take the positional palette and a saved session does
|
||||||
not freeze a colour that is meant to follow the signal list), **the math channels with their
|
not freeze a colour that is meant to follow the signal list), **the math channels with their
|
||||||
graphs** and **the measurements pane**. Signal identity
|
graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics
|
||||||
|
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
|
||||||
|
when absent. Signal identity
|
||||||
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
|
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
|
||||||
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
|
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
|
||||||
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
|
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
|
||||||
@@ -21,6 +23,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
was parsed into the last calibration record and the enable state was lost without a word.
|
was parsed into the last calibration record and the enable state was lost without a word.
|
||||||
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
||||||
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
||||||
|
- **Measurement options persist under *named* keys -- `noise` and `hf` -- so a file written before them
|
||||||
|
loads at the defaults**, which are exactly the behaviour it was written against: `noise = 10` is the
|
||||||
|
hysteresis band the app has always counted with, and `hf = 0` is no filter.
|
||||||
|
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
|
||||||
|
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
|
||||||
|
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
|
||||||
|
`math_op_at()` index, which is why `MathOp` may only be *appended* to.
|
||||||
- **The export is collected at click time, not in the file-dialog callback.** The live
|
- **The export is collected at click time, not in the file-dialog callback.** The live
|
||||||
view keeps scrolling while the chooser is up, so a late collection would write a
|
view keeps scrolling while the chooser is up, so a late collection would write a
|
||||||
window the user never saw. The writer is picked by the chosen path's extension.
|
window the user never saw. The writer is picked by the chosen path's extension.
|
||||||
@@ -50,3 +59,48 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
out — a source whose streamer was merely down during one run would silently vanish from the
|
out — a source whose streamer was merely down during one run would silently vanish from the
|
||||||
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
|
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
|
||||||
configured nothing had nothing worth persisting.
|
configured nothing had nothing worth persisting.
|
||||||
|
- **It refuses again while `replaying` is set: a file view is not the source's session.** Same
|
||||||
|
argument, different cause — this view configured nothing about the stream. A plain replay leaves
|
||||||
|
the rows where the user put them, so its save would merely be pointless; a **group** rebinds every
|
||||||
|
slot to its own renamed channels and the save wrote `signals = ["square (A)", …]` into the file.
|
||||||
|
Nothing noticed until the way back: *Go live* re-applies the session, those names match no live
|
||||||
|
signal, so nothing was enabled and **the live view came back with no channels at all** — on the
|
||||||
|
window the poisoned file carried, which is also how it swallowed the window restore below it. The
|
||||||
|
reading is `[events] live restored (after a group)`: the symptom was a stuck `pending=1` beside
|
||||||
|
`channels=0`, because the restore waits for a channel list that was never going to arrive. The
|
||||||
|
explicit *Save as* is deliberately **not** guarded: that one is a user's own answer to "save this
|
||||||
|
view", and its dialog reports the outcome.
|
||||||
|
|
||||||
|
## A configuration is a named session, and a source may hold several
|
||||||
|
|
||||||
|
One source, several arrangements: the same streamer serves different experiments, and retyping a
|
||||||
|
layout to look at the second one is how a session gets lost. Storage is therefore a **directory
|
||||||
|
per source** — `~/.config/udpscope/<host>_<port>/` — holding one `<name>.toml` per configuration
|
||||||
|
plus an `active` file naming the one the source is on. `configuration_path()` is the only
|
||||||
|
expression of the scheme, and the name is `sanitize()`d exactly as the host is, so a name typed
|
||||||
|
with a slash cannot escape the directory.
|
||||||
|
|
||||||
|
A configuration carries the whole session *and* the settings that can only be decided before the
|
||||||
|
first socket: `buffer_mb`, `relative_time`, `capture_packets`. The startup chooser applies them
|
||||||
|
(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed
|
||||||
|
— the one moment a buffer size can be applied at all. Switching configurations inside a running
|
||||||
|
app applies what can change live and says in the status line that the rest takes effect on the
|
||||||
|
next start; pretending otherwise would be a configuration that half-applies.
|
||||||
|
|
||||||
|
A configuration's *startup* settings reach the model from two directions, and both are needed:
|
||||||
|
`apply_source()` for a source picked in the chooser, and `apply_stored_settings()` for a source
|
||||||
|
named with `--server`, which never reaches the chooser at all. Without the second, a saved host's
|
||||||
|
buffer size, clock and capture ring were silently ignored — the layout still applied, because the
|
||||||
|
app loads that separately once a CONFIG names the signals — so an edit to any of the three read as
|
||||||
|
one that never took effect. **A flag the user typed wins over the file** (`--buffer-mb`,
|
||||||
|
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
|
||||||
|
`Config::buffer_mb_specified` is what tells "not asked" from "asked for the default".
|
||||||
|
|
||||||
|
The `active` file is written on every save and by the chooser's pick, so "which configuration was
|
||||||
|
I on" survives a restart. A session written before configurations existed is **imported once** as
|
||||||
|
the configuration named `default`, and the old file is renamed to `<host>_<port>.toml.imported`
|
||||||
|
rather than deleted: an import that cannot be undone by hand is a migration nobody can back out
|
||||||
|
of.
|
||||||
|
|
||||||
|
Nothing is lost by switching: the dialog saves the current session *before* loading the picked
|
||||||
|
one, which is the only ordering in which a switch cannot lose what it switches from.
|
||||||
@@ -31,6 +31,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
||||||
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
||||||
capture up to the requested window.
|
capture up to the requested window.
|
||||||
|
- **With nothing bound, `set_time_window()` frames `{-window, 0}` and never `{0, window}`.** Under
|
||||||
|
a following x group a range states two things — its span, and *through its maximum* where the
|
||||||
|
window sits behind the live edge — so a maximum **at** the edge is what makes the offset zero.
|
||||||
|
When there is a live edge to read that is `{newest - window, newest}`; when there is not, the
|
||||||
|
edge the live view would put at x = 0 is 0, so the equivalent statement is `{-window, 0}`.
|
||||||
|
Asking for `{0, window}` there is an offset of a *whole window*. This is not a rare fallback:
|
||||||
|
"no samples bound" is the state every structural change leaves behind, and
|
||||||
|
`apply_time_window()` re-plans the rings and *then* calls this, so it is the axis a mode change
|
||||||
|
is drawn with until the first sample lands. With `{0, window}` the two or three frames after
|
||||||
|
every event or waveform open drew the time base one screen to the left of where the following
|
||||||
|
frames drew it — a trace that jumps a screen sideways and comes back, which is what "opening
|
||||||
|
events or even waveform sometime result in data flickering (like time base) and is unusable"
|
||||||
|
was. The reading is the dump's traceless paint counts: `mode=live` 38 → 0 and `mode=waiting`
|
||||||
|
2 → 40, the latter being the point — a waiting view now paints its empty axis in the *same*
|
||||||
|
place every frame instead of twitching into a framed one.
|
||||||
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||||||
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||||||
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||||||
@@ -75,3 +90,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
|
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
|
||||||
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
|
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
|
||||||
the two cannot drift apart.
|
the two cannot drift apart.
|
||||||
|
- **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified`
|
||||||
|
is set by `--relative-time` and by the preferences — which the source chooser can reach before the run
|
||||||
|
is planned, so that is the one place a preference still changes *this* run. Both the list's own copy
|
||||||
|
(`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when
|
||||||
|
nothing newer was said. Without the marker, clicking a source row after applying the preference
|
||||||
|
silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.
|
||||||
@@ -30,6 +30,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
out empty: any stream whose time signal advances in large steps drove it to its
|
out empty: any stream whose time signal advances in large steps drove it to its
|
||||||
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
|
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
|
||||||
itself. **Do not reintroduce a rate estimate on this path.**
|
itself. **Do not reintroduce a rate estimate on this path.**
|
||||||
|
- **The engine identifies its channel by *name*, never by index.** `TriggerEngine::configure()`
|
||||||
|
drops an open capture when the trigger moves to a different channel, and it is re-called from
|
||||||
|
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
|
||||||
|
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
|
||||||
|
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
|
||||||
|
`ScopeModel::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index
|
||||||
|
back. Keying the reset on the index therefore made any such rebuild abandon the capture **and
|
||||||
|
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
|
||||||
|
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
|
||||||
|
and no user action beyond enabling a channel. The name changes when the source genuinely
|
||||||
|
changes, so a deliberate re-point still resets. The rebuild also replaces the `BufferStore`, so
|
||||||
|
the surviving capture's pre side is short and the snapshot reports its own `pre_s` — which may
|
||||||
|
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
|
||||||
|
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
|
||||||
|
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
|
||||||
|
- **`EventCondition` carries the trigger's own rejection: `noise_band` and `hf_fc`.** Both are in the
|
||||||
|
struct's `operator==`, so a change to either *is* a change to the condition. `EventDetector::configure()`
|
||||||
|
takes `dt` and early-returns only when the condition **and** the interval are unchanged -- it runs on
|
||||||
|
*every packet*, so a configure that reset unconditionally would restart the machine before any pulse could
|
||||||
|
close. `dt` is `Partition::slot_dt()`, the *stored* interval and not the CONFIG's nominal rate, and comes
|
||||||
|
from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`); a zero there leaves
|
||||||
|
the reject's coefficient at 1, which is a pass-through.
|
||||||
|
- **A crossing is a change between two *decided* sides, never a change from the undecided one.** The band
|
||||||
|
makes each sample decide above / below / undecided, and an edge is counted only when the signal has reached
|
||||||
|
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
|
||||||
|
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
|
||||||
|
trigger is not supposed to see.
|
||||||
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
||||||
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
||||||
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
||||||
@@ -50,6 +77,30 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
|
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
|
||||||
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
|
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
|
||||||
picture is equally immovable; resuming or leaving trigger mode clears it.
|
picture is equally immovable; resuming or leaving trigger mode clears it.
|
||||||
|
- **An opened file is a pinned view too, and *how* it is pinned matters as much as that it is.** The
|
||||||
|
file view states its pin from the ring's own stamps every frame, and it used to do that with
|
||||||
|
`pin_event_window()` -- which writes the group's range as well as the pin's numbers -- so a box zoom
|
||||||
|
drew its frame and changed nothing: the controller's `set_x_range` went onto the *member*, the next
|
||||||
|
frame's re-statement put the group's range back, and the branch it fell into (`clamp_to_data()`,
|
||||||
|
because a file is neither `event_held` nor `Paused` nor `capturing`) never adopts a member into a
|
||||||
|
group that is not following. That is the wheel-in-single-trigger defect again, in the one mode nobody
|
||||||
|
re-tested. Two things hold it now: the branch is `Paused || capturing || rows->window_pinned()`, so a
|
||||||
|
pinned view is always clamped by `hold_pinned_window()`; and the file's per-frame statement uses
|
||||||
|
`set_data_pin()` -- the pin's numbers -- unless `pinned_window_is()` says the pin is a *different*
|
||||||
|
window, which is a re-open with a new extent and is a new event, stated in full. `[boxzoom-probe]`'s
|
||||||
|
`zoomed=` is the guard, and it is a reading to take in **both** modes: live and file must both read
|
||||||
|
`1`.
|
||||||
|
- **`set_data_pin()` takes a *range*; `pin_event_window()` and `pinned_window_is()` take the pre/post
|
||||||
|
*split*.** The three are used in one breath by the file view's statement and by the held event's, and
|
||||||
|
the two forms are not interchangeable: for data over `[lo, hi]` it is `set_data_pin(lo, hi)` against
|
||||||
|
`pin_event_window(-lo, hi)`. Handing the split to `set_data_pin()` stores the pin as `[lo, -lo]`-ish,
|
||||||
|
so the next frame's `pinned_window_is()` reads false, the pin is stated in full again, and the view
|
||||||
|
alternates between the file's span and the corrupted pin at the frame rate -- the flicker in which no
|
||||||
|
interaction survives, because every second frame rewrites the group's range. It is invisible on a file
|
||||||
|
whose stamps are all positive (the corrupted pin is then *wider* than the data and
|
||||||
|
`hold_pinned_window()` never clamps) and on one that ends at 0 (the pin inverts, and the clamp returns
|
||||||
|
early), which is why it reached a user on a recording spanning `[-4..16]` and nothing before it.
|
||||||
|
`[drop-probe]`'s `repins=` is the reading: **2 for a whole run**, not one per frame.
|
||||||
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
|
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
|
||||||
Two things had to agree for this, and both were wrong in the same direction:
|
Two things had to agree for this, and both were wrong in the same direction:
|
||||||
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
|
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
|
||||||
@@ -107,3 +158,203 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
export.** Both need the same absolute index range over the time ring and every value
|
export.** Both need the same absolute index range over the time ring and every value
|
||||||
ring, or an export and a capture of the same window would hold different samples. Call
|
ring, or an export and a capture of the same window would hold different samples. Call
|
||||||
it under `mutex()`.
|
it under `mutex()`.
|
||||||
|
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
|
||||||
|
so the engine would not latch; but the real reason the samples are withheld is the other one:
|
||||||
|
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
|
||||||
|
asserts `trigger_in_hole=0` rather than relying on NaN's arithmetic properties.
|
||||||
|
- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
|
||||||
|
the arming, the hold-off and the post window and holds no condition.** The split is what lets
|
||||||
|
the buffer search ask the trigger's exact question without owning a trigger, and it is only
|
||||||
|
true as long as neither half leaks into the other: a detector that knew about the hold-off
|
||||||
|
would answer a search differently from the live trigger, which is the one thing the
|
||||||
|
`agree=1` cross-check in `[search-probe]` exists to catch.
|
||||||
|
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
|
||||||
|
A pulse width, a window excursion and a dropout timeout are all *stateful over several
|
||||||
|
samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
|
||||||
|
then measures from whatever sample the engine happened to resume on. The hold-off is applied
|
||||||
|
to the detector's *answer*, not to its input — `process()` feeds first and tests
|
||||||
|
`now_s < holdoff_until_s_` afterwards.
|
||||||
|
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
|
||||||
|
the closing edge but could in principle be timed at the opening one, and a dropout is detected
|
||||||
|
one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
|
||||||
|
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
|
||||||
|
all three, or the marker, the cut and the gate would disagree with each other by the width of
|
||||||
|
a pulse.
|
||||||
|
|
||||||
|
## A latch is also a file
|
||||||
|
|
||||||
|
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
|
||||||
|
collapsed until it is asked for, because a column of stale events beside a live plot is what a user
|
||||||
|
who has stopped looking at events does not want. What makes it *available* -- (recording is on) or
|
||||||
|
(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and
|
||||||
|
the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and*
|
||||||
|
collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is
|
||||||
|
deliberately **not** one of the conditions: a stored event being examined is a file, which has no
|
||||||
|
trigger, and tying the pane to the trigger would close the list under the click that opened an event.
|
||||||
|
The recording switch lives in the trigger panel, not in the pane it empties.
|
||||||
|
|
||||||
|
## Opening a file is a detour, so *Go live* is a restore
|
||||||
|
|
||||||
|
A file view overwrites the live view's settings and every overwrite has a reason: the window becomes
|
||||||
|
the event's own span because that is what *framing* an event means, and the trigger goes off because
|
||||||
|
a file has no edge to wait for (leaving it on kept a frozen capture on screen *instead* of the file,
|
||||||
|
so the click looked like it did nothing at all). None of those reasons survive the way back. What
|
||||||
|
the user had set is theirs, and "going back to live loses the trigger I spent a minute setting up"
|
||||||
|
is the complaint this answers.
|
||||||
|
|
||||||
|
- **`snapshot_live_state()` on the way in, `restore_live_state()` on the way out.** The snapshot is
|
||||||
|
taken by both ways into a file view -- `replay_event()` and `build_event_group()` -- *before* the
|
||||||
|
acquisition is stopped, and is a **no-op while `replaying`**: a second event opened from the first,
|
||||||
|
or a group opened from a replay, would otherwise snapshot a file's settings as "live", which are
|
||||||
|
the very values the restore exists to undo. The group takes it after its argument checks, because a
|
||||||
|
group that is refused leaves the live view alone.
|
||||||
|
- **The trigger channel is stored by *name*.** Same reason `TriggerEngine::configure()` takes one: the
|
||||||
|
index is a position in a channel list that a file view rebuilds, and a group **renames** its
|
||||||
|
channels outright (`square (A)`). Putting an index back would select a different signal.
|
||||||
|
- **`enabled` and `armed` are deliberately not restored from the snapshot.** They belong to the
|
||||||
|
toolbar toggle, which owns an atomic mirror of `enabled` (`set_trigger_enabled()`) that a plain
|
||||||
|
field write under the lock would leave stale. The restore writes the rest of the `TriggerConfig`
|
||||||
|
and then moves the *toggle*, whose handler states both.
|
||||||
|
- **The restore runs from the tick, not from `go_live_again()`, and *after* the deferred layout
|
||||||
|
block.** Two writers would otherwise beat it:
|
||||||
|
- `apply_layout()` ends in `apply_time_window(st.window_s)` -- the window the **session file**
|
||||||
|
holds, not the one the live view had. `go_live_again()` clears `layout_loaded`, so that block
|
||||||
|
fires on the next tick; a restore done inside `go_live_again()` is overwritten by the saved value
|
||||||
|
a frame later. Being the next statement after it in `tick()` is the fix.
|
||||||
|
- `rebuild_trigger_signals()`, finding a `channel` of `-1` after the rebuild renumbered the
|
||||||
|
channels, silently settles on the first signal *and* re-defaults the level -- which is why
|
||||||
|
`trigger_level_touched` is restored with the configuration.
|
||||||
|
- **It waits for the stream's own channel list, gated on `generation()` having moved.** The restored
|
||||||
|
name has to be resolved against the live channels, and those do not exist until the first CONFIG of
|
||||||
|
the restarted client lands on the producer thread. `App::live_restore_generation` is the generation
|
||||||
|
read when *Go live* was pressed; `live_restore_pending` stays set until it differs **and**
|
||||||
|
`channels()` is non-empty. A `pending=1` that never clears is therefore a reading about the
|
||||||
|
*stream*, not about the restore -- see `invariants-session-and-export.md` for the save that made it
|
||||||
|
stick at `channels=0`.
|
||||||
|
- **Only the two controls a file view actually moved are restated** -- the signal dropdown and the
|
||||||
|
level spin. The rest of the trigger panel was never touched, and writing to a control fires its
|
||||||
|
handler, so the ones with nothing to put back are left alone. Both writes are inside
|
||||||
|
`updating_panels`.
|
||||||
|
- **`pinned` is not a pass/fail reading on the way back.** `apply_time_window()` drops the file's pin
|
||||||
|
and re-arms the live follow, but if the restored trigger was *on* it can latch again within the
|
||||||
|
frame or two before the probe reads -- and a held capture is pinned, correctly. That is what
|
||||||
|
`held=` and `mode=` are beside it for.
|
||||||
|
|
||||||
|
## A comparison is two plots of one model
|
||||||
|
|
||||||
|
*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a
|
||||||
|
comparison is **two plots** over it, and which plot draws what is only a matter of which channel *names*
|
||||||
|
its rows bind. `App::rows` is the primary (`RowSet`, area, gestures); `App::rows_b` is the second, with
|
||||||
|
its own `RowSet` and its own hidden `PlotArea` in a paned, deliberately without the drop target, the
|
||||||
|
right-click menu or the frame counter: those act on the primary's rows, and a menu on the second plot
|
||||||
|
that rearranged the first plot's rows would be a menu lying about its subject.
|
||||||
|
|
||||||
|
Rules that cost a defect each:
|
||||||
|
|
||||||
|
- **The layout is the user's and is mirrored, never reinvented.** The group build must not call
|
||||||
|
`set_row_count()`; it reads the rows' slot names and re-names the same slots for each layer
|
||||||
|
(`… (A)`, `… (B)` for a comparison; a bare name for layer 0 and `… #2`, `… #3` for the extra layers of
|
||||||
|
an accumulation). An *accumulation* puts its extra layers into free slots -- the row that holds the
|
||||||
|
signal first, then any row with room -- so the user's traces never move. The default layout is four
|
||||||
|
signals in one row, which is why `kSlotsPerRow` is 8: with four, "accumulate these two events" had
|
||||||
|
nowhere to put the second copy and silently did nothing.
|
||||||
|
- **A layer is not a signal.** A comparison's model holds one channel per (signal, layer), and the
|
||||||
|
*panel* must not treat the copies as signals of their own: the signal list shows one row per signal
|
||||||
|
(`layer_base_name()`, the shared rule that takes `(A)`, `(B)` and `#N` back off), `signal_color_index()`
|
||||||
|
counts *signals* and not channels -- counting channels spread one signal's colour across the palette
|
||||||
|
-- and both plots of a comparison bind their signal's layers to the *same* colour index, because two
|
||||||
|
colours would say they were different signals. The reading is `list=4` in `[events] compare plots`
|
||||||
|
for a two-event comparison of four signals.
|
||||||
|
- **A slot name carries the last group's suffix, so it must be stripped before it is re-derived**:
|
||||||
|
after a comparison the rows are named `sine (A)`, and a following accumulation that mapped that name
|
||||||
|
again bound to no channel at all (`traces0=0`, a plot with nothing in it). `base_name()` takes
|
||||||
|
`(A)`, `(B)` and `#N` back off.
|
||||||
|
- **The axes are mirrored from the ranges, once per frame.** The plotter owns its window and a gesture
|
||||||
|
reaches it through the plot's own setter with nothing announcing it, so the frame compares each row's
|
||||||
|
signature with the previous frame's: whichever side moved is the side the user touched, and the other
|
||||||
|
is set to match. The signature is `{span, y.min, y.max}` when the x axis has a **live edge**
|
||||||
|
(`x_live_edge()`, not merely an armed follow: an armed axis with nothing streaming keeps a *stored*
|
||||||
|
window, whose edges a pan moves and whose span never changes) and `{x.min, x.max, y.min, y.max}`
|
||||||
|
otherwise. Comparing the edges under a live edge would read as "both sides moved" on every frame --
|
||||||
|
the window is recomputed as a distance behind the newest sample -- and a zoom in one plot would be
|
||||||
|
overwritten by the other's drift. When both moved the first plot is the reference, which is also what
|
||||||
|
makes the frame after a build converge.
|
||||||
|
- **`set_y_range()` clears that axis' auto-scale**, by design: a mirrored zoom is a user decision, and
|
||||||
|
*Fit* re-arms it.
|
||||||
|
|
||||||
|
## A group of events is one model, built once
|
||||||
|
|
||||||
|
*Compare* and *accumulate* are the same machinery: several event files in **one** model, because the
|
||||||
|
buffers cannot hold two independent loads -- `load_waveform_into_model` calls `set_signals()`, which
|
||||||
|
rebuilds them, so a second load takes the first with it. So the group is built by hand: one channel per
|
||||||
|
(signal, layer) pair, `set_target_span()` to cover the whole group *before* `set_signals()` (or the
|
||||||
|
oldest samples are written past the ring's end), then all of the events pushed in one pass. A row is
|
||||||
|
one **signal** and its slots are the events, which is what makes a comparison like for like and an
|
||||||
|
accumulation a density; the trace alpha (0.35 while accumulating, 1 otherwise) is what turns agreement
|
||||||
|
into brightness.
|
||||||
|
|
||||||
|
Two things every group must do, both of which cost a defect to learn:
|
||||||
|
|
||||||
|
- **Stop the acquisition first.** A live producer keeps pushing into the model it was started with --
|
||||||
|
and it pushes *four* stream signals into the group's *eight* channels, so the group's own samples are
|
||||||
|
the ones that go missing. The reading that catches it is the samples the busy channel holds
|
||||||
|
(`samples0` in the `[events] compare` line): it must be exactly the number of stamps pushed, not
|
||||||
|
that plus whatever the stream added.
|
||||||
|
- **Free the slots the group does not fill.** A row carries the previous view's binding, and a trace
|
||||||
|
left in slot 2 of a two-layer row is drawn as if it belonged to the group.
|
||||||
|
|
||||||
|
*Compare* shifts the first event so its span *ends* where the second's *begins*, and fills the half a
|
||||||
|
sample does not belong to with **NaN** -- the plotter excludes non-finite samples from every series and
|
||||||
|
from every pyramid bucket, so a NaN is a gap and not a repeat, and no line is drawn across the join.
|
||||||
|
|
||||||
|
**A replay changes the data and frames it once; it never rearranges the layout.** A row plots a signal
|
||||||
|
*name*, and an event's export carries the same names the stream does, so loading the event into the
|
||||||
|
model leaves every row, slot, colour and the row count pointing at the right signal by itself. So
|
||||||
|
`replay_event()` must **not** rebind the rows (`bind_channels_to_rows()`, which is what a freshly opened
|
||||||
|
*file* needs) -- that is the user's work, and a click on a list is not a request to rearrange it. The
|
||||||
|
window is restated **once, on open** (`apply_time_window(span)`, the only path allowed to change the
|
||||||
|
scale), so the whole event is framed the moment it is opened and the user's own zooms are left alone
|
||||||
|
afterwards; never per frame. The measurements, the cursors and the readouts then report the stored
|
||||||
|
event, because they read the model. What a replay *does* change is the model's contents and the trigger:
|
||||||
|
it leaves trigger mode, without which the display stays `Held` and draws the old snapshot.
|
||||||
|
|
||||||
|
**A file view has nothing to acquire.** The signal list's acquire tick is *hidden* -- not disabled --
|
||||||
|
whenever a file is on screen (`App::waveform_name` non-empty: a replay, a comparison, a `--open`run).
|
||||||
|
The samples are stored, "acquire this signal" is a stream's question, and a greyed tick invites the user
|
||||||
|
to look for a reason there is none for. Going live rebuilds the panel, so the ticks come back.
|
||||||
|
|
||||||
|
**"The same length" is the same count at the same rate, not the same span to the sample.** Two events
|
||||||
|
this app recorded itself differ in span by the fraction of a sample the acquisition's window happened to
|
||||||
|
fill to (0.9999 s of a 1 s pre-window is normal). Demanding one sample interval made a comparison of two
|
||||||
|
just-recorded events refuse to open; the check allows four intervals or a millisecond, whichever is
|
||||||
|
larger, and the merged stamp grid carries the difference as a gap.
|
||||||
|
|
||||||
|
**A replay must leave trigger mode.** `display_mode()` answers `Held` whenever the model holds a
|
||||||
|
snapshot, and a held display draws *the snapshot*, not the live channels -- so replaying an event with
|
||||||
|
trigger mode still armed loaded the file into the model and left the old capture on screen: a click
|
||||||
|
that looks like it did nothing, with the event sitting in the ring unread. The replay also frames the
|
||||||
|
view with `apply_time_window(report.span_s)`, so the click shows the whole event rather than the part
|
||||||
|
of it that fell inside the live window.
|
||||||
|
|
||||||
|
The switch that decides recording belongs to the **trigger panel**, not to the pane it empties: it
|
||||||
|
is a trigger option (it decides whether a latch is written at all), and a pane must not be the only
|
||||||
|
place to reach the control that stops it.
|
||||||
|
|
||||||
|
|
||||||
|
Every published snapshot is written to the event cache while recording is on: its table (the *export*
|
||||||
|
format, so `--open` reads it back), a sidecar of what the list shows, and a preview drawn from the
|
||||||
|
samples. Three rules keep that honest:
|
||||||
|
|
||||||
|
- **The record happens on the GTK thread, at the end of the tick that framed the snapshot**, not on
|
||||||
|
the producer. The snapshot is taken by `shared_ptr` (a latch landing mid-write cannot pull the data
|
||||||
|
away), and a `record_pending` flag defers the write to the end of the frame so the rows are already
|
||||||
|
bound to the event when the picture is drawn. Recording from the producer would put file I/O
|
||||||
|
between two `recv()` calls, which is the one thing the packet capture's design also refuses.
|
||||||
|
- **Nothing a latch does may be expensive.** The write is a `write_npy` of the event's own window and
|
||||||
|
a 480x160 raster; a 2 s, four-channel event is ~230 kB of picture and ~600 kB of samples. The pane
|
||||||
|
is refreshed, not rebuilt, and a failure to write is a line on stderr -- a cache that cannot be
|
||||||
|
written must not stop the scope.
|
||||||
|
- **The preview is drawn from the data, never captured from the window.** The plot is a `GtkGLArea`:
|
||||||
|
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
|
||||||
|
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
|
||||||
|
class GTK-free, which is what makes the cache testable without a window.
|
||||||
@@ -94,6 +94,20 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
||||||
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
||||||
the disarm` line in the dump meaning what it says.
|
the disarm` line in the dump meaning what it says.
|
||||||
|
- `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M
|
||||||
|
enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`,
|
||||||
|
`after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr,
|
||||||
|
`UDPSCOPE_PROBE_TRIGRENUM_MS`) — the first line is the geometry the real widgets produced
|
||||||
|
(`window=1.0000 event_span=1.0000 pre_pct=10.0 post=0.9000` for the reported setup); it is
|
||||||
|
context, not the assertion. The third must show the index **actually moving** (`ch=0` →
|
||||||
|
`ch=1`), or the probe tested nothing — the sine has to be a channel that sorts before the
|
||||||
|
square. **`t0_held=1` is the assertion**: the capture that opened on `T` latched on `T`, so the
|
||||||
|
renumber neither abandoned it nor let a later edge inside the post window become t0. With the
|
||||||
|
engine's invalidation keyed on the channel's index instead of its name this read `t0_held=0`,
|
||||||
|
with `pending` back to 0 and `holdoff_until` *behind* `now_t` on the engine line — i.e. no
|
||||||
|
hold-off was set at all, which is the whole defect. The `[udpscope] trigger: edge at ... got
|
||||||
|
[--0.0199..0.9000]` beside it is expected: the rebuild replaces the `BufferStore`, so the
|
||||||
|
surviving capture has no pre side left and reports a negative `pre_s`.
|
||||||
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr,
|
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr,
|
||||||
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
|
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
|
||||||
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
|
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
|
||||||
@@ -182,7 +196,7 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
"the app placed them and they are on screen".
|
"the app placed them and they are on screen".
|
||||||
- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..]
|
- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..]
|
||||||
[duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W
|
[duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W
|
||||||
pane_h=H`
|
pane_h=H switch_on=X reset_sensitive=Y`
|
||||||
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
||||||
plottable signal and lets the tick compute them, so what it prints is what a user reads.
|
plottable signal and lets the tick compute them, so what it prints is what a user reads.
|
||||||
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
|
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
|
||||||
@@ -199,7 +213,10 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
*and* a marker at the first sample that reached it — two level marks at both extremes was
|
*and* a marker at the first sample that reached it — two level marks at both extremes was
|
||||||
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
|
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
|
||||||
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
||||||
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
|
seconds, and the rest the channel's unit. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against
|
||||||
|
`measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with
|
||||||
|
`[stats-probe] on=` is a `.ui` that lost the widget, or lost the wiring to it; `pane_rows=R` is what
|
||||||
|
proves the readout rows landed in the frame's `measures_list` rather than nowhere. `marks_shown` counts two per shown measurement
|
||||||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||||||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||||||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||||||
@@ -298,6 +315,9 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
|
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
|
||||||
back as the zoomed range one frame later.
|
back as the zoomed range one frame later.
|
||||||
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
|
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
|
||||||
|
`side_w` is the sidebar column's width and **must be its own request (320), never 0**: it read 0 while
|
||||||
|
the divider sat at the window's right edge, which is a panel that is not there. It was 320 during the
|
||||||
|
first frames and 0 once the window had settled, so read it from a `placed` line rather than the first.
|
||||||
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
|
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
|
||||||
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
|
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
|
||||||
buttons exist only while the editor does. `done` follows the same rule for the editor's
|
buttons exist only while the editor does. `done` follows the same rule for the editor's
|
||||||
@@ -320,7 +340,7 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
floor are the two refusals, and because the probe emits `::clicked` straight at the
|
floor are the two refusals, and because the probe emits `::clicked` straight at the
|
||||||
widget it reaches the handler even when the button is insensitive, so `add above 0` there
|
widget it reaches the handler even when the button is insensitive, so `add above 0` there
|
||||||
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
|
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
|
||||||
- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be
|
- `header: window_button_w=W bar_w=B` (stderr, once a second) — **`W` must be
|
||||||
a small constant and must not track `B`**: the window control is a header-bar child,
|
a small constant and must not track `B`**: the window control is a header-bar child,
|
||||||
and an expanding one is measured into the title's centre box, so it stretched from the
|
and an expanding one is measured into the title's centre box, so it stretched from the
|
||||||
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
|
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
|
||||||
@@ -550,7 +570,15 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
||||||
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
||||||
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
||||||
- `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
- `[help] pages=14 parsed=14 shortest='Pause and play' 1627 B; shortcuts: app=12 plot=14 tables=26 ui=26 ui_accel=19 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. The `shortcuts:` numbers are the shortcut overview four ways.
|
||||||
|
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
|
||||||
|
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
|
||||||
|
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
|
||||||
|
every row twice: `GtkShortcutsWindow` reaches each one from more than one place in its own tree.
|
||||||
|
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
|
||||||
|
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
|
||||||
|
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
|
||||||
|
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
||||||
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||||||
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||||||
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||||||
@@ -572,5 +600,882 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
it afterwards.
|
it afterwards.
|
||||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||||
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
||||||
|
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
|
||||||
|
open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr,
|
||||||
|
`UDPSCOPE_PROBE_RESEQ_MS`) —
|
||||||
|
the resequencing path's mid-run state; the measured readings and the per-case command are in
|
||||||
|
[verification.md](verification.md). Eight things to read carefully:
|
||||||
|
|
||||||
|
1. **`backfilled` and `hole_slots` are not comparable directly.** `hole_slots` counts
|
||||||
|
*time-ring* slots once per hole; `backfilled` counts *value-ring* slots repaired and
|
||||||
|
therefore sums over every non-math channel. On a stream with 4 non-math channels they
|
||||||
|
stand a factor of 4 apart. `hole_slots=4000 backfilled=15600` in the `late` case is
|
||||||
|
healthy, not an error (the shortfall is the one hole whose late packet is still in
|
||||||
|
flight); `backfilled` well below `4 × hole_slots` would mean some channels went
|
||||||
|
unrepaired.
|
||||||
|
|
||||||
|
2. **`nan_slots=0` beside a large `holes` is healthy, not a contradiction.** A hole is
|
||||||
|
retired from the table only when its slots age out of the ring
|
||||||
|
(`ScopeModel::forget_holes_before()`) or a re-plan drops it, *never* on repair. So the
|
||||||
|
`late` case reads `holes=40 nan_slots=100`: 39 of the 40 holes were fully repaired and all
|
||||||
|
40 entries stay in the table, the 100 remaining placeholders being the one packet still in
|
||||||
|
flight. What *would* be a defect is `nan_slots > 0` with `bands_repaired == bands`, which
|
||||||
|
would mean a band claimed data was recovered while placeholders remained. The
|
||||||
|
discriminator is `bands_repaired`, not `holes`.
|
||||||
|
|
||||||
|
3. **`bands` is the visible spans, `open_holes` the whole table.** `bands` counts what the
|
||||||
|
plot is actually shading in the visible window; `open_holes` counts every live hole entry
|
||||||
|
whether or not it is in view. `bands` below `open_holes` is normal — a hole that has
|
||||||
|
scrolled out of the visible window is dropped by `build_invalid_spans()` rather than
|
||||||
|
pinned to the edge. `bands` *above* `open_holes` would name a stale span left on a plot
|
||||||
|
after its hole was retired. Neither is comparable with `holes`, which is cumulative: a
|
||||||
|
re-plan clears the table and `kMaxHoles` caps it at 64, so the re-plan case reads
|
||||||
|
`holes=315 open_holes=64`.
|
||||||
|
|
||||||
|
4. **`bands_repaired` is the faint tier.** A hole whose late packets have all arrived is
|
||||||
|
still drawn, in `Style::invalid_repaired_fill` rather than `Style::invalid_fill`, so the
|
||||||
|
display distinguishes "there is no data here" from "there was none when this window was
|
||||||
|
first drawn". The three states are: solid while the hole still lacks packets, faint once
|
||||||
|
`Hole::repaired()` is true, absent once the slots age out and `forget_holes_before()`
|
||||||
|
drops it. `bands_repaired == bands` with `nan_slots=0` is the fully recovered reading;
|
||||||
|
`bands_repaired=0` with a non-zero `backfilled` would mean the repair never reached the
|
||||||
|
display.
|
||||||
|
|
||||||
|
5. **`trigger_in_hole` must be 0, always.** A `1` means the trigger engine was fed a
|
||||||
|
reserved span's samples. The engine is specifically not fed them; a NaN comparison is
|
||||||
|
false anyway, but the real reason is that feeding them would advance the hold-off clock
|
||||||
|
across time nothing was sampled in.
|
||||||
|
|
||||||
|
6. **The discriminator for whether a reservation sized the hole right is the exit dump's
|
||||||
|
per-channel `stored=` line, not `frames=`.** `frames` counts delivered packets and is
|
||||||
|
blind to the slots a reservation pushed on their behalf. `stored` reflects what the ring
|
||||||
|
actually holds, so a reservation that mismeasured the hole shows up there and nowhere
|
||||||
|
else.
|
||||||
|
|
||||||
|
7. **`stale` is 0 by construction whenever resequencing is enabled**, which is every default
|
||||||
|
run. The client increments it only on the `reorder_window_s <= 0` branch, where it names
|
||||||
|
the pre-resequencing behaviour: a packet that did not advance the counter was dropped.
|
||||||
|
With the window open such a packet is delivered flagged instead and counted in `late`, so
|
||||||
|
a reading of 0 here is the feature working and not a dead counter. The only way to see it
|
||||||
|
move is to turn resequencing off.
|
||||||
|
|
||||||
|
8. **`overflows` is how an undersized reorder ring reads.** It counts the times a packet
|
||||||
|
needed a slot in the client's 64-slot / 4 MB reorder ring and found none, so the gap was
|
||||||
|
committed early to make room — the window those packets got was shorter than
|
||||||
|
`reorder_window_s` asked for. Nothing is dropped on that path (the arriving packet is held
|
||||||
|
after the commit, or delivered in order with the counters it skipped reported as `lost`),
|
||||||
|
which is the point: a dropped packet would leave a hole nothing could ever backfill. It
|
||||||
|
reads 0 at the four cases' 10 kS/s and non-zero at the re-plan case's 100 kS/s, where
|
||||||
|
`--reorder-every 10 --reorder-delay-ms 50` keeps about 50 packets in flight against 64
|
||||||
|
slots. A non-zero reading beside a healthy `backfilled` is a note about the ring's size,
|
||||||
|
not a defect.
|
||||||
|
|
||||||
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
||||||
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
||||||
|
|
||||||
|
## `[condition-probe]`
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_CONDITION_MS`, with the mock's `--glitch-at`/`--glitch-width` (legs 0 and 1) or
|
||||||
|
`--quiet-at` (leg 3).
|
||||||
|
|
||||||
|
Four legs, selected by `UDPSCOPE_PROBE_CONDITION_SHAPE`. Every leg prints one line of the form:
|
||||||
|
|
||||||
|
```
|
||||||
|
[condition-probe] type=T cmp=C width=W t0=T0 measured=M at0=A earlier=E level2=L2 mode=D
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured readings (`--rate 10000 --freq 5`, each on a fresh port). Shape 0's `level2=` and
|
||||||
|
`mode=` fields are from the `[search-probe]` run in `.superpowers/sdd/progress.md`; shapes 2 and
|
||||||
|
3 were re-measured with those fields present. **Shape 1's `level2=0.500000 mode=2` alone is read
|
||||||
|
from the code** rather than from a run (`condition_probe_arm` always sets `level2` to `0.5`, and
|
||||||
|
the probe now reports only once the mode is `Held=2`):
|
||||||
|
|
||||||
|
```
|
||||||
|
shape=0 type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2
|
||||||
|
shape=1 type=1 cmp=1 width=0.010000 t0=2.160100 measured=0.200000 at0=nan earlier=nan level2=0.500000 mode=2
|
||||||
|
shape=2 type=2 cmp=0 width=0.010000 t0=1.706700 measured=nan at0=-0.500907 earlier=-0.498185 level2=0.500000 mode=2
|
||||||
|
shape=3 type=3 cmp=0 width=0.010000 t0=4.120200 measured=0.300100 at0=nan earlier=nan level2=0.500000 mode=2
|
||||||
|
```
|
||||||
|
|
||||||
|
`measured` is the reading, not a phase. It is the pulse width read back from the held snapshot's
|
||||||
|
own stamps, which are edge-relative: the snapshot is the ground truth, not the mock's absolute
|
||||||
|
clock.
|
||||||
|
|
||||||
|
**`t0` is not an assertion.** It is a stream stamp, and the stream clock is rebased by whichever
|
||||||
|
rebuild last dropped history, so the same leg reads a different `t0` on every run — leg 2 read
|
||||||
|
`1.7103`, `1.6975` and `1.8719` on three consecutive runs, and leg 3 read `2.6552` against a
|
||||||
|
`--quiet-at 4.0` stream. Read `measured`, `at0`/`earlier` and `mode`; read `t0` only as
|
||||||
|
*something* latched. A leg that is genuinely not working says so in `measured`/`at0`, or does not
|
||||||
|
latch at all.
|
||||||
|
|
||||||
|
Four more things are worth knowing before calling a reading wrong:
|
||||||
|
|
||||||
|
- **Leg 0**: `measured=0.004` is the injected glitch width (here the search-probe run used
|
||||||
|
`--glitch-width 0.004`; the earlier standalone probe run read `0.002` with `--glitch-width 0.002`).
|
||||||
|
A `measured` near the signal's own half-period means the width qualifier did not qualify — the
|
||||||
|
trigger caught a normal edge instead of the runt.
|
||||||
|
- **Leg 1**: `measured=0.200` is an ordinary square half-period; every square pulse satisfies
|
||||||
|
`> 0.010`. **Use a 1.0 s window with 50 % pre** so the opening edge of a 0.2 s pulse lies within
|
||||||
|
the pre window.
|
||||||
|
- **Leg 2**: `at0` must lie **outside** `[-0.5, +0.5]` and `earlier` **inside** it — those are the
|
||||||
|
two sample values around the band crossing. The probe prints `nan` for any field a leg cannot
|
||||||
|
measure. **This leg uses `sine`**, not `square`: the square is exactly ±1 and has no sample
|
||||||
|
inside the `[-0.5, +0.5]` band, so a window condition on the square can never fire. This leg
|
||||||
|
uses a **0.2 s window with 10 % pre** — short enough to capture the crossing, because the event
|
||||||
|
is the band exit, which lands at x = 0 in the held snapshot.
|
||||||
|
- **Leg 3**: `measured=0.300` is the dropout timeout. **This leg also uses `sine`**. A timeout of
|
||||||
|
0.050 s against a sine crossing zero every 0.1 s latches on a healthy signal, so use 0.300 s
|
||||||
|
or longer; **use a 1.0 s window with 50 % pre** so the last crossing before the outage lies
|
||||||
|
within the pre window. It needs `--quiet-at`: with `--quiet-at -1` the leg correctly never
|
||||||
|
latches, which is the check that the dropout is not firing on the rebuild's own gap.
|
||||||
|
|
||||||
|
The channel-selection line `[condition-probe] <name>=<index> of <N> channels` is printed before
|
||||||
|
the trigger fires; if the channel is not found this reads `-1` and the probe fails immediately.
|
||||||
|
There are two failure lines, and they name different defects:
|
||||||
|
|
||||||
|
- `no latch after N polls` — the condition never fired: a wrong shape, a missing mock flag, or
|
||||||
|
the wrong signal selected.
|
||||||
|
- `latched but never held` — the edge was accepted but the post window never filled, so there is
|
||||||
|
no snapshot to read. The report deliberately waits for `Held`: a finite `trigger_time()` only
|
||||||
|
means the edge was *accepted*, and every field is read from `display_snapshot()`, whose stamps
|
||||||
|
span x = 0 only once the event is held. Reporting on the latch alone printed `nan` for legs
|
||||||
|
that were working.
|
||||||
|
|
||||||
|
## `[stats-probe]`
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_STATS_MS`. Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and
|
||||||
|
`UDPSCOPE_PROBE_MEASURE_MS` to add measurements. Run against the fast mock
|
||||||
|
(`--rate 1300000 --batch 2000 --freq 1`) for readable figures.
|
||||||
|
|
||||||
|
Three phases, one line each. Every line carries `phase=`, `on=` (the toggle), `passes=` (the
|
||||||
|
pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi=…` group per
|
||||||
|
measurement. Phase 1 adds `dn=` per group.
|
||||||
|
|
||||||
|
Measured readings (fast mock, 1 Hz sine, with
|
||||||
|
`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`):
|
||||||
|
|
||||||
|
```
|
||||||
|
phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1
|
||||||
|
phase=1 … dn=0 (on all four measurements)
|
||||||
|
phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1
|
||||||
|
```
|
||||||
|
|
||||||
|
- `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the
|
||||||
|
thousands means the accumulator is fed per sample, not per recompute. `mean=0.633511` (≈ 2/π)
|
||||||
|
for the `top` measurement on a unit sine is the check that the threshold types are correct, not
|
||||||
|
merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`.
|
||||||
|
- `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once
|
||||||
|
when it settles and then never again. A `dn` near 120 is the second defect — accumulating per
|
||||||
|
frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards
|
||||||
|
zero. Note that `n` is **not** reset by the pause or the resume; `passes` continues growing.
|
||||||
|
- `phase=2` — after Reset. **`n` must be 0 or 1** (0 if the report ran before the next recompute,
|
||||||
|
1 if after); anything larger means Reset cleared nothing.
|
||||||
|
|
||||||
|
## `[search-probe]`
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
|
||||||
|
set in exactly one place for both the live trigger and the search, so `UDPSCOPE_PROBE_CONDITION_MS`
|
||||||
|
must **not** also be set (two arms on the same port race). Run with
|
||||||
|
`--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and
|
||||||
|
`UDPSCOPE_EXIT_AFTER_MS=12000`.
|
||||||
|
|
||||||
|
Measured output (port 8419):
|
||||||
|
|
||||||
|
```
|
||||||
|
[condition-probe] type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2
|
||||||
|
[search-probe] started on channel 0, armed t0=5.710000
|
||||||
|
[search-probe] state=2 hits=12 scanned=4.5115 total=4.5115 chunks=2 worst=0.106ms total=0.116ms armed t0=5.710000 vs scanned first=5.710000 err=0 agree=1
|
||||||
|
[search-probe] jumped to hit 0 t=1.710000 mode=4 axis=[-0.1000..0.1000] pos=1
|
||||||
|
[search-probe] marks placed=1 of 1 in view (12 found) locked=1 mode=4 axis=[-0.1000..0.1000]
|
||||||
|
[udpscope] search marks: placed=1 hits=12 locked=1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`agree=1` is the load-bearing line of the whole feature.** The search and the live trigger run
|
||||||
|
the same `EventDetector` over the same stamps, so `agree=0` means one of the two is reading a
|
||||||
|
different clock — which is exactly the class of defect a search that merely "returns plausible
|
||||||
|
times" hides. Here `err=0`: exact agreement, not within tolerance.
|
||||||
|
- `state=2` is `BufferSearch::State::Done` (the enum's ordinal). `state=1` would be `Running`.
|
||||||
|
- `mode=4` after the jump is `DisplayMode::Paused` (the enum's ordinal: `Live=0`, `Capturing=1`,
|
||||||
|
`Held=2`, `Waiting=3`, `Paused=4`). `mode=2` would be `Held` — the jump having left the held
|
||||||
|
event in place instead of publishing its own pause view.
|
||||||
|
- **`hits=` is a count, not a constant.** It depends on the retained span and on the arm phase,
|
||||||
|
both of which move between runs: the same invocation read `hits=12` at `armed t0=5.710000` and
|
||||||
|
`hits=11` at `armed t0=5.742000`. A count in the low tens is the reading; `hits=0` with
|
||||||
|
`agree=0` is the defect.
|
||||||
|
- **`placed=1 of 1 in view` is the correct answer, not an under-count.** The jump frames a 0.2 s
|
||||||
|
window and the glitches are 0.4 s apart (one per square period at 5 Hz / square half-frequency),
|
||||||
|
so only one hit can be in view at a time. `1 of 1` against `(12 found)` is the marks agreeing
|
||||||
|
with the window, not with the hit list.
|
||||||
|
- **`total=4.5115` far short of the run is not a defect.** `condition_probe` enables the channel
|
||||||
|
(which starts acquisition) and then calls `apply_time_window()`, and each of those rebuilds the
|
||||||
|
buffer and drops history. 4.51 s was the genuinely retained span.
|
||||||
|
- `locked=1` says the marks are out of the pointer's reach, like every other readout. The exit
|
||||||
|
dump's `search marks: placed=1 hits=12 locked=1` is the same assertion from the dump path; its
|
||||||
|
`placed=` counts marks in the **visible window at exit**, which is the live view after the probe
|
||||||
|
disarmed the trigger — so `placed=0` there is legitimate and asserts nothing about the search.
|
||||||
|
The load-bearing `placed=` is the `[search-probe] marks` line, emitted a frame after the jump,
|
||||||
|
which quotes the window the scope has actually moved to.
|
||||||
|
|
||||||
|
## `[capture-probe]`
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_CAPTURE_MS` toggles the packet capture through the status-bar button's own
|
||||||
|
handler, then reads the ring file back off disk and prints one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
[capture-probe] enabled=1 read_ok=1 path=/home/m/.cache/udpscope/127.0.0.1-9102/capture.bin slots=1000 stored=253 resident=253 cursor=253 torn=0 truncated=0 header_ok=1 first_seq=1 last_seq=253 payload=2031337 file=65576064 open_ms=2.9 t0=1791524811.925
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `1` after the probe's toggle; `0` when the open failed, with the reason on stderr | a toggle that sets a flag nobody acts on, or an open that failed silently |
|
||||||
|
| `read_ok`, `header_ok` | `1` | a file whose header does not round-trip — the format changed without the version bump |
|
||||||
|
| `stored` | datagrams since the toggle; equal to the exit dump's datagram count over the same interval | the producer storing more or fewer datagrams than the client handed it — a copy in the capture path, which the design forbids |
|
||||||
|
| `resident` | `min(stored, slots)`; equal to `slots` once wrapped | the ring reusing a slot before its `seq` was published — a torn slot that reads as a hole in history |
|
||||||
|
| `cursor` | `stored mod slots` | a cursor maintained out of step with `seq`; the file's hint would mislead the `xxd` user it exists for |
|
||||||
|
| `torn` | `0` | a slot whose bytes landed but whose `seq` never did *without* a crash — the release-store ordering is wrong |
|
||||||
|
| `truncated` | `0` | a datagram longer than `UDPS_MAX_DATAGRAM_BYTES` — the slot sizing disagrees with the client's receive buffer |
|
||||||
|
| `first_seq` | `1` on a fresh ring | the ordinal not starting where the file starts — a leftover file being appended to instead of renamed |
|
||||||
|
| `last_seq - first_seq` | `slots - 1` on a wrapped ring | ordering lost across the wrap: the newest slot is not the largest `seq` |
|
||||||
|
| `payload` | the bytes the wire delivered over the interval | a capture storing headers and not payloads, or truncating silently |
|
||||||
|
| `file` | `64 + slots * slot_bytes`, a constant for a given N | the reservation is short — which with `mmap` is the SIGBUS the design forbids |
|
||||||
|
| `open_ms` | single-digit ms on a local filesystem | a blocking filesystem between two `recv()` calls — the producer stall the popover's toggle costs |
|
||||||
|
|
||||||
|
With a **failing** open (the cache path colliding with a plain file, a full disk), the line reads
|
||||||
|
`enabled=0 read_ok=0` with every count 0, the toggle returns to off on the next tick, and the
|
||||||
|
popover row reads `failed: <strerror>` — one stderr line, no retry storm against a full disk.
|
||||||
|
|
||||||
|
## `[defaults]` and `[configs]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01
|
||||||
|
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
|
||||||
|
[configs] before=default active=default
|
||||||
|
[configs] save-as=1 names=probe,default active=probe
|
||||||
|
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
|
||||||
|
[configs] after forget names=default
|
||||||
|
```
|
||||||
|
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
|
||||||
|
sets them to **7.5 and 250** before saving, so a key that never reached the file reads back as the defaults
|
||||||
|
(10 and 0) instead -- which is the only way a round trip can tell a written key from an absent one. They read
|
||||||
|
`-1` when the saved configuration holds no measurement, which is what the run above does.
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it |
|
||||||
|
| `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number |
|
||||||
|
| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01` | the same, per trigger field; a level of `-1e12` is a number nobody chose |
|
||||||
|
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
|
||||||
|
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
|
||||||
|
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
|
||||||
|
|
||||||
|
## `[rowmenu]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1
|
||||||
|
[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal]
|
||||||
|
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1
|
||||||
|
[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot]
|
||||||
|
[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
|
||||||
|
[rowmenu] remove-clicked 'sine' trace_freed=1
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `h` | 18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (`popover.udpscope-menu`) |
|
||||||
|
| `label_x` | 26 for every row, 52 for **Back** | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for |
|
||||||
|
| `accel_right`/`W` | equal when the row has a shortcut (`Fit all` → `F`), `-1` when it has none | the shortcut not flush right, or shown where the action has none |
|
||||||
|
| `remove_rows` | 1 per occupied trace slot, plus `All traces on this plot` when there is more than one, plus the **Back** row | a remove view built from the wrong row, or missing its way out |
|
||||||
|
| `remove_row_gone`, `remove_row_in_main` | 1 | a submenu that floats beside the menu instead of replacing it |
|
||||||
|
| `can_open_again` | 1 | the second click not reopening: the `GtkMenuButton`-owned submenu that collapsed and left its row dead |
|
||||||
|
| `bad` | 0 for every view | any row outside the compact style. Each view is measured a step after the click that swapped it in — in the same call the new child is unallocated (`h=0`), which reads like a style defect and is not one |
|
||||||
|
| `trace_freed` | 1 | a remove entry that dismisses the menu without taking the trace off the row |
|
||||||
|
|
||||||
|
## `[sources]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[sources] form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 connect_sensitive=1
|
||||||
|
[sources] configuration created: 9.9.9.9:7000/probe-host note='made by the new-host probe' buffer=32 relative=1
|
||||||
|
[sources] picked 9.9.9.9:7000 multicast= interface= buffer=32 relative=1 capture=0 config=probe-host note='made by the new-host probe'
|
||||||
|
[sources] edit form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 button='Save'
|
||||||
|
[sources] configuration saved: 9.9.9.9:7000/bench note='edited by the probe' buffer=16 relative=0
|
||||||
|
[sources] 1 known: 9.9.9.9:7000/bench plots=0 signals=0 buffer=16 relative=0 capture=0 note='edited by the probe' |
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `form:` | the name, note, buffer and clock the form was given | a form field that is not wired: the four things a host could not be given before |
|
||||||
|
| `connect_sensitive` | 1 once the server is typed | the button's validation not following the entry |
|
||||||
|
| `configuration created`/`saved` | the identity the form held, with the settings | a save that writes somewhere other than what the form shows |
|
||||||
|
| `edit form:` … `button='Save'` | the **existing** configuration's values, pre-filled | the edit path not reading the file — an edit form that opens empty edits nothing |
|
||||||
|
| `known:` after a rename | one row, under the new name | a rename that leaves the old file, so the chooser lists the same session twice |
|
||||||
|
| `known:` for a settings-only configuration | a row, though `signals=0` | the listing filter treating "no signals yet" as "nobody configured this", which hides every host the form set up |
|
||||||
|
|
||||||
|
## `[sources] loading`
|
||||||
|
|
||||||
|
```
|
||||||
|
[sources] picked 127.0.0.1:21077 multicast= interface= buffer=16 relative=0 capture=0 config=default note=''
|
||||||
|
[sources] loading: title='Connecting to 127.0.0.1:21077' detail='planning the 16 MB sample buffer'
|
||||||
|
[udpscope] CONFIG: 5 signals, publish=accumulate
|
||||||
|
[sources] loading: closed
|
||||||
|
```
|
||||||
|
|
||||||
|
The loading window covering the gap from the chooser to the first frame of the real window. It is
|
||||||
|
printed by any run that reaches the chooser, so `UDPSCOPE_PROBE_PICK_MS` reads the host leg and
|
||||||
|
`UDPSCOPE_PROBE_WAVE=<path>` the file one (`title='Opening rt.npy' detail='reading the recording'`).
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `loading:` after `picked` | the address or basename just picked, and the buffer size | the window raised from the wrong place: before the chooser's answer it cannot name the wait, after the model it covers nothing |
|
||||||
|
| its position in the log | **before** the `CONFIG:` or `waveform loaded:` lines | a window shown after the work it is meant to cover — the ten seconds of empty screen, unchanged |
|
||||||
|
| `loading: closed` | **after** `CONFIG:`, i.e. once the real window is presented | a hide that runs earlier, which puts the screen back to empty for the frames the main window takes to map |
|
||||||
|
| no `loading:` line at all | correct for a `--server` or `--open` run | only the path that *asked* gets one; a line here means every probe just changed path |
|
||||||
|
|
||||||
|
`loading ui: loading.ui is missing an id this code reads` is the file-and-code mismatch the chooser's
|
||||||
|
own check names, and like it the run continues: a loading window that cannot be built must not stop
|
||||||
|
the app from opening.
|
||||||
|
|
||||||
|
## `[sources] prefs`
|
||||||
|
|
||||||
|
```
|
||||||
|
[sources] prefs: opened=1 rows=1
|
||||||
|
[sources] prefs: dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark cfg_relative=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_PICK_MS` + `UDPSCOPE_PROBE_CHOOSER_PREFS=1` opens the preferences from the *chooser*
|
||||||
|
rather than from the main window's menu, fills the form through the same `prefs_probe_fill_and_apply()`
|
||||||
|
the app's probe uses, and reports both the file and `cfg_relative` -- what the run about to start will
|
||||||
|
actually use.
|
||||||
|
|
||||||
|
- The second line must echo the probe's input, exactly as `[prefs-probe]` does.
|
||||||
|
- `cfg_relative=1` is the chooser's half of the contract: relative time is the one preference that can
|
||||||
|
still change *this* run, because `main()` plans the buffers after this dialog returns.
|
||||||
|
- The run leaves the preferences window **open** on purpose: the pick that follows destroys the chooser
|
||||||
|
with the window still up. A `Gtk-CRITICAL` or a crash here is the transient-window lifetime being
|
||||||
|
wrong, not a cosmetic failure.
|
||||||
|
- A `[sources] prefs: could not write <path>` line, with the second line reading the *defaults*
|
||||||
|
(`dir= keep=32 rel=0 theme=auto`), is the signature of the config directory not having been created:
|
||||||
|
the chooser is the first thing that writes preferences on a fresh install, so it is where a
|
||||||
|
`mkdir()` that does not make its parents shows up. This reading is why that is now caught here rather
|
||||||
|
than reported by a user whose settings quietly did not stick.
|
||||||
|
- The line after it, `[sources] picked ... relative=1`, is the pair that matters: the preference stated
|
||||||
|
in the dialog has to survive the row click that follows, which overwrites the rest of the config from
|
||||||
|
the source's stored session.
|
||||||
|
|
||||||
|
## `[sources] settings from configuration`
|
||||||
|
|
||||||
|
```
|
||||||
|
[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500
|
||||||
|
```
|
||||||
|
|
||||||
|
Printed by `apply_stored_settings()` when a source is named on the command line: the stored
|
||||||
|
configuration's startup settings, filled in where the command line was silent. A `buffer=` here
|
||||||
|
that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a
|
||||||
|
configuration that carries settings, is the defect this exists for — a saved host opened with
|
||||||
|
`--server` ran on the defaults, so an edit to its buffer or clock never took effect.
|
||||||
|
|
||||||
|
## `[sources] waveforms` and the waveform load
|
||||||
|
|
||||||
|
```
|
||||||
|
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1]
|
||||||
|
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms
|
||||||
|
[sources] picked waveform /tmp/rt.npy
|
||||||
|
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]
|
||||||
|
[udpscope] waveform loaded: /tmp/long.npy signals=4 samples=31250 rows=1000000 decim=32 span=19.999980s rate=50000 time=[-4.000000..15.999980]
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, and all four buttons `1` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw |
|
||||||
|
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=waveforms` once the page is shown | the Waveforms page not built, or a stale path offered as if it were openable |
|
||||||
|
| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads |
|
||||||
|
| `signals=`, `rows=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels |
|
||||||
|
| `samples=` × `decim=` | `rows`, to within the last partial group | the loader not filtering the file into the plan it built: every row pushed into a ring that cannot hold them leaves the file as its own tail (`stored=` far above `published=` in the channel dump, and a 20 s recording on screen as its last 4 s) |
|
||||||
|
| `decim=` | 1 whenever the recording fits the buffer at its own rate; a power of two above it | a decimation that moved without the span moving — the second line above is the reading to compare against: `samples` fell 32-fold and `span=` did **not** |
|
||||||
|
| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
|
||||||
|
| `frames=0 packets=0` beside `channels=N` | 0 — no acquisition | a socket opened for a file, which would also make the starvation notice live |
|
||||||
|
|
||||||
|
## `[drop-probe]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||] view=[-3.999690..15.999670] repins=2
|
||||||
|
[drop-probe] dropped signal 0 -> 1 slots=[sine|sine||]
|
||||||
|
[drop-probe] dropped signal 1 -> 1 slots=[sine|sine|square|]
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `rows` | the column count, capped at `RowSet::kMaxRows` — one row per trace | rows sized four signals to one row: the only row fills and *every* drop is refused ("plot 1 already holds four signals"), which reads as a scope that cannot plot anything |
|
||||||
|
| `dropped … -> 1` | 1, and the row gains that trace | a drop that cannot find a free slot, or a signal with no channel to bind |
|
||||||
|
| `channels` | one per signal | a file whose columns did not become channels, so nothing can be plotted at all |
|
||||||
|
| `view` | the file's own span, or whatever the user last zoomed to — never a *sub-range of it the user did not ask for* | a pin that no longer holds the data: `view=[3.999690..15.999670]` against a file spanning `[-4..16]` is the pin written as a pre/post split where a range belongs |
|
||||||
|
| `repins` | **2**, for the whole run, however long | the file view stating its pin per frame instead of refreshing its numbers. Each statement writes the group's range, so the axis alternates between the file's span and the pin's and no gesture survives: `repins=116` at three seconds is one per frame, and it reads on screen as a flicker in which nothing can be clicked |
|
||||||
|
|
||||||
|
## `[events]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
|
||||||
|
[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
|
||||||
|
[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
|
||||||
|
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
|
||||||
|
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 pane=1
|
||||||
|
[events] activated row0: replaying=1 pane=1 pane_w=240 pane_box=228 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
|
||||||
|
[events] replayed: channels=3 samples=20001 replaying=1 waveform=1
|
||||||
|
[events] live again: replaying=0 waveform=0
|
||||||
|
[events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=10/5
|
||||||
|
[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
|
||||||
|
[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
|
||||||
|
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
|
||||||
|
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1
|
||||||
|
[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1
|
||||||
|
[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1
|
||||||
|
[events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000
|
||||||
|
[events] group left: replaying=0 kind=0 waveform=0
|
||||||
|
[events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=22/16
|
||||||
|
```
|
||||||
|
|
||||||
|
`thumb_size` is the built size of the row's picture: `kEventThumbW` wide, and as tall as the *stored*
|
||||||
|
preview's own aspect makes it at that width, clamped to `[kEventThumbH, kEventThumbMaxH]`. So it moves
|
||||||
|
with the event's **signal count** (a three-signal event sits on the floor at `138x46`, a six-signal one
|
||||||
|
reads `138x75`) as well as with the CSS border and the display's scale factor. What it must never be is
|
||||||
|
`0x0`, and a height pinned to the floor for *every* event says the aspect fit was dropped and tall
|
||||||
|
previews are being squashed again.
|
||||||
|
|
||||||
|
`[events] preview redrawn:` is the repair path, and the probe forces it: step 1 deletes the newest
|
||||||
|
`.bmp`, drops the thumbnail cache and refreshes the pane, so this line is `EventStore::redraw_preview()`
|
||||||
|
drawing the picture again from the `.npy`. `stored` is the file it wrote, read back out of the BMP
|
||||||
|
header — `480` wide and `44 * signals` tall — and `row_drawn=1` says the row got a paintable from it.
|
||||||
|
The probe therefore needs a cache a **previous** run populated: it fires ~4.5 s in, before this run's
|
||||||
|
first latch.
|
||||||
|
|
||||||
|
`[events] live restored (…)` is the way *back*, read once per way into a file view — after a plain
|
||||||
|
replay and after a group — and four of its fields are a **pair**: the value now, and the value the
|
||||||
|
live view held before the file view opened over it. The probe captures that expectation itself
|
||||||
|
(`capture_live_expectation()`, `App::dbg_live_*`) rather than asking the restore path for it: a
|
||||||
|
reading taken from the mechanism under test asserts nothing. The window is set to 0.5 s just before
|
||||||
|
the event is opened precisely so the pair can disagree — with the event's own span there would be
|
||||||
|
nothing to tell "restored" from "untouched".
|
||||||
|
|
||||||
|
Both lines are read a *later* step, not in the handler that pressed *Live*: the restore waits for the
|
||||||
|
restarted client's first CONFIG, so it is performed by a tick ~900 ms on. `pending=0` is what says it
|
||||||
|
happened; `gen=a/b` is the generation now against the one *Go live* read. A **stuck `pending=1`** is
|
||||||
|
not a reading about the restore but about the stream behind it — the generation never moved, or the
|
||||||
|
channel list came back empty, which is how a session file poisoned by a save taken during a group
|
||||||
|
produced `pending=1 channels=0` and a live view with nothing on it.
|
||||||
|
|
||||||
|
| field | value it must read | the defect a wrong reading names |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `n`, `stem`, `seq` | one entry per latch, named by its time and the run's counter | two events in one second overwriting each other, or a cache that is never written |
|
||||||
|
| `dur` | `pre + post` for the event's window | the snapshot's own window not recorded, so the list disagrees with the picture |
|
||||||
|
| `signals`, `samples` | the event's channels and rows | a table built from the *rows* instead of the snapshot: only what happened to be plotted |
|
||||||
|
| `thumb_bytes` | a real 24-bit BMP, `54 + 480 * (44 * signals) * 3` rounded up per row to a multiple of four -- so it *grows with the signal count* (190134 at three signals, 380214 at six) | a preview that is empty or missing -- the widget snapshot of a `GtkGLArea`, which is NULL -- or a picture back on a fixed total height, which gave a six-signal event 26 px a signal and an eight-signal one 20: a smear, not a waveform |
|
||||||
|
| `recorded` | 1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) |
|
||||||
|
| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane |
|
||||||
|
| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot |
|
||||||
|
| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode |
|
||||||
|
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=1 pane=1 recording=0` in both **once the cache holds anything** (the probe's own run does by this point) | the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or `pane_w` not being 240 with the pane open. With an *empty* cache the same two lines read `button_visible=0 pane=0`, which is the empty column the item exists to prevent |
|
||||||
|
| `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both. It also catches the cache never being *created*: `event_preview_texture()` answers from the cache or not at all, so a reader that does not create one leaves **every** row blank, which is what `thumbnail=0` meant for as long as it read 0 |
|
||||||
|
| `preview redrawn` | `stored=480x<44*signals>` and `row_drawn=1` | a preview that cannot be repaired. The `.npy` is the event and the `.bmp` is only a picture of it, so a write that failed, a truncated file or a cache from an older build must be redrawn rather than shown as a blank row -- `row_drawn=0` is a row the user reported as "sometimes missing" |
|
||||||
|
| `axis`, `marker`, `pane_box` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000`, and a `pane_box` close to `pane_w` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker; or a pane whose *rendered* width is 0 while the divider says 240 -- the position is what was asked for, `pane_box` is what the pane actually got, and a divider at 240 with a zero-width pane is still no pane |
|
||||||
|
| `rows_kept`, `selected` (in `activated row0`) | `rows_kept=1` and `selected=N/N` -- the counts either side of the activation equal | the list **rebuilt** by the activation. It is the row *object* that is compared, not its index: a rebuilt list has a row 0 too, and only identity tells a list that was left alone from one that was torn down under the user's scroll position and their comparison selection. `mark_events_on_screen()` must rewrite the titles in place |
|
||||||
|
| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread |
|
||||||
|
| `window`, `trigger`, `channel`, `level` (in `live restored`) | each equal to its `(want …)` | the way back out of a file view discarding what the live view was set to. One wrong half names which writer won: a `window` that reads the **session file's** value is the deferred `apply_layout()` beating the restore; `trigger=0` is the toggle the file view switched off and nobody switched back; an empty or shifted `channel` is an index restored instead of a name, after a rebuild renumbered (or a group renamed) the channels; and a `level` that moved on its own is `rebuild_trigger_signals()` re-defaulting it because `trigger_level_touched` was not restored with the configuration |
|
||||||
|
| `pending`, `gen` (in `live restored`) | `pending=0`, and the two generations different | the restore still waiting. It is gated on the stream's first CONFIG, so `pending=1` says the channel list the stored trigger name needs never arrived -- with `channels=0` behind it, a session file that enabled nothing |
|
||||||
|
| `pinned`, `held`, `mode` (in `live restored`) | `pinned=0`, **or** `pinned=1` with `held=1` | a file's pin surviving the way back, which is a live view that cannot be navigated. `apply_time_window()` drops it; the second reading is the legitimate case -- a restored trigger that was *on* can latch again before the probe reads, and a held capture is pinned, correctly |
|
||||||
|
| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
|
||||||
|
| `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored |
|
||||||
|
| `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once |
|
||||||
|
| `compare buttons` | `selected=2` and both buttons sensitive | the buttons reading something other than the selection, or staying insensitive with a valid pair selected |
|
||||||
|
| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `pane=1` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced |
|
||||||
|
| `compare plots` | `area_b=1 rows_a=1 rows_b=1 list=4 same_x=1` | no second plot on screen, a second plot with a different row count, two plots not on one x window, or `list` above the signal count -- the panel listing each event's copy of a signal as a signal of its own |
|
||||||
|
| `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees |
|
||||||
|
| `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds |
|
||||||
|
| `group left` | `replaying=0 kind=0 waveform=0` | the group surviving *Live*: a file view with no file behind it |
|
||||||
|
|
||||||
|
## `[boxzoom-probe]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[boxzoom-probe] open: swept=200x10 frame=100,0 200x853 axes_h=853 band=1 axis_before=[0.000000..2.000000]
|
||||||
|
[boxzoom-probe] closed: frame=0x0 gone=1 axis_after=[0.000000..0.553167] zoomed=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_BOXZOOM_MS` injects a Ctrl box zoom straight into row 0's controller -- the way a
|
||||||
|
headless probe has to, since the area's gestures arrive from GTK -- and reads the frame back off the
|
||||||
|
*plot* one frame later. That is what makes this the adapter's per-frame sync being exercised: the
|
||||||
|
library measures and draws the rect in tests of its own.
|
||||||
|
|
||||||
|
- `band=1` is the reading that matters. The pointer swept `200x10`, and the frame is `200` wide and
|
||||||
|
`1665` -- the height of the axes -- tall. Ctrl zooms **x only**, so the band across the plot is what
|
||||||
|
the release will apply, while the sliver the pointer actually swept is only the gesture. Its `x` and
|
||||||
|
`y` must be the press point's.
|
||||||
|
- `closed: frame=0x0 gone=1` is the other half of the contract: a finished gesture leaves no frame, so
|
||||||
|
nothing goes on claiming a selection is open. Both lines together are the whole feature.
|
||||||
|
- `axis_before`/`axis_after`/`zoomed` are the *settled* axis, read a few ticks after the release so
|
||||||
|
that whatever pin or clamp the view has has already had its say -- which is the point: a rect can
|
||||||
|
report the gesture and nothing about whether it did anything. **Run this in both modes and both must
|
||||||
|
read `zoomed=1`.** A file opened with `--open` reading `zoomed=0` while the live view reads `1` is the
|
||||||
|
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
|
||||||
|
axis from the ring each frame is what swallowed the gesture.
|
||||||
|
|
||||||
|
## `[sidebar-probe]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[sidebar-probe] beside window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1)
|
||||||
|
[sidebar-probe] regained window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_SIDEBAR_MS` reads the sidebar in the two states a window can put it in, and against a hide
|
||||||
|
that did not come from the toolbar: `beside` first, then the sidebar is forced into the **overlaid** state
|
||||||
|
(`collapsed`, which is what a window with no room beside the plot produces), then the split view's own
|
||||||
|
`show-sidebar` is **turned off behind the button's back** — an overlay swipe, or the widget hiding itself —
|
||||||
|
and `regained` reads 400 ms later.
|
||||||
|
|
||||||
|
- **`sidebar` must never be 0.** It was, for every window narrower than the 940 px the divider used to sit
|
||||||
|
at, and the pane could only be found by dragging the divider back — the report this reading exists for.
|
||||||
|
It must read its own width (320) in *both* states: `collapsed` changes where the sidebar is, never how
|
||||||
|
wide it is.
|
||||||
|
- **`shown` and `button` must agree, always.** They are the panel and the toolbar button, and a button that
|
||||||
|
says "shown" over a hidden panel is the report this reading exists for. `shown=1 button=1` on `regained`
|
||||||
|
is also the rule that a hide not asked for through the button does not win.
|
||||||
|
- **`sens=0` with no source is correct.** The button is insensitive and un-pressed while there is nothing to
|
||||||
|
put in the sidebar; `button=0` is the button telling the truth about the panel rather than the panel
|
||||||
|
having forgotten the wish. A source reconnecting must give `shown=1 button=1` again — that is the reading
|
||||||
|
a `wanted` that got eaten by the no-signals frame shows as `shown=0 button=0(sens=1)`, which is the
|
||||||
|
regression.
|
||||||
|
- `collapsed=1` means the window is narrower than `kSidebarRoom` (900), which is the expected reading on a
|
||||||
|
scaled display, where the logical width is half what the screen looks like.
|
||||||
|
|
||||||
|
## `[prefs-probe]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
|
||||||
|
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
|
||||||
|
[prefs-probe] default restored: file=''
|
||||||
|
[prefs-probe] browse: cancelled=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences, changes every row, and reads the settings file *back
|
||||||
|
from disk* after each change -- not from `App::settings`, because a dialog that edits its own copy and
|
||||||
|
never writes is exactly the failure this has to catch. **A change applies as it is made**: the rows are
|
||||||
|
libadwaita's (`AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`), and the `preferences.ui`
|
||||||
|
dialog has no Apply button for the probe to press.
|
||||||
|
|
||||||
|
- `opened=1 rows=1` is the dialog having come up *and* its rows having resolved by id. The file is
|
||||||
|
checked by the build (`xml-stripblanks`) and by `GtkBuilder` at load; a missing **id** is the one
|
||||||
|
mistake neither sees, and reading a null row on the first change is what it costs.
|
||||||
|
- `dir` is the directory the *row* is offering -- the preference if there is one, otherwise the default
|
||||||
|
written out in full -- and `store` is the directory the cache is really using. **`store` must be `dir`
|
||||||
|
plus the source's folder name**, which is the reading that says the row shows the path in force rather
|
||||||
|
than a plausible string.
|
||||||
|
- The second line must echo the probe's own input: directory `/tmp/udpscope-prefs-events`, keep `7`,
|
||||||
|
relative time `1`, theme `dark`. Any field that differs means the row, the write or the parse dropped
|
||||||
|
it. `live=1` is the theme having reached the running style manager rather than only the file.
|
||||||
|
- `default restored: file=''` closes the loop from the other side: the probe puts the default path back
|
||||||
|
into the row, and the file must end up with **no** `event_cache_dir` at all. A path in the file is a
|
||||||
|
user's choice, and leaving the default is not one -- pinning it there would stop the setting following
|
||||||
|
the cache directory it is named after.
|
||||||
|
- `browse: cancelled=1 icon_ok=1 field='...'` is the folder chooser. `cancelled=1` is its cancellable
|
||||||
|
having been cancelled, which is the *cancel* path a picker invites (a user's own Cancel button arrives
|
||||||
|
in the same callback with the same null answer); `icon_ok=1` is the icon theme knowing
|
||||||
|
`folder-open-symbolic`; and the field must still read the directory it read before -- **a cancelled
|
||||||
|
picker changes nothing**.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
|
||||||
|
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
|
||||||
|
[prefs-probe] default restored: file=''
|
||||||
|
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences dialog, fills its form, clicks **Apply** and reads
|
||||||
|
the settings file *back from disk* -- not from `App::settings`, because a dialog that edits its own
|
||||||
|
copy and never writes is exactly the failure this has to catch.
|
||||||
|
|
||||||
|
- `opened=1` is the window having come up; `apply=0` would mean its *Apply* button was never stored,
|
||||||
|
which would make the click below test nothing.
|
||||||
|
- `dir` is the directory the dialog *offers* -- the preference if there is one, otherwise the default
|
||||||
|
written out in full (it is deliberately not a description: "the standard location" is not something a
|
||||||
|
user can act on) -- and `store` is the directory the cache is really using. **`store` must be `dir`
|
||||||
|
plus the source's folder name**, which is the reading that says the field shows the path in force
|
||||||
|
rather than a plausible string. `dir` here is the default because the file had no preference yet.
|
||||||
|
- The second line must echo the probe's own input -- directory `/tmp/udpscope-prefs-events`, keep
|
||||||
|
`7`, relative time `1`, theme `dark`. Any field that differs means the form, the write
|
||||||
|
or the parse dropped it.
|
||||||
|
- `live=1` is the theme having reached the running style manager rather than only the file.
|
||||||
|
- `browse: toplevels=N extra=1 field='…'` is the folder chooser: opened from its button, then cancelled
|
||||||
|
from *outside* it. `extra=1` is the new toplevel that is the dialog -- a count, because the dialog is
|
||||||
|
not named after anything a probe could match on -- and the field must still read the directory it read
|
||||||
|
before it opened, because **a cancelled picker changes nothing**. This is the async-dialog crash
|
||||||
|
surface (a callback landing after its dialog went away), which is why the preferences window is closed
|
||||||
|
a beat later than the cancel and why the callback tolerates every widget being null.
|
||||||
|
- `icon_ok=1` is the icon theme knowing `folder-open-symbolic`, the same question the toolbar-icons
|
||||||
|
line asks of the bundled six. It is a reading rather than a convention because the button is an icon
|
||||||
|
and *nothing else*: a name the theme does not carry draws a missing-image placeholder, and nothing
|
||||||
|
else in a run would say so.
|
||||||
|
- `default restored: file=''` closes the loop on the same idea from the other side: the probe types the
|
||||||
|
default path back into the field and presses *Apply*, and the file must end up with **no**
|
||||||
|
`event_cache_dir` at all. A path in the file is a user's choice, and typing the default is not one --
|
||||||
|
pinning it there would stop the setting following the cache directory it is named after.
|
||||||
|
|
||||||
|
## `mnemonics=` in the two preference probes
|
||||||
|
|
||||||
|
```
|
||||||
|
[prefs-probe] mnemonics=4/4
|
||||||
|
[sources] form mnemonics=8/8
|
||||||
|
```
|
||||||
|
|
||||||
|
How many of the dialog's rows have `use-underline` set -- the property that makes a `_` in a row's title a
|
||||||
|
mnemonic. It is FALSE on `AdwPreferencesRow` by default, which is why these titles were drawn with a
|
||||||
|
literal underscore; anything below the full count is that bug back. Read by property name rather than
|
||||||
|
through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form's line appears only when
|
||||||
|
`UDPSCOPE_PROBE_NEWHOST` drives the form.
|
||||||
|
|
||||||
|
## `trigger panel:`
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
|
||||||
|
```
|
||||||
|
|
||||||
|
The trigger panel's frame, read from the window: `boxes` is the row boxes the `.ui` holds, `controls_inside`
|
||||||
|
counts the fifteen controls the code built and then appended into those rows -- the last two being the
|
||||||
|
noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
|
||||||
|
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
||||||
|
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
|
||||||
|
`controls_inside` below 15 while every other line still looks right, which is the panel that has a field
|
||||||
|
missing.
|
||||||
|
|
||||||
|
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
|
||||||
|
widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see
|
||||||
|
`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's seven spins in tree order, so
|
||||||
|
the two lines together read what the file gave and what the code put in it.
|
||||||
|
|
||||||
|
## `signals panel:`
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] signals panel: labels=2 list=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The signals panel's frame read from the window: the heading and the hint the `.ui` gives it, and the list
|
||||||
|
the rows go in. `labels=1` is a `.ui` that lost one of the two, `list=0` one that lost the list -- both of
|
||||||
|
which otherwise show as a panel quietly missing a piece. `panels: … signals=WxH row_h=26` is the other half:
|
||||||
|
the list's own geometry and the height a row really takes, which is what the compact-row CSS rule is for.
|
||||||
|
|
||||||
|
## `math panel:`
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] math panel: head=2 buttons=1 list=1 title='Math channels'
|
||||||
|
```
|
||||||
|
|
||||||
|
The math panel's frame read from the window: the head row the `.ui` gives it (the title and the add
|
||||||
|
button), and the list the channel rows go in. `buttons=0` is a `.ui` that lost the `+` -- a panel that
|
||||||
|
cannot add a channel, with nothing else complaining -- and `title=` is the file's own text read back, so a
|
||||||
|
`.ui` that lost the label shows as `title='-'`. `[math-probe]` is the other half of the reading: it drives
|
||||||
|
the add and the editor through the same code a user does.
|
||||||
|
|
||||||
|
## `events pane:`
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] events pane: compare=1 accumulate=1 list=1 revealer=1 nested=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The events pane's frame read from the window: the two group actions, the list, and the revealer the `.ui`
|
||||||
|
builds, plus `nested` — the list being *inside* that revealer, which is the one thing a lost id cannot show
|
||||||
|
(a list parented elsewhere reads 1 everywhere else). `[events]` is the other half, and it drives the pane end
|
||||||
|
to end: recording off, recording on, the trigger, the history button that reveals it, a row click and the
|
||||||
|
replay, then Compare and Accumulate — so the file's buttons are read by being used.
|
||||||
|
|
||||||
|
## `[windowpop]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[windowpop] opened=1 visible=1 click_gestures=2
|
||||||
|
[windowpop] after a press elsewhere: active=0 (want 0)
|
||||||
|
[windowpop] after a press on the button: active=1 (want 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover and how it closes. Autohide is turned *off* for the
|
||||||
|
probe, exactly as the menu probe does it -- a popover dismisses itself when GTK holds its *grab*, and a bare
|
||||||
|
`xvfb-run` has no window manager to hand one out -- so what is measured is the state a user meets when the
|
||||||
|
grab goes missing, where the press has to close the bubble anyway. `click_gestures` reads that the controller
|
||||||
|
is *connected* as well as that its handler works; the last press is on the button itself, which must be left
|
||||||
|
to its own toggle (`want 1` rather than `0`).
|
||||||
|
|
||||||
|
## `[drop-probe]` and what a refused drop says
|
||||||
|
|
||||||
|
```
|
||||||
|
[drop-probe] full row refused the drop: 0 (want 0)
|
||||||
|
[drop-probe] refused feedback: notice='plot 1 holds four signals already: drop it on another plot, or add a row with the layout editor (L)' warning=1
|
||||||
|
[drop-probe] the status line, composed: warning=1 status='plot 1 holds four signals already: ... | live · 9.97 kS/s · 2 s window'
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_DROP_MS`. The first line is the refusal itself (a full row takes no more signals); the
|
||||||
|
second is that it *said so* -- the message and the stock `.warning` class the status line carries while a
|
||||||
|
notice is fresh; the third is the composed line the user reads, which is why the label is only checked here
|
||||||
|
and not in the second: the status line folds the notice into itself on its own cadence, so a beat after the
|
||||||
|
refusal it may still hold the previous text.
|
||||||
|
|
||||||
|
## `[enable-all-probe]` and switching a signal off
|
||||||
|
|
||||||
|
```
|
||||||
|
[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the 200 MB buffer'
|
||||||
|
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
|
||||||
|
```
|
||||||
|
|
||||||
|
The first line's `notice` is the **startup** notice, and it is read here because this probe is the
|
||||||
|
earliest thing that reads the status bar. It is set as the window is presented -- before the first
|
||||||
|
CONFIG can take the model's mutex, so before anything can block -- and it names the host and the
|
||||||
|
buffer being planned, because that is the window's only answer to "why is nothing happening yet".
|
||||||
|
An empty `notice` here is the window that used to come up silent while a large buffer was allocated:
|
||||||
|
at `--buffer-mb 4000` that is the dialog disappearing and then over a second of a frozen, blank
|
||||||
|
window. (The buffer is allocated on the producer thread under `ScopeModel::mutex()`, which the frame
|
||||||
|
tick also takes, so the freeze is real and cannot be papered over in the UI -- see the
|
||||||
|
`[udpscope] buffer allocated:` line below.)
|
||||||
|
|
||||||
|
`UDPSCOPE_ENABLE_ALL_MS` enables every signal and assigns the acquired channels across the rows, then
|
||||||
|
switches one off **through its own acquire tick in the signal list** -- the tick is a box inside the row, which
|
||||||
|
is the shape `signal_tick_visible()` walks -- and reads the plots back. `traces_left=0` is the rule: a disabled
|
||||||
|
signal leaves every plot rather than staying as a slot that draws nothing, and the notice is what says so.
|
||||||
|
|
||||||
|
The switch step runs late, so that a math probe armed *behind* this one has installed its graphs by then; the
|
||||||
|
two halves after it are the other rule. `math_off` is the derived channels that read this signal -- they cannot
|
||||||
|
be computed from a signal nobody acquires, so they go off with it -- and `math_on` is the ones that do not,
|
||||||
|
which must be untouched:
|
||||||
|
|
||||||
|
```
|
||||||
|
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'
|
||||||
|
```
|
||||||
|
|
||||||
|
## `[udpscope] buffer allocated:`
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
Not a probe — it is printed by `ScopeModel::rebuild_locked()` in every build, because this is the one
|
||||||
|
operation in the application whose duration a user can see with no instrument: the store's rings are
|
||||||
|
`std::vector<std::atomic<T>>` and so **value-initialised**, which touches the whole `--buffer-mb`
|
||||||
|
(roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding
|
||||||
|
`ScopeModel::mutex()` — which the frame tick also takes. So the line's `ms` is a frozen window, not
|
||||||
|
bookkeeping.
|
||||||
|
|
||||||
|
**It is the count that is the assertion, not the duration.** One line per *real* change of plan. A
|
||||||
|
rebuild whose plan describes the storage already allocated clears the store instead
|
||||||
|
(`Partition::same_storage_as()` → `BufferStore::reset()`) and prints nothing, so a startup or a mode
|
||||||
|
change that reads more than one or two of these is a re-plan that should have been coalesced. Startup
|
||||||
|
asked for **fifteen**; it now asks for two or three. A run at `--buffer-mb 4000` measured those
|
||||||
|
coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the
|
||||||
|
evidence that the freeze *is* this call and not something downstream of it.
|
||||||
|
|
||||||
|
The companion reading is the startup `notice` on the `[enable-all-probe]` line above: the allocation
|
||||||
|
cannot be made free, so the window must at least say what it is doing while it happens.
|
||||||
|
|
||||||
|
## `[math-probe] graph edit:`
|
||||||
|
|
||||||
|
```
|
||||||
|
[math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_MATH_MS` applies an edit to one graph through the editor. The plan is a partition over the
|
||||||
|
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
|
||||||
|
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
|
||||||
|
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
|
||||||
|
|
||||||
|
## `[noise-probe]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[noise-probe] setup channels=1
|
||||||
|
[noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00
|
||||||
|
[noise-probe] trigger hf: hf=7/2 band+hf=6/2 band=7
|
||||||
|
[noise-probe] trigger delay: clean=3 filtered=4 delay=0.002206 want=0.002206 ratio=1.000
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_NOISE_MS`, and it needs the mock's **spiked** sine -- `--spike-every 0.005 --spike-amp 0.5
|
||||||
|
--spike-width 2` -- where every other probe uses the plain mock. It runs as **four stages**, and the chain is the point:
|
||||||
|
|
||||||
|
1. At `UDPSCOPE_PROBE_NOISE_MS` the setup installs the de-noise graph through `apply_math_channels()`.
|
||||||
|
That is a structural rebuild that *drops the buffer's history*, so the five seconds to the next stage are
|
||||||
|
what let the ring refill before anything scans it.
|
||||||
|
2. Five seconds later the legs run. They enable the signals they measure *themselves*, because a probe must
|
||||||
|
not depend on another probe's side effects (`enable_all_probe` ends by switching one off again, which is
|
||||||
|
how `sine` went missing from the first run of this one), and wait 2.5 s for the ring to refill after that
|
||||||
|
structural change. The legs then each search the whole retained buffer through `BufferSearch`, one after
|
||||||
|
another **without yielding**, so all six see the same buffer -- and the five measurements come next, over
|
||||||
|
the same retained history.
|
||||||
|
3. Two and a half seconds later the de-noise channel is **enabled**, by name, through the same switch the
|
||||||
|
panel drives. The setup installs it *disabled* and this stage turns it on, because an acquired channel is
|
||||||
|
a share of the ring: a fifth one shortens *every* channel's retained span, and every figure above scales
|
||||||
|
with that span -- the six leg counts, and `pkpk bare`, which is a peak-to-peak *of the retained window*.
|
||||||
|
The readings above are the older feature's proof, so the new channel stays out of the acquisition plan
|
||||||
|
until they have all run.
|
||||||
|
4. Two and a half seconds after that, the de-noise reading. A math channel starts producing when it is
|
||||||
|
applied *and* acquired, so the channel's first sample is the first one after stage 3, and a peak-to-peak
|
||||||
|
needs a window holding a trough *and* a crest: twelve cycles of the 5 Hz source.
|
||||||
|
|
||||||
|
With `UDPSCOPE_PROBE_NOISE_MS=4000` the legs therefore land at about 11.5 s and the last line at 16.5 s,
|
||||||
|
which is why the documented run needs `UDPSCOPE_EXIT_AFTER_MS=18000`. Four seconds and not three: the
|
||||||
|
enable-all probe's own last act, a click on the front channel's tick at `ENABLE_ALL_MS + 2000`, rebuilds
|
||||||
|
nothing itself but *is* rebuilt under when a graph is installed at its instant -- see `verification.md`.
|
||||||
|
|
||||||
|
- **`setup channels=1`** -- one math channel was installed, and it is printed by the *first* stage, before
|
||||||
|
any leg: it is the line that says the graph exists at all. A count above 1 means a session file from an
|
||||||
|
earlier run was loaded on top of it (`$XDG_CONFIG_HOME/udpscope`); those channels load disabled, so they
|
||||||
|
do not change the partition, and a clean run of the documented command prints `1`.
|
||||||
|
- **`trigger band:`** -- `bare` is the bare comparator on the spiked sine (every impulse that lands near zero
|
||||||
|
carries it across, so the count is dominated by spikes), `band` the same with a 0.8 band. **`ratio` must be
|
||||||
|
at or above 5**, and `band` about the number of sine cycles retained. Neither count is a *fixed* number:
|
||||||
|
both scale with the retained buffer's length (7 at a 2.5 s buffer, ~30 at 6 s), so the ratio and the
|
||||||
|
agreement between the legs are the assertions. A `ratio` near 1 means the band is not being applied --
|
||||||
|
`decide()` is not reached from `feed_edge`.
|
||||||
|
- **`trigger hf:`** -- `hf` (no band, 50 Hz reject) and `band+hf` (both) must be **within 2 of `band`**: the
|
||||||
|
reject removes the impulses on its own. `hf` near `bare` means the pole never left `a == 1` (check
|
||||||
|
`configure()` recomputes it, and that `hf_fc` reached the condition); `band+hf` near 0 means the reject is
|
||||||
|
attenuating the sine itself, which would be the Nyquist clamp clamping 50 Hz.
|
||||||
|
- **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a
|
||||||
|
one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read
|
||||||
|
`want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one
|
||||||
|
means `a` is far too small, i.e. `dt` never arrived at the detector.
|
||||||
|
- `state` is `2` (Done) or `4` (Capped) on every leg. `3` (Truncated) on more than one leg means the pump loop
|
||||||
|
is being overtaken -- nothing to fix in the feature, but say so.
|
||||||
|
|
||||||
|
The same probe then puts five measurements on the same spiked `sine` as **two windows** -- three legs at
|
||||||
|
`hf=0` and two at `hf=50` -- and reads them back:
|
||||||
|
|
||||||
|
```
|
||||||
|
[noise-probe] measure pkpk bare noise=10.0 hf=0.0 valid=1 value=2.987692
|
||||||
|
[noise-probe] measure pkpk hf noise=10.0 hf=50.0 valid=1 value=1.990075
|
||||||
|
[noise-probe] measure period noband noise=0.0 hf=0.0 valid=1 value=0.021290
|
||||||
|
[noise-probe] measure period band noise=25.0 hf=0.0 valid=1 value=0.200000
|
||||||
|
[noise-probe] measure period hf noise=0.0 hf=50.0 valid=1 value=0.200000
|
||||||
|
[noise-probe] measure windows=2
|
||||||
|
[noise-probe] denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`pkpk bare`** -- about **3.0**: the sine's 2 plus an impulse either way. The impulses dominate the
|
||||||
|
window's extremes, so this is the *stimulus*.
|
||||||
|
- **`pkpk hf`** -- about **1.99** (`2/sqrt(1 + (5/50)^2)`): the *waveform*. This is the reading that says the
|
||||||
|
filter reached **pass one** and not only pass two; a value near 3 means it did not -- check `measure_scan`
|
||||||
|
is handed `w.scan_filter`, and that `filtered` disabled the buckets, since a bucket's aggregate is raw.
|
||||||
|
- **`period noband`** -- about **0.021**, far below the true 0.2. **This one is supposed to read wrong**: it is
|
||||||
|
the control the other two are compared against, the defect the band exists to fix. "Fixing" it removes the
|
||||||
|
check.
|
||||||
|
- **`period band`** -- exactly **0.200000**, the true period, from the band alone. Near `period noband` means
|
||||||
|
`noise_pct` is not reaching `measure_set_level`. The leg asks for **25 %** and not the 10 % default: the band
|
||||||
|
is a share of the *window's* peak-to-peak, which with the impulses in it is about 3, so 10 % is a half-band
|
||||||
|
of 0.3 -- less than the 0.5 an impulse moves the signal by, which is exactly what
|
||||||
|
`MeasureOptions::noise_pct`'s own rationale says (10 % holds to a 0.3 interferer).
|
||||||
|
- **`period hf`** -- exactly **0.200000** with the band off: the reject alone. Near `period noband` means the
|
||||||
|
pole coefficient stayed at 1 -- check `measure_filter_set` was given a non-zero `dt`.
|
||||||
|
- **`denoise`** -- the feature under test. `raw` is the spiked sine's peak-to-peak over the visible window and
|
||||||
|
`denoised` the same reading from the **`math denoise`** channel -- a `Source` of `sine` into `DeNoise` with
|
||||||
|
`med_n=5` and `fc=0`, i.e. the median alone. **`denoised` must read exactly `2.000000`**: that is the clean
|
||||||
|
sine's own peak-to-peak, so *not one impulse survived*, which is the whole claim the node makes (the
|
||||||
|
impulses are 2 samples wide and the median is 5, shorter than half a window). `denoised` near `raw` means the
|
||||||
|
median is passing them: check `med_n` arrived as 5 and not 0, since a `med_n <= 0` node returns NaN and a
|
||||||
|
peak-to-peak over holes would read `--` instead. **`denoised=0.000000` with `valid=0` means the channel never
|
||||||
|
produced** -- it was never enabled, its source `sine` was never re-enabled, or `apply_math_channels()` was
|
||||||
|
never called. `ratio` is `denoised / raw` and follows `raw`, which is whatever the visible window's extremes
|
||||||
|
happen to hold: 0.669 here, and up to about 0.8 in a run whose window misses the spike at one extreme. The
|
||||||
|
assertion is on `denoised` and on `raw` being above it; the ratio is the reading, not the test.
|
||||||
|
`channels=` is the math channel *count* (`a->math.size()`), not whether the channel is acquired.
|
||||||
|
- **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two
|
||||||
|
distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk
|
||||||
|
and two of them read a signal they did not ask for.
|
||||||
+439
-1
@@ -26,6 +26,12 @@ all. A fifth warning, or any critical, is a finding.
|
|||||||
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
|
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
|
||||||
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
|
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
|
||||||
|
|
||||||
|
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
|
||||||
|
printed.** The line is not behind a probe — it is in every build — and one line is one *real* change
|
||||||
|
of buffer plan, each of which blocks the main thread for the time it takes to touch the whole
|
||||||
|
`--buffer-mb`. A normal run prints two or three. Fifteen at startup is the defect it was added for;
|
||||||
|
see [probe-readings.md](probe-readings.md).
|
||||||
|
|
||||||
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
|
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
|
||||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||||
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
||||||
@@ -44,6 +50,13 @@ precedent, not an exception.
|
|||||||
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
|
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
|
||||||
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
|
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
|
||||||
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
|
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
|
||||||
|
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
|
||||||
|
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
|
||||||
|
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
|
||||||
|
enables the sine — which sorts before the square and so moves the trigger channel's index under
|
||||||
|
the engine. Its three lines are in [probe-readings.md](probe-readings.md); **`t0_held=1` is the
|
||||||
|
assertion**. Run it with `--holdoff 10`, which makes the stolen-t0 reading unmistakable: a second
|
||||||
|
latch barely a second after the first cannot be a hold-off that applied.
|
||||||
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
|
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
|
||||||
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
|
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
|
||||||
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
|
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
|
||||||
@@ -141,7 +154,7 @@ precedent, not an exception.
|
|||||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||||
measured on a still picture. It now also asserts what a pause *is*
|
measured on a still picture. It now also asserts what a pause *is*
|
||||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||||
glance=[..]` on stderr):
|
skips=N glance=[..]` on stderr):
|
||||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||||
@@ -161,6 +174,17 @@ precedent, not an exception.
|
|||||||
stops while paused and "how long have I been looking at this" is a question about the user, not
|
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||||
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||||
instead of growing digits (see the layout rule).
|
instead of growing digits (see the layout rule).
|
||||||
|
**`skips=` is the assertion for the render gate** (the end of `tick()`), cumulative over the
|
||||||
|
session: the `live` line must read **0** and the `paused` line must read **more than 0**. A
|
||||||
|
frozen view is bound to an immutable snapshot on a pinned axis, so a frame whose picture cannot
|
||||||
|
have changed is not drawn again — and this is the only line that can say whether that happened,
|
||||||
|
since a skipped frame is invisible in every other reading by construction. Non-zero on the
|
||||||
|
`live` line is the gate's condition inverted, which would cost dropped samples' worth of
|
||||||
|
picture; 0 on the `paused` line is the gate not engaging and the saving not being made.
|
||||||
|
The gate cannot stall the picture (`kStaticRenderEvery` renders every few ticks regardless),
|
||||||
|
so the renderer's own baselines below are unchanged by it: a measured run read `skips=0`,
|
||||||
|
then 33, then 45, with `columns == graded`, `render blank frames: 0`, and the layout floor
|
||||||
|
still converged (`y-gutter 104..104`).
|
||||||
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
||||||
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
||||||
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
||||||
@@ -170,6 +194,369 @@ precedent, not an exception.
|
|||||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||||
only the file chooser.
|
only the file chooser.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_CONDITION_MS` — arms a qualified trigger against the mock and reports the latched
|
||||||
|
`t0`, the measured pulse or band width, and sample values for the window condition. Schedules its
|
||||||
|
own arm and report stages. `UDPSCOPE_PROBE_CONDITION_SHAPE` picks the leg: `0` pulse width `<`
|
||||||
|
(the glitch, the default, needs `--glitch-at`/`--glitch-width`), `1` pulse width `>` (uses a
|
||||||
|
1.0 s window and 50 % pre so the opening edge of a 0.2 s pulse is captured), `2` window
|
||||||
|
(uses `sine` with a 0.2 s window and 10 % pre), `3` dropout (needs `--quiet-at`; uses `sine`
|
||||||
|
with a 1.0 s window and 50 % pre so the last crossing before the outage is captured).
|
||||||
|
- `UDPSCOPE_PROBE_STATS_MS` — reports every measurement's accumulator (`n`, mean, sd, lo, hi),
|
||||||
|
pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three
|
||||||
|
phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs
|
||||||
|
`UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first.
|
||||||
|
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
|
||||||
|
trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to
|
||||||
|
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
|
||||||
|
|
||||||
|
The following mock waveform features are what the qualified-condition probes are checked against,
|
||||||
|
as distinct from the fault injection below, which exercises ingest:
|
||||||
|
|
||||||
|
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the start of
|
||||||
|
every square period from stream second `S` onwards — a runt pulse of a known width, which is
|
||||||
|
what a `<` pulse-width trigger and a glitch search are checked against.
|
||||||
|
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it stops
|
||||||
|
crossing zero: the dropout trigger's input.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_RESEQ_MS` — one mid-run reading of the resequencing path. Five cases,
|
||||||
|
**run each as its own process** — five back to back starve the mock past its 2 s silence
|
||||||
|
timeout and every case then reads a reconnect instead of what it was testing. Common
|
||||||
|
conditions for the first four: the mock at `--rate 10000 --batch 100 --freq 5`, udpscope with
|
||||||
|
no extra options, under `xvfb-run` with
|
||||||
|
`UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 UDPSCOPE_EXIT_DUMP=1
|
||||||
|
UDPSCOPE_PROBE_RESEQ_MS=5000`. Delete `~/.config/udpscope/127.0.0.1_8000.toml` first: a
|
||||||
|
session left by an earlier run restores its math channel, and five channels instead of four
|
||||||
|
change `channels=`, `span=` and every count derived from them.
|
||||||
|
|
||||||
|
| case | mock extra flags | key reading |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **base** | *(none)* | every counter 0; proves the machinery costs nothing |
|
||||||
|
| **reorder** | `--reorder-every 10` | `reseq=51` (mid-run) / `102` (dump); `holes=0`; resequencing inside the window leaves no reservation |
|
||||||
|
| **late** | `--reorder-every 10 --reorder-delay-ms 50` | `bands_repaired` one short of `bands` with `nan_slots=100`; the band still solid is the one hole whose late packet is in flight, and it is the *only* unrepaired one |
|
||||||
|
| **mixed** | `--drop-every 20 --reorder-every 10 --reorder-delay-ms 50` | `bands=43 bands_repaired=20 nan_slots=2200`; all three band states at once |
|
||||||
|
| **re-plan** | `--rate 100000` with the `mixed` flags, and `UDPSCOPE_PROBE_WINDOW_MS=4000 UDPSCOPE_PROBE_RESEQ_MS=6000 UDPSCOPE_EXIT_AFTER_MS=9000` | four `buffer rebuilt` lines before the reading, then `bands=64 bands_repaired=16 nan_slots=7800`: **a band is still only marked repaired when its own slots hold values**. A `bands_repaired == bands` beside a non-zero `nan_slots` here is the hole table and the producer's `tick_` surviving a re-plan that reset `produced()` — a band marked settled over samples that are all still NaN. The non-zero `overflows` is this case too: 100 kS/s with a 50 ms reorder delay keeps about 50 packets in flight against 64 slots |
|
||||||
|
|
||||||
|
The re-plan case needs `--rate 100000`, not the other four's 10 000: at 10 kS/s the ring
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_CAPTURE_MS` — toggles the packet capture **through the same handler the
|
||||||
|
status-bar button uses**, waits, then reads the file back off disk and prints one line:
|
||||||
|
`enabled= path= slots= stored= resident= cursor= torn= truncated= header_ok= first_seq=
|
||||||
|
last_seq= payload= file= open_ms= t0=`. `stored` is the datagrams-since-open counter and must
|
||||||
|
equal the dump's datagram count from the toggle's instant; `resident` is what the file holds,
|
||||||
|
which is the slot count once the ring has wrapped; `file` is the reserved size
|
||||||
|
`64 + slot_count * slot_bytes` and is a constant for a given N, so a mismatch against the file
|
||||||
|
on disk is a reading. Two cases, **run each as its own process**:
|
||||||
|
|
||||||
|
1. **enable at runtime** — the mock at `--rate 20000 --batch 200`, udpscope with
|
||||||
|
`UDPSCOPE_ENABLE_ALL_MS=700 UDPSCOPE_PROBE_CAPTURE_MS=1500 UDPSCOPE_EXIT_AFTER_MS=9000`
|
||||||
|
and no capture flag: `enabled=1 header_ok=1 torn=0 truncated=0 first_seq=1`, `stored` equal
|
||||||
|
to the datagrams since the toggle, `open_ms` a few ms.
|
||||||
|
2. **wrap** — the same with `--capture-packets=16` and
|
||||||
|
`UDPSCOPE_PROBE_CAPTURE_MS=7000 UDPSCOPE_EXIT_AFTER_MS=10000`: `stored` (870 in the
|
||||||
|
reference run) exceeds `slot_count`, `resident=16`, `last_seq - first_seq == 15`,
|
||||||
|
`torn=0`, and `cursor` agrees with `seq` (counter mod slot count). A wrap that loses
|
||||||
|
ordering, or a `cursor` that disagrees, shows here and nowhere else.
|
||||||
|
|
||||||
|
Then the converter over the wrap case's file: `tools/capture_to_pcap.py <capture> out.pcap`
|
||||||
|
must exit 0, print a record count equal to `slot_count` with `torn` 0, and the pcap's first
|
||||||
|
and last record timestamps must fall inside the run's wall-clock interval — both checked by a
|
||||||
|
`struct.unpack` of the pcap headers, nothing outside the standard library.
|
||||||
|
|
||||||
|
- **A gap must not kill a filtered trace.** `--relative-time` with the mock dropping
|
||||||
|
(`--drop-every 20`) and `UDPSCOPE_PROBE_MATH_MS=2500 UDPSCOPE_ENABLE_ALL_MS=600
|
||||||
|
UDPSCOPE_EXIT_AFTER_MS=9000`: every derived channel's dump line
|
||||||
|
`math '<name>': finite=N/4000` must read about 3800 — the tail minus the reserved holes —
|
||||||
|
and **`finite=0` beside a non-zero `hole_slots` is the defect** (a biquad that integrated the
|
||||||
|
hole's NaN, permanently). The filters' `sd` must stay near the no-drop value; run the same
|
||||||
|
command without `--drop-every` as the control, where every `finite` reads 4000.
|
||||||
|
- `UDPSCOPE_PROBE_CONFIGS_MS` — saves the running session as a configuration named `probe` with a
|
||||||
|
note, through `save_configuration_as()` (the same entry point the dialog's button uses), then
|
||||||
|
reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the
|
||||||
|
round trip must carry the note **and** the startup settings —
|
||||||
|
`comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there
|
||||||
|
is the per-source buffer size not being written, which is the one setting a user notices only
|
||||||
|
after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe
|
||||||
|
deletes its own configuration at the end (`after forget names=…`).
|
||||||
|
- `UDPSCOPE_PROBE_ROWMENU_MS` — gives row 0 a trace, opens the plot's context menu with
|
||||||
|
`open_row_menu()` (the one entry point `on_right_click()` wraps) and walks its two views one step
|
||||||
|
at a time (`main` → click **Remove signal** → `remove` → click **Back** → `after-back` → click
|
||||||
|
**Remove signal** again → `second-open` → click a trace), measuring each view's rows:
|
||||||
|
`[rowmenu] <view> N '<label>' h=18 label_x=26 accel_right=R/W ok=1` per row and one
|
||||||
|
`rows=/bad=/labels=` line per view, then `[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6
|
||||||
|
remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1
|
||||||
|
remove_row_gone=1 can_open_again=1` and `[rowmenu] remove-clicked 'sine' trace_freed=1`.
|
||||||
|
**Every `bad` must be 0** (the same compact row style as the app's own menu: one label column, a
|
||||||
|
shortcut flush right, 18 px rows — the **Back** row's icon indents its label, which every menu
|
||||||
|
does, so its `label_x` is 52 against the others' 26) and the three booleans must all read 1: the
|
||||||
|
remove view *replaces* the main one (`remove_row_gone`, `remove_row_in_main`), and it can be
|
||||||
|
entered again after leaving it (`can_open_again`) — that last one is the case a
|
||||||
|
`GtkMenuButton`-owned submenu failed, its row going dead on the second click. Each view is
|
||||||
|
measured a step after the click that swapped it in, because a popover allocates its new child on
|
||||||
|
the next layout pass.
|
||||||
|
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
||||||
|
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
|
||||||
|
`GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
|
||||||
|
dialog's own reader would then apply — which is exactly what the calibration offset did. It then
|
||||||
|
calibrates signal 0 through `set_calibration()` and reopens the dialog:
|
||||||
|
`[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored
|
||||||
|
calibration**, and the two halves must match.
|
||||||
|
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
|
||||||
|
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
|
||||||
|
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
|
||||||
|
`[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
|
||||||
|
configuration carried, into the `Config` the model is built from. A legacy file
|
||||||
|
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
|
||||||
|
holding `default`, and the old file renamed `.imported`.
|
||||||
|
- **The loading window covers the gap, and only on the path that asked.** The same
|
||||||
|
`UDPSCOPE_PROBE_PICK_MS` run must print
|
||||||
|
`[sources] loading: title='Connecting to 127.0.0.1:<port>' detail='planning the N MB sample buffer'`
|
||||||
|
**between** the `picked` line and the stream's `CONFIG:`, and `[sources] loading: closed` **after**
|
||||||
|
it — the window is raised before the buffer is planned and taken down only once the real window has
|
||||||
|
been presented, which is the whole of what it exists for. `UDPSCOPE_PROBE_WAVE=<path>` is the file
|
||||||
|
leg of it (`title='Opening <basename>' detail='reading the recording'`, before `waveform loaded:`).
|
||||||
|
A run that names its source with `--server` prints **no** `loading:` line at all: every other probe
|
||||||
|
here is on that path and must stay on it.
|
||||||
|
- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` reads
|
||||||
|
the pane's availability first, in the state the app starts in -- `[events] live: button_visible=0
|
||||||
|
pane=0 recording=1`, **no pane and no button without trigger mode** -- then turns trigger mode on
|
||||||
|
through the real toggle (`[events] after trigger on: button_visible=1 pane=0`: the button appears,
|
||||||
|
the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays
|
||||||
|
the newest event through the pane's own row:
|
||||||
|
`[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001
|
||||||
|
rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1`, then
|
||||||
|
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
|
||||||
|
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
|
||||||
|
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
|
||||||
|
the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and
|
||||||
|
`[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written
|
||||||
|
some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the
|
||||||
|
cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read
|
||||||
|
`button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the
|
||||||
|
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
|
||||||
|
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
|
||||||
|
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
|
||||||
|
door. It then **waits for a second latch** (bounded: twelve retries) and drives the two group layouts
|
||||||
|
through the buttons: `[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1` (the
|
||||||
|
buttons read the selection), `[events] halve`… `[events] halves a_first=20001 a_second=0 b_first=0
|
||||||
|
b_second=20001` -- **the two halves of the ring, and the reading that says a comparison's gap is a
|
||||||
|
gap: the first event's channel is finite only in the first half and the second's only in the second**
|
||||||
|
-- `[events] compare: kind=1 rows=4 channels=8 samples0=40002 traces0=2 traces1=2 window=4.000
|
||||||
|
recording=1 pane=1` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left
|
||||||
|
running pushes its own four signals into the group's eight channels), `[events] compare plots:
|
||||||
|
area_b=1 rows_a=1 rows_b=1 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1` (the second plot exists, is
|
||||||
|
on screen, mirrors the row count, and both are on one x window), then the sync -- the probe sets a y
|
||||||
|
range nobody would arrive at by accident on the *first* plot and reads the second a few frames later:
|
||||||
|
`[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1` -- and `[events] accumulate: kind=2 rows=1
|
||||||
|
channels=8 samples0=33045 selected=2 traces0=8 window=2.000` (`traces0=8`: the four signals the layout
|
||||||
|
holds plus one layer each for the second event, **in the same row**, which is what an accumulation
|
||||||
|
means). Run it with a throwaway
|
||||||
|
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s).
|
||||||
|
- **A waveform round-trips.** Export from a live run (`UDPSCOPE_EXPORT_MS` with
|
||||||
|
`UDPSCOPE_EXPORT_PATH`, against the mock at 10 kS/s), then open that file with `--open` and an
|
||||||
|
exit dump. The load must print
|
||||||
|
`[udpscope] waveform loaded: … signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]`
|
||||||
|
— **the same row count, column count, span and time range the export probe reported**
|
||||||
|
(`rows=20001 cols=4 time=[-2.000000..0.000000]`) — and the dump must show `channels=4`,
|
||||||
|
`frames=0 packets=0` (no acquisition) and, per channel,
|
||||||
|
`20001 samples vs 20001 time slots … y=[-1.0000, 1.0000]`. A file that loads with fewer samples
|
||||||
|
than it was written with, or with `NaN` slots, is the reader mis-slicing its records.
|
||||||
|
- **A recording that does not fit its buffer is *filtered* into it, not truncated.** The case needs a
|
||||||
|
file bigger than the budget, which the mock cannot export in one window, so write one with NumPy —
|
||||||
|
the dtype is the exporter's own, `[('time','<f8'), ('abs_seconds','<f8'), (<signal>,'<f4'), …]` — and
|
||||||
|
open it against a small buffer:
|
||||||
|
```bash
|
||||||
|
python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \
|
||||||
|
a=np.zeros(n, dtype=[('time','<f8'),('abs_seconds','<f8'),('a','<f4'),('b','<f4'),('c','<f4'),('d','<f4')]); \
|
||||||
|
a['time']=t; a['abs_seconds']=1000.0+t; a['d']=np.linspace(-1,1,n); np.save('/tmp/long.npy', a)"
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --open /tmp/long.npy --buffer-mb 1
|
||||||
|
```
|
||||||
|
must read `samples=31250 rows=1000000 decim=32 span=19.999980s time=[-4.000000..15.999980]` and
|
||||||
|
`stored=31250 … published=31250` per channel — **the whole span, at a thirty-second of the rate**.
|
||||||
|
The ramp column is the assertion that the *head* is there: `y=[-0.999969, 0.999969]` is the file end
|
||||||
|
to end, where the defect this exists for reads a fraction of it. With every row pushed regardless of
|
||||||
|
the plan, this file arrived as its **last 0.9 s** and nothing in the log said so.
|
||||||
|
- **A loaded file can be plotted.** `UDPSCOPE_PROBE_DROP_MS` drives the drop handler the way a drag
|
||||||
|
from the signal panel does, on a file opened with `--open`: it must read
|
||||||
|
`[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||] view=[-3.999690..15.999670] repins=2` —
|
||||||
|
**`rows` equal to the column count**,
|
||||||
|
one trace per row — and each drop must return `1` and add its trace (`dropped signal 1 -> 1
|
||||||
|
slots=[sine|sine|square|]`). `rows=1 slots0=[sine|square|ramp|noise]` with every drop returning `0`
|
||||||
|
is the defect this exists for: rows sized four signals to one row fill the only row, so the app
|
||||||
|
reads as one that cannot plot anything. A file-mode run must also write **no session** for the
|
||||||
|
default `127.0.0.1:8000` (`App::source_port` is 0), or it overwrites that source's saved layout.
|
||||||
|
- **A file view states its pin once.** `repins` on the line above must read **2** whenever the probe
|
||||||
|
is scheduled — at one second or at seven — and `view` must be the file's whole span. A file's extent
|
||||||
|
does not change while it is open, so the tick refreshes the pin's *numbers* and writes the group's
|
||||||
|
range only when the view first becomes an event. Reading it against the decimated file above
|
||||||
|
(`/tmp/long.npy`, stamps `[-4..16]`) is the case that matters: with the pin written as a pre/post
|
||||||
|
split where a range belonged it read `view=[3.999690..15.999670] repins=116` at three seconds —
|
||||||
|
the axis alternating between the file's span and a corrupted pin every frame, which on screen is a
|
||||||
|
flicker in which no pan, zoom or click survives to the next frame. A file whose stamps are all
|
||||||
|
positive, or which ends at 0 (an export's own window), hides it: the corrupted pin is then wider
|
||||||
|
than the data, or empty, and the clamp never fires. `UDPSCOPE_PROBE_PAN_MS` on the same run is the
|
||||||
|
other half — `allowed=1` and a `view` that is still the zoomed range when the drop probe reads it
|
||||||
|
later is interaction actually taking.
|
||||||
|
- **The chooser's Waveforms page**: `UDPSCOPE_PROBE_WAVEFORMS=1` prints
|
||||||
|
`[sources] waveforms: N recent <path>(ok|missing) … page=waveforms`, and `UDPSCOPE_PROBE_WAVE=<path>` sets
|
||||||
|
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
|
||||||
|
load line above, with no socket involved.
|
||||||
|
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
|
||||||
|
holding `127.0.0.1_8101/bench.toml` (`buffer_mb = 64`, `relative_time = true`,
|
||||||
|
`capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101`
|
||||||
|
must print `[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500`, the
|
||||||
|
`relative time:` line, and a `span=` of about 209 s (64 MB at 10 kS/s) — **not** the 200 MB
|
||||||
|
default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`:
|
||||||
|
**an explicit flag wins over the file**. Without this, the settings a host was edited to have were
|
||||||
|
ignored whenever the address came from the command line.
|
||||||
|
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
|
||||||
|
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
|
||||||
|
`[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then
|
||||||
|
`[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a
|
||||||
|
`picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it
|
||||||
|
(`buffer=32 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
|
||||||
|
list cannot be edited next time. `UDPSCOPE_PROBE_EDITHOST=host:port` opens the same form on that
|
||||||
|
configuration and prints `[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'`
|
||||||
|
— **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then
|
||||||
|
saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one
|
||||||
|
`known:` row afterwards, with the old file gone.
|
||||||
|
- `UDPSCOPE_PROBE_RESEQ_MS`'s neighbour for **reconnect**: the mock gains an EMI case,
|
||||||
|
`--blackout-at S --blackout D` — silent from second S for D seconds (D must exceed the
|
||||||
|
client's 2 s silence timeout), resuming from *now* so the link's backlog is what the link
|
||||||
|
ate. One run (`--blackout-at 4 --blackout 3.5`, `UDPSCOPE_EXIT_AFTER_MS=15000`) has to read:
|
||||||
|
every reconnect's `CONFIG:` line followed by `CONFIG unchanged: keeping the buffer and its
|
||||||
|
history` (a reconnect that re-plans wipes history, which is the defect this case exists for),
|
||||||
|
no `buffer rebuilt` line after the first CONFIG, `frames` climbing again after the blackout
|
||||||
|
with `gaps=0`, and a trigger armed *after* recovery (`UDPSCOPE_PROBE_KEYS_MS` past the
|
||||||
|
blackout) latching — the dump's `CAPTURING`/`EVENT HELD` and `publish cost: N events` with
|
||||||
|
N ≥ 1 are that proof.
|
||||||
|
already covers 838 s, so no window the control can reach moves `time_stride`,
|
||||||
|
`set_target_span()` returns without rebuilding, and the window probe reports `decim=1`
|
||||||
|
throughout — it re-plans nothing. At 100 kS/s the ring covers 83.9 s and the probe's
|
||||||
|
500 ms → 500 s → 120 s → 600 s → 60 s walk crosses the boundary four times.
|
||||||
|
|
||||||
|
Full measured readings (probe line first, then exit-dump):
|
||||||
|
|
||||||
|
```
|
||||||
|
=== base mock: none
|
||||||
|
[reseq-probe] reseq=0 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 open_holes=0
|
||||||
|
bands=0 bands_repaired=0 nan_slots=0 scanned=44200 trigger_in_hole=0
|
||||||
|
[udpscope] frames=1032 packets=1033 gaps=0 channels=4 span=838.861s stride=1 reseq=0 late=0
|
||||||
|
stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||||
|
[udpscope] channel 0 'sine' stored=95700 published=95400
|
||||||
|
|
||||||
|
=== reorder mock: --reorder-every 10
|
||||||
|
[reseq-probe] reseq=51 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||||
|
open_holes=0 bands=0 bands_repaired=0 nan_slots=0 scanned=44200
|
||||||
|
trigger_in_hole=0
|
||||||
|
[udpscope] frames=1027 packets=1028 gaps=0 channels=4 span=838.861s stride=1 reseq=102 late=0
|
||||||
|
stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||||
|
[udpscope] channel 0 'sine' stored=95300 published=95100
|
||||||
|
|
||||||
|
=== late mock: --reorder-every 10 --reorder-delay-ms 50
|
||||||
|
[reseq-probe] reseq=157 late=46 stale=0 overflows=0 holes=40 hole_slots=4000 backfilled=15600
|
||||||
|
open_holes=40 bands=40 bands_repaired=39 nan_slots=100 scanned=44000
|
||||||
|
trigger_in_hole=0
|
||||||
|
[udpscope] frames=1027 packets=1028 gaps=98 channels=4 span=838.861s stride=1 reseq=296
|
||||||
|
late=98 stale=0 overflows=0 holes=91 hole_slots=9100 backfilled=36400
|
||||||
|
[udpscope] channel 0 'sine' stored=95300 published=95000
|
||||||
|
|
||||||
|
=== mixed mock: --drop-every 20 --reorder-every 10 --reorder-delay-ms 50
|
||||||
|
[reseq-probe] reseq=159 late=25 stale=0 overflows=0 holes=43 hole_slots=4300 backfilled=8400
|
||||||
|
open_holes=43 bands=43 bands_repaired=20 nan_slots=2200 scanned=44100
|
||||||
|
trigger_in_hole=0
|
||||||
|
[udpscope] frames=978 packets=981 gaps=101 channels=4 span=838.861s stride=1 reseq=309 late=50
|
||||||
|
stale=0 overflows=0 holes=94 hole_slots=9400 backfilled=18400
|
||||||
|
[udpscope] channel 0 'sine' stored=95400 published=95400
|
||||||
|
|
||||||
|
=== re-plan mock: --rate 100000 --drop-every 20 --reorder-every 10 --reorder-delay-ms 50
|
||||||
|
[reseq-probe] reseq=4374 late=72 stale=0 overflows=36 holes=315 hole_slots=31136
|
||||||
|
backfilled=26800 open_holes=64 bands=64 bands_repaired=16 nan_slots=7800
|
||||||
|
scanned=156600 trigger_in_hole=0
|
||||||
|
[udpscope] frames=8949 packets=8953 gaps=614 channels=4 span=83.886s stride=1 reseq=8028
|
||||||
|
late=144 stale=0 overflows=36 holes=603 hole_slots=59936 backfilled=55600
|
||||||
|
[udpscope] channel 0 'sine' stored=588600 published=584600
|
||||||
|
```
|
||||||
|
|
||||||
|
The `late` case's `bands_repaired` is one short of `bands` with `nan_slots=100` — exactly one
|
||||||
|
packet's worth of slots at stride 1 — because the probe fires 50 ms-delayed releases into a
|
||||||
|
window that always has one in flight. An earlier recording of this case read
|
||||||
|
`bands_repaired=42 nan_slots=0`; both are healthy, and which one a run gets is where the
|
||||||
|
probe's instant falls relative to the newest release. A `bands_repaired` *above* the count of
|
||||||
|
holes whose slots hold values is the defect, in either direction.
|
||||||
|
|
||||||
|
`holes` far above `open_holes` in the re-plan case is the two bounds working: a re-plan clears
|
||||||
|
the table (the old entries address a buffer that is gone) and `kMaxHoles` caps what survives
|
||||||
|
at 64. The `mixed` case's `open_holes=43` against `bands=43` has no such spread because
|
||||||
|
nothing re-planned. The per-field reference is in [probe-readings.md](probe-readings.md).
|
||||||
|
|
||||||
|
The mock also carries three **fault injections** that no probe schedules, because what they exercise
|
||||||
|
is the ingest rather than the UI. The first: `--time-glitch-at <stream seconds> --time-glitch-by <seconds>` makes
|
||||||
|
**one** packet's time signal jump ahead and then carries on from where the true timeline was, which
|
||||||
|
is what a device whose clock glitches puts on the wire. Run it with the trigger armed, e.g.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=400 UDPSCOPE_ARM_TRIGGER_MS=700 UDPSCOPE_EXIT_AFTER_MS=22000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8000 --holdoff 10
|
||||||
|
# mock: --rate 10000 --batch 100 --freq 5 --time-glitch-at 6.0 --time-glitch-by 10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Four readings, all off the once-a-second `[udpscope] trig engine:` line:
|
||||||
|
|
||||||
|
- exactly one `warning: ... jumped implausibly far ahead of the previous packet (16.01..16.0199
|
||||||
|
against a previous 6.0099); bridging that packet ...` — the glitch was **caught**;
|
||||||
|
- **no** `timing samples by arrival instead` — one bad packet must not change the time source;
|
||||||
|
- `now_t` crosses the glitch continuously (`5.040 → 6.050 → 7.050`), and `history` keeps **growing**
|
||||||
|
(`1.01 … 23.15`) instead of collapsing — the ring was not wiped;
|
||||||
|
- the latches stay a hold-off apart: `edge at 1.060 / latched at 2.060 / holdoff_until=12.060`, then
|
||||||
|
`edge at 12.260 / latched at 13.260 / holdoff_until=23.260` — **11.2 s** for a 10 s hold-off plus
|
||||||
|
a 0.9 s post window.
|
||||||
|
|
||||||
|
Before the fix the same run read a fallback warning, `history` collapsing 4.03 → 0.95, `now_t`
|
||||||
|
jumping 5.040 → 16.040, and two latches **1.2 s** apart. Note the window here comes from the saved
|
||||||
|
session for that port; the figures above are the default 2 s window (50 % pre), so move
|
||||||
|
`~/.config/udpscope/<host>_<port>.toml` aside to reproduce them.
|
||||||
|
|
||||||
|
The other two are **packet loss** (`--drop-every N`, `--drop-burst K`) and **wire inversion**
|
||||||
|
(`--reorder-every N`). Loss withholds a datagram the counter and the stream clock have already
|
||||||
|
advanced over; the client counts the forward jump as `counter_gaps`. An inversion withholds a
|
||||||
|
packet until the one behind it is sent, so the receiver sees a later counter first; a packet that
|
||||||
|
arrives within the 20 ms reorder window is resequenced with no hole, and one that arrives past it
|
||||||
|
leaves a committed hole that it then backfills (`UDPSCOPE_PROBE_RESEQ_MS` exercises both paths).
|
||||||
|
There is **no interpolation** anywhere below the ring.
|
||||||
|
|
||||||
|
All of the readings below are off `UDPSCOPE_EXIT_DUMP`'s `[udpscope] frames= packets= gaps=` line,
|
||||||
|
over an 8 s run against `--rate 10000 --batch 100 --freq 5` (100 packets a second) except where the
|
||||||
|
burst needs the faster stream. **`gaps` is the exact reading** and repeats to the packet; `frames`
|
||||||
|
and `packets` move by one or two with the run's own timing. Run each of these on its own — four of
|
||||||
|
them back to back in one script starved the mock past the 2 s silence timeout and every case,
|
||||||
|
including the clean one, then read a reconnect instead of what it was testing:
|
||||||
|
|
||||||
|
- **clean**, as the reference: `frames=1029 packets=1030 gaps=0`, no warning.
|
||||||
|
- `--drop-every 10`: `frames=925 packets=926 gaps=102`. Every tenth packet never reaches the wire,
|
||||||
|
so `packets` falls with `frames` and `gaps` is the count of them. No warning: a one-packet hole is
|
||||||
|
a one-packet jump in the stamps, far inside the glitch guard.
|
||||||
|
- `--reorder-every 10`: `frames=1028 packets=1029 gaps=0`. The inverted packet lands inside the
|
||||||
|
20 ms reorder window and is resequenced: every datagram arrives and `gaps` is 0. The
|
||||||
|
pre-resequencing signature was `frames=925 packets=1028 gaps=102` — an inversion cost a counter
|
||||||
|
gap and a discarded packet, which is exactly what resequencing removes. Adding
|
||||||
|
`--reorder-delay-ms 50` pushes the arrival *past* the window and is the case the
|
||||||
|
`UDPSCOPE_PROBE_RESEQ_MS` readings above cover; it is not this row.
|
||||||
|
- `--drop-every 1000 --drop-burst 200` at `--rate 100000 --batch 100` (1000 packets a second):
|
||||||
|
`frames=7000 packets=7001 gaps=1600`, eight bursts of 200, and still no warning.
|
||||||
|
- `--drop-every 1000 --drop-burst 300`, same stream: `frames=6203 packets=6204 gaps=2400` **and the
|
||||||
|
guard fires** — `warning: ... jumped implausibly far ahead of the previous packet (1.299..1.29999
|
||||||
|
against a previous 0.99899)`, then `timing samples by arrival instead`. 300 packet spans is past
|
||||||
|
`kGlitchPackets` (256), so real loss is read as a clock glitch; and since each bridge advances the
|
||||||
|
anchor by one packet span while the true timeline is 300 ahead, the next four packets fail too and
|
||||||
|
`kFailureLimit` throws the stream onto the arrival clock. A burst between 256 packets and the 2 s
|
||||||
|
`silence_timeout_s` is the only window in which this is reachable, and it is a finding, not a
|
||||||
|
baseline.
|
||||||
|
|
||||||
|
Keep a burst under 2 s of wire time whatever the rate: past that the client reconnects, the mock
|
||||||
|
resets its counter and clock on the new CONNECT, and the run reads `gaps=0` with a *backwards* stamp
|
||||||
|
warning (`does not advance across packets ... 0..0.0099 against a previous 0.3899`) — the reconnect
|
||||||
|
path, not the loss path.
|
||||||
|
|
||||||
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
|
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
|
||||||
same display and the second gets no GL context — it dumps `layout over 0 frames` and
|
same display and the second gets no GL context — it dumps `layout over 0 frames` and
|
||||||
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
|
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
|
||||||
@@ -178,3 +565,54 @@ hold ports.
|
|||||||
|
|
||||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||||
|
|
||||||
|
## The noise probe needs a spiked mock
|
||||||
|
|
||||||
|
Every other probe runs against the plain mock. The noise probe reads what a *band* and an *HF reject* do to
|
||||||
|
impulsive interference, so its mock must have some: an alternating-sign impulse train of 2 samples at
|
||||||
|
amplitude 0.5 every 5 ms, added to `sine` alone (so a spiked channel can be read against a clean one in the
|
||||||
|
same run).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/martino/Projects/tools/udpscope
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \
|
||||||
|
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 &
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
UDPSCOPE_PROBE_NOISE_MS=4000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which
|
||||||
|
installs a math graph through `apply_math_channels()` -- and that is a *structural rebuild* that replaces the
|
||||||
|
buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
|
||||||
|
happen seconds *before* them, and the chain is what the number is really choosing: `4000` puts the graph in at
|
||||||
|
4 s, `noise_probe` at 9 s (it enables the signals it measures itself and waits 2.5 s for the ring to refill
|
||||||
|
after *that* change), the legs and the five measurements at about 11.5 s, the de-noise channel enabled at
|
||||||
|
14 s, and the de-noise reading at 16.5 s -- hence the 18 s exit and the 20 s mock. **Collapsing the two
|
||||||
|
stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s and then scanned it
|
||||||
|
immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator.
|
||||||
|
|
||||||
|
**Why 4 s and not 3.** `enable_all_probe`'s last act is a timeout of its own, `ENABLE_ALL_MS + 2000` -- 3 s
|
||||||
|
under this recipe -- which walks `signal_list` for the front channel's acquire tick and clicks it. A probe
|
||||||
|
that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then
|
||||||
|
emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class
|
||||||
|
pointer`, with the backtrace in `enable_all_probe()::{lambda}`. It is a real fragility of that probe's lookup
|
||||||
|
and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not
|
||||||
|
merely from each other.
|
||||||
|
|
||||||
|
The de-noise channel is installed *disabled* and enabled only for its own reading (stage 3). An acquired
|
||||||
|
channel is a share of the ring, so a fifth one shortens every channel's retained span -- and the six leg
|
||||||
|
counts and `pkpk bare` all scale with that span. The readings above the de-noise line belong to the older
|
||||||
|
feature and are the proof it still works, so the new channel stays out of the acquisition plan until they
|
||||||
|
have run. `--spike-every`/`--spike-amp`/
|
||||||
|
`--spike-width` are the mock's own; `--batch` must stay at or below 1000 (2000 samples x 5 signals x f64 is
|
||||||
|
80 kB, past UDP's ~65507-byte payload, and the mock dies on its first `sendto` while the run still exits 0).
|
||||||
|
|
||||||
|
The three lines and what each must read are in `probe-readings.md`.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
|
||||||
|
in the same run. The noise and measurement probes both push onto `app.measures`, and the later one would read
|
||||||
|
the earlier one's rows as its own; the noise and math probes both write `app.math`, so the later one's graph
|
||||||
|
lands on top of the earlier one's and both sets of readings describe a state neither probe set up. The noise probe carries
|
||||||
|
its own measurement legs (see `probe-readings.md`), so nothing is lost by keeping them apart.
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Project review — 2026-10-13
|
||||||
|
|
||||||
|
A status review for performance, defects and gaps. Every claim below was read back against the
|
||||||
|
current tree; where a candidate finding turned out to be wrong it is listed as dismissed, with the
|
||||||
|
reason, rather than dropped — a false alarm that is not written down gets re-raised.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project is in good shape. All ten `todo.md` items are implemented, every deviation from
|
||||||
|
`SPECS.md` is documented in README's *Review findings not (yet) implemented* and *Limitations*, there
|
||||||
|
is no `TODO`/`FIXME`/`HACK` anywhere in `src/`, no dead command-line option, and no settings field
|
||||||
|
that is written but never read. The tree builds clean.
|
||||||
|
|
||||||
|
- **Defects: none confirmed.** Four candidates were investigated and all four are correct as
|
||||||
|
written (§2).
|
||||||
|
- **Performance: one real win on the ingest hot path** (§1.1) — a 64-bit `div` per sample per
|
||||||
|
channel that the code's own comment believes is already cheap. Then one real win on the idle
|
||||||
|
frame path (§1.2), and three small ones. **§1.1 and §1.2 have since been implemented**; the three
|
||||||
|
small ones (§1.3-§1.5) have not.
|
||||||
|
- **Gaps: no missing requirement.** What is missing is only what was never asked for (§4), plus one
|
||||||
|
stale sentence in README (§3).
|
||||||
|
|
||||||
|
## 1. Performance
|
||||||
|
|
||||||
|
### 1.1 A 64-bit division per sample per channel on the producer hot path
|
||||||
|
|
||||||
|
> **Implemented** (2026-10-13), as the fix below describes: `Acquisition::acc_mask_` holds the
|
||||||
|
> per-channel masks, built where the accumulators are sized; `reserve_hole()` moved with it; the
|
||||||
|
> misleading comment at `src/model.cpp:120-122` now says what the mask actually buys. Written up in
|
||||||
|
> README under *What the ingest filter and a still frame cost*.
|
||||||
|
|
||||||
|
`src/acquisition.cpp:856-857`, in the innermost loop of `on_data()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const u64 period = g * part.stride[i];
|
||||||
|
if (tick % period != period - 1) continue;
|
||||||
|
```
|
||||||
|
|
||||||
|
Three facts make this the most expensive avoidable thing in the application:
|
||||||
|
|
||||||
|
1. **`part.stride[i]` is always 1.** `plan_partition()` assigns it (`src/model.cpp:49`) and then
|
||||||
|
explicitly re-asserts it (`src/model.cpp:70`), with the comment above it explaining why
|
||||||
|
per-channel strides were abandoned. So `period == g` for every channel, always — the load and
|
||||||
|
the multiply are provably redundant.
|
||||||
|
2. **`tick % period` is a real `div`.** `g` is a runtime `u64`, so the compiler cannot strength-
|
||||||
|
reduce it, whatever its value. A 64-bit integer division is ~20-40 cycles of latency on x86.
|
||||||
|
The intent was otherwise: `src/model.cpp:120-122` says a power-of-two stride "keeps `tick %
|
||||||
|
period` cheap", which would be true of a compile-time constant and is not true of a variable.
|
||||||
|
**`time_stride` being a power of two currently buys nothing here.**
|
||||||
|
3. **The same condition is already computed.** `tick % g == g - 1` is evaluated at
|
||||||
|
`src/acquisition.cpp:811` for the time ring, and again at `:881` for the math push — and
|
||||||
|
`:857` is the same test once more per channel.
|
||||||
|
|
||||||
|
Cost model: `(n_channels + 2)` divisions per **raw** sample, i.e. per wire sample, not per stored
|
||||||
|
one — the decimation filter deliberately reads every sample (`:850-852`). At the fast mock's
|
||||||
|
1.3 MS/s with 4 channels that is ~7.8 M divisions a second on the producer thread, which is the
|
||||||
|
thread whose budget overrun costs packets rather than frames.
|
||||||
|
|
||||||
|
Fix: compute the group boundary **once per sample** and reuse it. `g` is a power of two, so
|
||||||
|
`const u64 mask = g - 1;` hoisted out of the packet loop turns all three sites into
|
||||||
|
`(tick & mask) == mask`. To keep the per-channel generality the type still carries, precompute a
|
||||||
|
per-channel mask vector when the partition is planned rather than per sample.
|
||||||
|
|
||||||
|
The same expression appears in the hole path at `src/acquisition.cpp:966` (inside
|
||||||
|
`reserve_hole()`'s double loop). That one is cosmetic — it runs only on packet loss — but it should
|
||||||
|
move with its twin so the two paths keep matching.
|
||||||
|
|
||||||
|
How to verify: the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), reading the
|
||||||
|
exit dump's `gaps=` and the producer's CPU share before and after. Expect the win to show as
|
||||||
|
headroom rather than as a changed reading on a stream the producer already keeps up with.
|
||||||
|
|
||||||
|
### 1.2 The figure is re-rendered 60 times a second even when the picture cannot change
|
||||||
|
|
||||||
|
> **Implemented** (2026-10-13), conservatively: a signature of what a frozen view can be re-framed
|
||||||
|
> by, opening the gate for `kSettleRenders` frames, plus a `kStaticRenderEvery` floor that renders
|
||||||
|
> anyway every four ticks, and the layout editor excluded outright. `[pause-probe]`'s new `skips=`
|
||||||
|
> field reports it: a measured run read 0 live, 33 paused, with `columns == graded`, `render blank
|
||||||
|
> frames: 0` and the layout floor still converged.
|
||||||
|
|
||||||
|
`src/ui.cpp:3709` queues a GL render unconditionally at the end of every tick, and the tick is a
|
||||||
|
fixed 16 ms timeout (`src/ui_app.hpp:1023`). In `Held` and `Paused` the bound data is an immutable
|
||||||
|
snapshot and the axis is pinned, so with no pointer interaction the frame is provably identical to
|
||||||
|
the last one — and it is still fully re-rendered. On llvmpipe, or on a laptop looking at a latched
|
||||||
|
event, that is the whole GPU cost of the application for nothing.
|
||||||
|
|
||||||
|
Fix: queue the render only when something that can change the picture did — new samples acquired,
|
||||||
|
a mode/epoch/pin change, a mark/cursor/label move, an interaction, a resize. Keep it conservative:
|
||||||
|
render unconditionally while the layout is still settling, since the layout editor's buttons are
|
||||||
|
placed from the *previous* frame's rects and several probes read a value one frame after the ask.
|
||||||
|
|
||||||
|
How to verify: the exit dump's per-plot painted-frame counts and `layout over N frames` are exactly
|
||||||
|
the two readings that would catch an over-eager skip.
|
||||||
|
|
||||||
|
### 1.3 The event publish blocks the UI tick for 69-115 ms
|
||||||
|
|
||||||
|
Already documented, with measurements, at `README.md:2189-2191` — listed here only because the
|
||||||
|
README prices exactly one remedy (a second live ring) and declines it for good reasons. Two
|
||||||
|
options it does not price, both cheaper than a second budget's worth of memory:
|
||||||
|
|
||||||
|
- do the snapshot copy on the **producer** side of the handoff, so the stall lands where a stall is
|
||||||
|
already tolerated at a latch (the pause publish already blocks the producer, 22-86 ms);
|
||||||
|
- chunk the copy across frames behind a sequence number, publishing only when complete.
|
||||||
|
|
||||||
|
Worth a design note before any code — the README's own A/B (`gaps=0` either way) says this buys a
|
||||||
|
smoother frame at a latch, not recovered packets, so it is a polish item, not a correctness one.
|
||||||
|
|
||||||
|
### 1.4 The status detail string is rebuilt and re-applied every frame
|
||||||
|
|
||||||
|
`src/ui.cpp:4063` calls `format_detail(snap)` every tick and `:4082` pushes the result into
|
||||||
|
`gtk_widget_set_tooltip_text()` every tick. The popover is already guarded by its own visibility
|
||||||
|
(`:4083-4087`); the tooltip is not, and the string is long enough that the code tracks its maximum
|
||||||
|
length. Nothing reads it unless the pointer rests on the status label.
|
||||||
|
|
||||||
|
Fix: keep the last applied detail string and skip both the format and the setter when it is
|
||||||
|
unchanged. Low risk, no behaviour change.
|
||||||
|
|
||||||
|
### 1.5 Search marks are re-filtered and re-sorted every frame
|
||||||
|
|
||||||
|
`src/ui.cpp:1760-1768`. Two heap vectors, a pass over every hit and an `std::sort` per frame for as
|
||||||
|
long as a search result set exists. Correctly short-circuited when there are no hits
|
||||||
|
(`:1753`), and being recomputed every frame is load-bearing for a *live* view (the marks are stored
|
||||||
|
in stream seconds and the window rolls under them) — but in `Held`/`Paused` neither the hits nor
|
||||||
|
the view range move, so the whole result can be cached and invalidated on a view-range or hit-set
|
||||||
|
change.
|
||||||
|
|
||||||
|
### 1.6 Noted, not recommended: the model mutex spans all of `on_data()`
|
||||||
|
|
||||||
|
`src/acquisition.cpp:558` takes the lock and holds it to the end of the function (~line 913),
|
||||||
|
covering the sample loop, the math evaluation, the trigger engine, and hole reserve/backfill. The
|
||||||
|
UI tick takes the same mutex (`src/ui.cpp:3285`). This is the documented design ("taken once per
|
||||||
|
DATA frame", `docs/claude/architecture.md:15-16`) and it is the right call at today's rates, but it
|
||||||
|
is the coupling by which math-channel cost on the producer turns into UI frame jitter. The existing
|
||||||
|
`math_timed` per-slot instrumentation is the thing to watch if math graphs get heavier.
|
||||||
|
|
||||||
|
## 2. Defect candidates investigated and dismissed
|
||||||
|
|
||||||
|
Recorded so they are not re-raised.
|
||||||
|
|
||||||
|
- **`capture_edge()` loading `memory_order_relaxed`** (`src/model.hpp:492`) while its partner
|
||||||
|
`trigger_pending()` carries release/acquire. **Correct as written.** The consumer reaches
|
||||||
|
`capture_edge()` at `src/ui.cpp:3275` only *after* `display_mode()` at `:3273` has done the
|
||||||
|
acquire load of `trigger_pending_`; the acquire establishes the happens-before, so a relaxed load
|
||||||
|
afterwards cannot see the stale edge, and an acquire also forbids the later load being reordered
|
||||||
|
ahead of it. The probe path (`src/ui_probe.cpp:1129-1130`) reads the flag first too.
|
||||||
|
*One fragility worth a line of doc:* the correctness is a property of **read order at the call
|
||||||
|
site**, and `src/model.hpp:489-491` says a consumer that sees the flag sees the edge without
|
||||||
|
saying that the consumer must therefore read the flag first. Tightening that comment is the whole
|
||||||
|
of the work.
|
||||||
|
- **`math_values_.data()` passed to `math_step()` across a possible reallocation**
|
||||||
|
(`src/model.cpp:409-422`). **Safe.** `push_math_sample()` is only reached from `on_data()`
|
||||||
|
(`src/acquisition.cpp:881`, `:978`), which holds the model mutex for its whole body, and
|
||||||
|
`set_math_channels()` takes the same mutex.
|
||||||
|
- **`model_.signals()` indexed by `channels[0].signal` after the lock was released**
|
||||||
|
(`src/acquisition.cpp:593-595`). **Safe** — same reason: the lock is held from `:558` to the end
|
||||||
|
of the function, so no CONFIG can re-shape the signal list underneath it.
|
||||||
|
- **`events_thumb` left dangling across a list rebuild** (`src/ui_events.cpp:632`). **Already
|
||||||
|
handled** — nulled before the rows are removed and rebound after, with the old texture unreffed.
|
||||||
|
|
||||||
|
Nothing in the trigger/pause/file-view/replay seams, the math-channel dependency handling, the
|
||||||
|
measurement and cursor paths, or the export/session writers reproduced a wrong number or a lost
|
||||||
|
action on inspection. That is a statement about a code read, not about a test run: README's own
|
||||||
|
`Limitations` is right that these paths are checked headless, and the seams between two *user*
|
||||||
|
actions are where the probes are thinnest.
|
||||||
|
|
||||||
|
## 3. One documentation defect
|
||||||
|
|
||||||
|
`README.md:2304-2307` says the interactive paths — "drag and drop, the dialogs, the right-click
|
||||||
|
menu" — "were not driven by an automated test". That is now stale in all three particulars:
|
||||||
|
`UDPSCOPE_PROBE_DROP_MS` drives the drop handler, `UDPSCOPE_PROBE_ROWMENU_MS` walks the
|
||||||
|
right-click menu and clicks through its submenu, and `UDPSCOPE_PROBE_DEFAULTS_MS`,
|
||||||
|
`UDPSCOPE_PROBE_CONFIGS_MS`, `UDPSCOPE_PROBE_PREFS_MS` and `UDPSCOPE_PROBE_HELP_MS` all open and
|
||||||
|
read back dialogs. What remains genuinely undriven is the *gesture* layer (a real pointer drag, a
|
||||||
|
real popover dismissal) rather than the handlers behind it, which is a much narrower claim and the
|
||||||
|
one worth making.
|
||||||
|
|
||||||
|
## 4. Gaps
|
||||||
|
|
||||||
|
### Requirements: complete
|
||||||
|
|
||||||
|
`SPECS.md` was walked bullet by bullet. Everything is implemented, and the five places where the
|
||||||
|
implementation differs are each documented with a reason at `README.md:2182-2240`: one live buffer
|
||||||
|
plus an immutable `EventSnapshot` rather than two live rings; allocation at the first CONFIG rather
|
||||||
|
than at startup; no per-channel decimation (the spec's formula aliased a 2 kHz sine on a 1 MHz clock
|
||||||
|
to a flat line); pre-trigger % as a share of the event window; and a capture drawn as it fills.
|
||||||
|
`todo.md`'s ten items are all really there, including item 9's full preference list — the
|
||||||
|
light/dark/auto theme, the reset-to-defaults and the cache controls are all in `src/settings.hpp`
|
||||||
|
and `src/ui_prefs.cpp`.
|
||||||
|
|
||||||
|
### Not asked for, worth considering
|
||||||
|
|
||||||
|
Ranked by value against cost, not by ambition:
|
||||||
|
|
||||||
|
1. **Save the current view as an image.** The cheapest of these by a wide margin: the event cache
|
||||||
|
already has a sample-driven image writer (`write_preview()`, `src/event_store.cpp:266`), so a
|
||||||
|
menu item and a file chooser is most of the work. A scope user screenshots constantly.
|
||||||
|
2. **A draggable trigger level on the plot.** The trigger marker exists but is a vertical cursor
|
||||||
|
and deliberately non-selectable (`src/plot_host.cpp:250`); the level is reachable only through
|
||||||
|
the panel's spin button. A horizontal handle on the trigger channel's row is the standard
|
||||||
|
gesture on every bench scope.
|
||||||
|
3. **Per-trace vertical offset and scale.** Up to eight signals share one row's y axis, so two
|
||||||
|
signals of different magnitudes cannot both be read. This is the gap a user hits soonest.
|
||||||
|
4. **An FFT / spectrum row.** The largest of these, and the one a UDPStreamer user is most likely
|
||||||
|
to ask for next; it fits the figure's row model (one row, one derived series) rather than
|
||||||
|
fighting it.
|
||||||
|
5. **XY mode**, and **undo for layout edits** — both real scope features, both a poor fit for the
|
||||||
|
current one-x-window figure and the layout file respectively. Mentioned for completeness.
|
||||||
|
|
||||||
|
## 5. Suggested order
|
||||||
|
|
||||||
|
1. §1.1, the ingest-path division — small, local, the clearest win, and it corrects a comment that
|
||||||
|
is currently misleading about why the power of two matters.
|
||||||
|
2. §3, the stale README sentence — minutes, and it is wrong today.
|
||||||
|
3. §1.4 and §1.5, the per-frame string and sort — small and safe.
|
||||||
|
4. §2's doc tightening on `capture_edge()`.
|
||||||
|
5. §1.2, the conditional render — the bigger win of the two performance items, but the one that
|
||||||
|
needs care against the layout-settling probes, so it deserves its own change.
|
||||||
|
6. §4.1, the screenshot — the cheapest real feature on the list.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
|||||||
|
# UI files and current GTK practice
|
||||||
|
|
||||||
|
Where the application's interface should be a `.ui` resource instead of widget construction, what is
|
||||||
|
already there, and the order to finish it in. Written 2026-10-11.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Three reasons, in the order they bite.
|
||||||
|
|
||||||
|
1. **Some things can only be said in a UI file.** The shortcut overview is the proof: GTK's
|
||||||
|
`GtkShortcutsShortcut` has exactly one gesture type that draws an `icon`, and libadwaita's
|
||||||
|
`AdwShortcutsItem` has no icon property at all -- so a mouse gesture can only be shown as a glyph
|
||||||
|
from a `.ui`. `src/ui/shortcuts.ui` is that, and it is the reason the file exists.
|
||||||
|
2. **Structure is data.** A window's arrangement rewritten in C++ is a hundred lines of
|
||||||
|
`gtk_box_append` that no one can read at a glance, cannot be compared with a diff, and cannot be
|
||||||
|
previewed. A UI file is the same arrangement in the notation the GNOME ecosystem reads.
|
||||||
|
3. **Current GTK and libadwaita have better widgets than this app uses.** The forms here are
|
||||||
|
`GtkEntry`/`GtkSpinButton` in hand-built label rows. The modern equivalents -- `AdwEntryRow`,
|
||||||
|
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`, inside `AdwPreferencesPage`/`AdwPreferencesGroup` in an
|
||||||
|
`AdwPreferencesDialog` -- are what the preferences dialog should be, and they come from the same
|
||||||
|
move.
|
||||||
|
|
||||||
|
## Conventions (established, and in `docs/claude/invariants-gtk-and-chrome.md`)
|
||||||
|
|
||||||
|
- `.ui` files live in `src/ui/` and are compiled by the third GResource (`udpscope-ui.gresource.xml`,
|
||||||
|
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||||
|
- Every file carries `preprocess="xml-stripblanks"`, which is the only thing that catches a malformed
|
||||||
|
file at build time.
|
||||||
|
- A file that is valid XML but wrong for GTK **aborts the process** (`Gtk-ERROR`). The probe that opens
|
||||||
|
every window is what catches that; there is no build-time substitute.
|
||||||
|
- A `GIcon` property takes a nested object, not a name.
|
||||||
|
- A `.ui` over 4095 characters needs `-Wno-overlength-strings` on its generated source.
|
||||||
|
- A window built from a builder is replaced (not reused) on the next opening: it belongs to the
|
||||||
|
builder's object list, and a destroyed `GtkWindow` cannot be presented again.
|
||||||
|
- A deprecated *widget* is acceptable when no deprecated *function* is called.
|
||||||
|
|
||||||
|
## Acceptance for every slice
|
||||||
|
|
||||||
|
The same as any other change here: the build stays at **four warnings**, a run stays at **no
|
||||||
|
`Gtk-CRITICAL`**, and the slice's own surface is read back by a probe rather than asserted. A new `.ui`
|
||||||
|
needs a probe that proves it *loads* and that its contents match whatever drives it -- `[help]`'s
|
||||||
|
`ui=` against `tables=` is the model.
|
||||||
|
|
||||||
|
## Already done
|
||||||
|
|
||||||
|
- `src/ui/main-window.ui` -- the main window's **skeleton**: the window, its two panes, the plot column
|
||||||
|
with the status line under it, and the sidebar's scroller. The four subtrees that depend on the stream
|
||||||
|
(the events pane, the two plot areas, the sidebar's panels, the status popover) are still built by
|
||||||
|
code and put into the slots by id.
|
||||||
|
- `src/ui/source-form.ui` -- the form a source is added or edited with: an `AdwDialog` of
|
||||||
|
`AdwPreferencesGroup` rows, with its action in the header bar where the label says which mode it is in.
|
||||||
|
- `src/ui/help.ui` -- the help window's chrome: an `AdwWindow` with a header bar and an
|
||||||
|
`AdwNavigationSplitView` whose sidebar is the page list. The rows stay in C++ because the fourteen
|
||||||
|
pages are data, not template.
|
||||||
|
- `src/ui/preferences.ui` -- the preferences, as an `AdwPreferencesDialog` with `AdwEntryRow`,
|
||||||
|
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` and `AdwButtonRow` rows. The Apply button is gone: a row
|
||||||
|
applies as it changes, which is what those widgets are designed around, and `[prefs-probe]` reads the
|
||||||
|
file back after every change.
|
||||||
|
- `src/ui/shortcuts.ui` -- the shortcut overview, with the mouse rows drawn as an icon cell.
|
||||||
|
- The `src/ui/` GResource and its build block.
|
||||||
|
- `[help]`'s `ui=`/`ui_accel=`/`ui_icon=`/`ui_bare=` reading, which checks the file against the tables
|
||||||
|
and asserts that no row is left without a visual.
|
||||||
|
|
||||||
|
## Remaining, smallest first
|
||||||
|
|
||||||
|
| where | what | why it is the right next step |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/source_dialog.cpp` (the chooser's own window) | the chooser: `.ui` for the window, its two pages and its action row. Its **host form is done**; what is left is the list, the notebook and the buttons around it | the last hand-built UI outside `ui.cpp`, and the file is already open |
|
||||||
|
| `build_measures_panel` (`src/ui.cpp`, ~82 lines) | the measurements panel: `.ui` + `tools/check_ui.py` after | small, self-contained, and its own probe |
|
||||||
|
| `src/ui/ui_trigger.cpp` (~22 kB) | the trigger panel: the `.ui` holds the panel and its groups; the signal chips and the condition rows stay in code because they follow CONFIG | the largest user-visible panel |
|
||||||
|
| `src/ui/ui_signals.cpp` (~20 kB) | the signals panel | mostly a list of data-driven rows, so the `.ui` holds the frame |
|
||||||
|
| `src/ui/ui_math.cpp` (~48 kB) | the math panel and the node editor's chrome (the canvas itself is drawn, not declared) | the largest file, and the one where least of it is template |
|
||||||
|
| `src/ui/ui_events.cpp` (~44 kB) | the events pane | the rows are events, the frame is not |
|
||||||
|
| `src/ui/ui_layout.cpp` (~20 kB) | the layout editor's overlay buttons, which are positioned by code | only the buttons are template |
|
||||||
|
| `build_toolbar` (`src/ui.cpp`, ~212 lines) | the header bar's layout, with every button still wired in code | delicate: the `[udpscope] header:` probe reads its geometry |
|
||||||
|
|
||||||
|
**Every row of the table above is done or accounted for.** The header bar went last and in one change, because
|
||||||
|
`gtk_header_bar_pack_start()` appends after whatever a file has already packed -- staging it by group would
|
||||||
|
have reordered the bar in an intermediate commit. Its frame is `src/ui/header-bar.ui`; the overflow menu's
|
||||||
|
rows, the window control and every handler stayed in code, and the probe's header readings are byte-identical
|
||||||
|
before and after.
|
||||||
|
|
||||||
|
**The layout editor's buttons are not a `.ui` row, and the note above is wrong about them.** Their count *is*
|
||||||
|
the row count (one per row, one per boundary), and `GtkBuilder` instantiates a file's objects once, so a file
|
||||||
|
can hold neither the number nor a prototype for the code to copy. What was real in that row was the
|
||||||
|
duplication: the shared look is now one function (`layout_overlay_button()`), and the layout probe reads
|
||||||
|
byte-identical before and after. See `invariants-gtk-and-chrome.md`.
|
||||||
|
|
||||||
|
**Done since, one row per commit:** the source chooser's window (`0a64e9e`), the measurements panel
|
||||||
|
(`d344361`, `07fcf61`), the trigger panel (`1ccc70c`), the signals panel (`c37fbdd`), the math panel
|
||||||
|
(`90a17eb`) and the math editor's window, identity row and canvas. What is left of that row is the palette
|
||||||
|
and the buttons around the canvas, which stay in code for the reason the canvas's *drawing* does.
|
||||||
|
|
||||||
|
**Done since:** the sidebar is an `AdwOverlaySplitView` (`src/ui/main-window.ui`) rather than a paned's end
|
||||||
|
child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see
|
||||||
|
`[sidebar-probe]`.
|
||||||
|
|
||||||
|
Each of these is one change on the pattern `src/ui/ui_cursors.cpp` now follows: `ui_load()` the file, resolve
|
||||||
|
the ids, keep every value, signal and rebuild in code, and run the panel's own probe before committing.
|
||||||
|
|
||||||
|
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,437 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# Three things a bench scope has and udpscope does not: qualified triggers, measurement statistics, and a search over the buffer
|
||||||
|
|
||||||
|
udpscope triggers on one thing — a level crossing — reports each measurement as a single
|
||||||
|
number with no sense of how it has varied, and offers no way to ask where in the retained
|
||||||
|
buffer something happened. A survey of what bench scopes and the open-source viewers provide
|
||||||
|
turned up a longer list than this; it was narrowed deliberately, and what was cut is named in
|
||||||
|
**Scope** below.
|
||||||
|
|
||||||
|
The three features chosen are not independent. A qualified trigger condition and a search over
|
||||||
|
the buffer are the *same question* asked of live data and of recorded data, so they are built
|
||||||
|
on one evaluator rather than two, and the probe that proves they agree is part of the work.
|
||||||
|
Measurement statistics are separate but cheap: the pane's recompute pass already produces the
|
||||||
|
number a statistic accumulates.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- A shared, allocation-free `EventDetector` extracted out of `TriggerEngine`, carrying four
|
||||||
|
condition types: edge (as now), pulse width (with glitch as a comparison, not a fifth type),
|
||||||
|
window, and dropout.
|
||||||
|
- The trigger panel's new type row and the parameter rows each type needs.
|
||||||
|
- Per-measurement statistics — count, mean, standard deviation, min, max — accumulated one
|
||||||
|
observation per recompute, with a pane-wide toggle and a Reset.
|
||||||
|
- A `Search` section in the trigger panel that scans the retained ring for the trigger's own
|
||||||
|
condition, marks the hits, and navigates them by reusing the existing pause.
|
||||||
|
- Four new probes, and the mock waveforms they need.
|
||||||
|
- `SPECS.md` is not changed; `README.md` and `docs/claude/` are.
|
||||||
|
|
||||||
|
**Out, and deliberately:**
|
||||||
|
|
||||||
|
- **Reference waveforms** — declined explicitly.
|
||||||
|
- **PNG export** — the `.npy`/`.csv` export covers the need that matters; a screenshot is the
|
||||||
|
window manager's job.
|
||||||
|
- **FFT / spectrum** — needs a second `plot::Figure` with its own x units, which is a project
|
||||||
|
of its own and not a feature of this one.
|
||||||
|
- **Byte-bounded event history** — a second retention policy beside the ring's, with its own
|
||||||
|
eviction. Out for the same reason.
|
||||||
|
- No new measurement types. No change to `measure_scan_buckets()` /
|
||||||
|
`measure_accumulate_bucket()` or to the hysteresis band. No change to the decimation plan, the
|
||||||
|
partition, or the two-thread split.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
These bind every task below and are not restated in each one.
|
||||||
|
|
||||||
|
- **Two threads only.** `EventDetector::feed()` runs on the producer inside
|
||||||
|
`Acquisition::on_data()`, under `ScopeModel::mutex()`. It allocates nothing, throws nothing,
|
||||||
|
does no I/O, and is `noexcept`. The statistics accumulator and the search driver run on the
|
||||||
|
GTK main thread.
|
||||||
|
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||||
|
- **Stream seconds, end to end.** Nothing on the trigger or search path converts a span to a
|
||||||
|
sample count, and nothing introduces a samples-per-second estimate. That conversion is the
|
||||||
|
documented cause of the "the post window is empty" defect
|
||||||
|
(`docs/claude/invariants-trigger-and-pinning.md`).
|
||||||
|
- **The detector is fed the stored (decimated, averaged) value**, never the wire value — an
|
||||||
|
event found on a sample the buffer does not hold cannot be cut from it.
|
||||||
|
- **A pinned axis is pinned from the snapshot's own stamps, never from the request.** Applies to
|
||||||
|
every pause the search publishes.
|
||||||
|
- **There is no unit test suite.** Every assertion is a probe line on stderr, driven through the
|
||||||
|
real widgets, read against the documented baselines: the documented build-warning count and
|
||||||
|
zero `Gtk-CRITICAL`.
|
||||||
|
- **No figure is written into a document unless it was measured on this machine.** Costs are
|
||||||
|
printed by probes and quoted from those runs.
|
||||||
|
- **Run one probe at a time.** The only exception is the search probe's internal
|
||||||
|
scan-versus-armed pairing, which is one probe.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/condition.hpp` / `.cpp` | **New.** `EventCondition`, `TriggerType`, `WidthCmp`, and `EventDetector` — the whole per-sample condition state machine and nothing else. No model, no GTK, no allocation. |
|
||||||
|
| `src/trigger.hpp` / `.cpp` | `TriggerConfig` grows an `EventCondition`. `TriggerEngine` keeps arming, the hold-off, the pre-window gate, the post window and the mode, and delegates detection to `EventDetector`. |
|
||||||
|
| `src/search.hpp` / `.cpp` | **New.** `BufferSearch` — the chunked scan driver over the retained ring, the hit list, the cursor into it, and the staleness state. Owns no widgets. |
|
||||||
|
| `src/measure.hpp` / `.cpp` | `MeasureStatsAccum` (Welford) beside the existing result types. |
|
||||||
|
| `src/plot_host.hpp` / `.cpp` | `kSearchMarks` cursors per row, created in `install()`, placed by a new `set_search_marks()`. |
|
||||||
|
| `src/ui.cpp` | The trigger panel's type and parameter rows, the `Search` expander, the statistics toggle and second line, the chunk pump in the frame tick, and the four probes. |
|
||||||
|
| `src/layout_store.hpp` / `.cpp` | One new `LayoutState` field: the statistics toggle. The trigger is not persisted today and is not persisted by this design. |
|
||||||
|
| `tools/mock_udpstreamer.py` | `--glitch-at` / `--glitch-width` / `--quiet-at`. |
|
||||||
|
| `README.md`, `docs/claude/*` | What was built, and the invariants it adds. |
|
||||||
|
|
||||||
|
`src/condition.*` and `src/search.*` are new files rather than additions to `src/trigger.cpp`
|
||||||
|
and `src/ui.cpp` for two reasons: the detector must be includable by the search without
|
||||||
|
dragging in the engine's arming state, and `src/ui.cpp` is already the largest file in the
|
||||||
|
project.
|
||||||
|
|
||||||
|
## 1. The shared event condition
|
||||||
|
|
||||||
|
### What is extracted
|
||||||
|
|
||||||
|
`TriggerEngine::process()` (`src/trigger.cpp:38-106`) currently does three jobs. Lines 78-84
|
||||||
|
detect a crossing; line 92 gates it on the pre-trigger window; lines 42-75 run the
|
||||||
|
pending/post-window/hold-off/mode machine. Only the first is what a search means by "a
|
||||||
|
condition", so only the first moves.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||||
|
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||||
|
|
||||||
|
struct EventCondition {
|
||||||
|
TriggerType type = TriggerType::Edge;
|
||||||
|
double level = 0.0;
|
||||||
|
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||||
|
TriggerEdge edge = TriggerEdge::Rising;
|
||||||
|
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||||
|
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||||
|
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||||
|
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||||
|
bool operator==(const EventCondition&) const noexcept;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EventDetector {
|
||||||
|
public:
|
||||||
|
/// Adopts the condition. Resets the state iff the condition differs.
|
||||||
|
void configure(const EventCondition& c) noexcept;
|
||||||
|
void reset() noexcept;
|
||||||
|
/// One sample, in stream order. Returns the event's own stream time, or NaN for no event.
|
||||||
|
double feed(double value, double t) noexcept;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`feed()` returns a **time** rather than a bool because for two of the four types the event's
|
||||||
|
instant is not the sample being looked at: a pulse width is only known at the pulse's closing
|
||||||
|
edge, and a dropout fires on an ordinary sample nowhere near an edge.
|
||||||
|
|
||||||
|
`TriggerEngine::process()` then reads:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const double event_t = detector_.feed(value, now_s);
|
||||||
|
if (!std::isfinite(event_t)) return false;
|
||||||
|
if (event_t - oldest_s_ < pre_seconds_) return false; // the gate, now on the event's own t
|
||||||
|
last_trigger_s_ = event_t;
|
||||||
|
pending_ = true;
|
||||||
|
pending_start_s_ = event_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the gate and `pending_start_s_` both move from `now_s` to `event_t`. For `Edge` they are
|
||||||
|
the same value and the behaviour is bit-identical to today; for a long qualifying pulse they
|
||||||
|
differ, and using `now_s` would measure the post window from the wrong instant.
|
||||||
|
|
||||||
|
`TriggerEngine::configure()` keeps its name-keyed reset exactly as it is
|
||||||
|
(`docs/claude/invariants-trigger-and-pinning.md`) and additionally calls
|
||||||
|
`detector_.configure(cfg.condition)`, which resets the detector only when the condition
|
||||||
|
actually changed — a condition re-applied every packet must not restart the state machine, or a
|
||||||
|
pulse-width condition could never see a pulse end.
|
||||||
|
|
||||||
|
### The four types
|
||||||
|
|
||||||
|
| Type | Fires when | The instant it calls t0 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Edge` | `level` is crossed in `edge`'s direction | the crossing sample |
|
||||||
|
| `PulseWidth` | a pulse — a crossing in `edge`'s direction, then the crossing back — whose duration satisfies `cmp` against `width_s` (and `width2_s` for `Within`/`Outside`) | **the closing edge**: the width is not known before it |
|
||||||
|
| `Window` | the signal enters or leaves `[min(level,level2), max(level,level2)]`; `Rising` = enters, `Falling` = leaves, `Both` = either | the crossing sample |
|
||||||
|
| `Dropout` | `level` has not been crossed for longer than `timeout_s` | the first sample at which the timeout is observed |
|
||||||
|
|
||||||
|
**Glitch is `PulseWidth` with `cmp = Less`** and is not a separate type. That is how bench
|
||||||
|
scopes spell it, and one state machine cannot disagree with itself about where a pulse ended.
|
||||||
|
The panel may label the preset "glitch".
|
||||||
|
|
||||||
|
`Dropout`'s t0 is the sample at which the expiry is *observed*, not the exact expiry instant,
|
||||||
|
for the same reason every readout in this app quotes a stored sample: t0 has to be a sample the
|
||||||
|
buffer holds, because the cut is a binary search over the time ring and the axis is pinned to
|
||||||
|
what the cut got.
|
||||||
|
|
||||||
|
**A zero-width band is refused by the panel, not special-cased in the detector.** The band test
|
||||||
|
is `v >= lo && v <= hi` and the event is a change of side, so with `level == level2` the signal
|
||||||
|
is "inside" only on exact float equality — which a crossing essentially never lands on, making
|
||||||
|
the condition one that silently never fires. That is a worse failure than a refusal, and it
|
||||||
|
cannot be fixed by special-casing the detector into an edge trigger: the two state machines
|
||||||
|
answer different questions and conflating them is the divergence this design exists to prevent.
|
||||||
|
So the second level spin is clamped to differ from the first by at least its own increment, the
|
||||||
|
panel says why, and `EventDetector` keeps one unconditional band test.
|
||||||
|
|
||||||
|
### Panel and session
|
||||||
|
|
||||||
|
The trigger panel gains a **type** combo above the existing rows, and the rows below it change
|
||||||
|
with the type: `Edge` shows the level, edge and pre-% rows it shows now; `PulseWidth` adds a
|
||||||
|
comparison combo and one or two width spins; `Window` turns the single level spin into two;
|
||||||
|
`Dropout` replaces the width with a timeout. Hold-off, pre-% and Normal/Single are common to
|
||||||
|
all four and do not move.
|
||||||
|
|
||||||
|
No plot instrument is added. The trigger level has never been drawn on the plot — it is a spin
|
||||||
|
button (`src/ui.cpp:135`, `:1604`) — so a band needs no second ruler.
|
||||||
|
|
||||||
|
`TriggerConfig` gains one `EventCondition` member; its `level` and `edge` replace the
|
||||||
|
`TriggerConfig` fields of those names, and every reader moves with them.
|
||||||
|
|
||||||
|
**The condition is not persisted, because the trigger is not persisted.** `LayoutState`
|
||||||
|
(`src/layout_store.hpp:19-63`) carries the window, rows, calibration, colours, enabled signals,
|
||||||
|
the source, the math graphs and the measurements — and no trigger field at all; the trigger is
|
||||||
|
built from `--holdoff` and the panel's own defaults on every launch. Adding persistence for it
|
||||||
|
is a separate feature nobody asked for, and a session that silently restored an armed
|
||||||
|
pulse-width condition would be a surprise rather than a convenience. The only session key this
|
||||||
|
design adds is the measurements pane's **statistics toggle**, which belongs beside the
|
||||||
|
`measures` it governs.
|
||||||
|
|
||||||
|
The level's existing default — the middle of the signal's declared range until the user edits it
|
||||||
|
(`src/ui.cpp:2016-2054`) — applies to `Window` as a band about that middle, and `level2`
|
||||||
|
defaults with it.
|
||||||
|
|
||||||
|
## 2. Measurement statistics
|
||||||
|
|
||||||
|
### What is accumulated
|
||||||
|
|
||||||
|
Not the samples: `Average` is already the mean of every visible sample. A statistic is of the
|
||||||
|
**measurement's own reported value over time** — count, mean, standard deviation, min and max of
|
||||||
|
every value that measurement has produced since the last reset.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The variation of one measurement's own value over time. Fixed size, no allocation: it is
|
||||||
|
/// folded in the pane's existing recompute pass, which must not grow a second pass.
|
||||||
|
struct MeasureStatsAccum {
|
||||||
|
std::uint64_t n = 0;
|
||||||
|
double mean = 0.0; ///< Welford's running mean
|
||||||
|
double m2 = 0.0; ///< Welford's sum of squared deviations
|
||||||
|
double lo = 0.0;
|
||||||
|
double hi = 0.0;
|
||||||
|
void add(double v) noexcept;
|
||||||
|
void reset() noexcept;
|
||||||
|
double sd() const noexcept; ///< sqrt(m2 / (n - 1)), 0 for n < 2
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Welford rather than `sum` and `sum_of_squares`: on a value with a large offset — a 3.3 V logic
|
||||||
|
level whose σ is a few millivolts — the naive form loses the variance entirely to cancellation
|
||||||
|
in `Σv²`.
|
||||||
|
|
||||||
|
### One observation per recompute
|
||||||
|
|
||||||
|
`update_measures()` is throttled deliberately (`src/ui.cpp:3141-3162`): at most one pass per
|
||||||
|
`kMeasureRefreshUs` while the window scrolls, four times that when a pass is heavy, and exactly
|
||||||
|
**once when the window settles** — the `!changed` early return at `:3153` is what makes a pinned
|
||||||
|
window recompute once rather than every frame. The accumulator takes one observation per pass,
|
||||||
|
so:
|
||||||
|
|
||||||
|
- A scrolling live view contributes on the order of ten observations a second.
|
||||||
|
- A paused view, a held event or a pinned window contributes **one**.
|
||||||
|
- `n` is therefore a count of *recomputes* — not of samples, frames or seconds. The pane labels
|
||||||
|
it `n` and its tooltip says so. Presenting it as a sample count would be a figure the user
|
||||||
|
acts on and that is false.
|
||||||
|
|
||||||
|
Two exclusions, each of which would corrupt the statistic silently:
|
||||||
|
|
||||||
|
- An **invalid** result (`--`) contributes nothing, to neither `n` nor the extremes.
|
||||||
|
- The **`measure_last_valid` fallback** (`src/ui.cpp:3363`) is **not** accumulated. It exists so
|
||||||
|
a level mark does not blink off and is by construction a repeat of an observation already
|
||||||
|
counted; folding it in pulls the mean toward whatever preceded a dropout and collapses σ.
|
||||||
|
|
||||||
|
### Reset
|
||||||
|
|
||||||
|
An explicit **Reset** button in the pane clears every accumulator. Three changes clear them
|
||||||
|
implicitly, because each one makes the values before and after a different population:
|
||||||
|
|
||||||
|
- the measurement is edited (signal, type or options);
|
||||||
|
- a CONFIG arrives — the signal list is rebuilt and a name may now resolve elsewhere;
|
||||||
|
- the display window changes — a mean over 2 s and a mean over 600 s are different statistics.
|
||||||
|
|
||||||
|
Deliberately **not** cleared by a pause, a resume, an event latch, or a pan/zoom within the
|
||||||
|
window. Each of those contributes its one settled observation.
|
||||||
|
|
||||||
|
Accumulators are not persisted. A statistic over a session that has ended is not a statistic;
|
||||||
|
the **toggle's state is** persisted.
|
||||||
|
|
||||||
|
### Display
|
||||||
|
|
||||||
|
One `Statistics` check button at the top of the measurements pane. Off — the default — the pane
|
||||||
|
is exactly as it is now. On, every row grows a second line beneath it, dimmed and smaller:
|
||||||
|
|
||||||
|
```
|
||||||
|
● ● frequency 5.0003 Hz edit
|
||||||
|
x̄ 5.0001 σ 0.0004 ▾ 4.9989 ▴ 5.0012 n 143
|
||||||
|
```
|
||||||
|
|
||||||
|
Pane-wide rather than per-measurement, so that the second lines align and are comparable, and
|
||||||
|
because the per-measurement dialog is already the busiest surface in the app.
|
||||||
|
|
||||||
|
The second line is a `GtkLabel` in a vertical box wrapping the existing horizontal row, built in
|
||||||
|
`measures_row()` (`src/ui.cpp:2495`) and shown or hidden by the toggle — the toggle must **not**
|
||||||
|
call `rebuild_measures_pane()`, which drops and recreates every label including the ones the
|
||||||
|
frame tick holds pointers to. All five numbers go through the same `format_measure()` the
|
||||||
|
value above them uses, so units and significant figures agree by construction.
|
||||||
|
|
||||||
|
## 3. Search and navigate
|
||||||
|
|
||||||
|
### What it searches, and with what
|
||||||
|
|
||||||
|
A `Search` expander at the bottom of the trigger panel, searching **the trigger's own
|
||||||
|
`EventCondition`** read straight off `TriggerConfig`. There is no second condition UI: a
|
||||||
|
condition is set up once and can either be armed to capture live or scanned for over the buffer
|
||||||
|
already held. That is what the shared evaluator buys, and it gives the search all four types for
|
||||||
|
free.
|
||||||
|
|
||||||
|
It runs on the **trigger's channel** over the **retained ring** — not the visible window, which
|
||||||
|
the user can already read by looking.
|
||||||
|
|
||||||
|
### The scan is exact, chunked, and progressive
|
||||||
|
|
||||||
|
It feeds the same `EventDetector`, sample by sample, in stream order, raw. No bucket-skipping: a
|
||||||
|
level-0 bucket whose `[lo,hi]` does not straddle the level cannot hold a crossing, so skipping
|
||||||
|
it is tempting, but the detector is a sequential state machine and synthesising its previous
|
||||||
|
value across a skipped span is precisely how the search and the live trigger would come to
|
||||||
|
disagree about where an edge was. The shared evaluator exists to make that impossible.
|
||||||
|
|
||||||
|
A raw pass over the whole ring cannot be one pass under one lock — that holds
|
||||||
|
`ScopeModel::mutex()` long enough to starve the producer. `BufferSearch` therefore advances in
|
||||||
|
**bounded chunks pumped from the frame tick**, taking and releasing the lock per chunk, the same
|
||||||
|
shape `update_measures()` uses to materialise its window in chunks. The detector's state carries
|
||||||
|
across chunks because it is sequential. The panel shows progress and a Cancel.
|
||||||
|
|
||||||
|
Two limits, both reported in the panel rather than hidden:
|
||||||
|
|
||||||
|
- **The ring keeps filling during the scan** — a pause freezes the view, not the acquisition.
|
||||||
|
Each chunk re-clamps to the ring's current base; if the base has passed the chunk's start the
|
||||||
|
scan stops and the panel says so (`searched 412 s of 600 s — the oldest data was overwritten
|
||||||
|
while scanning`). Reachable only at high rates on a small buffer.
|
||||||
|
- **The hit list is capped** at `kSearchHitsMax = 10000`, and the panel says
|
||||||
|
`stopped at 10000 matches`. A condition matching every sample is a mis-set condition; the
|
||||||
|
answer is a message, not millions of doubles.
|
||||||
|
|
||||||
|
### The hits, and navigating them
|
||||||
|
|
||||||
|
Hits are a `std::vector<double>` of stream times, each the t0 its condition defines. The panel
|
||||||
|
shows `147 matches` with `◀ prev`, `next ▶` and `3 / 147`, with key bindings while the panel has
|
||||||
|
focus.
|
||||||
|
|
||||||
|
Navigation reuses the existing pause. Jumping to hit `t` pauses if not already paused and calls
|
||||||
|
the primitive that is already there (`src/ui.cpp:2188-2199`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
|
||||||
|
if (auto snap = model->pause_view(); snap && !snap->stamps.empty())
|
||||||
|
rows->pin_pause_window(snap->stamps.front(), snap->stamps.back());
|
||||||
|
```
|
||||||
|
|
||||||
|
`w` is the current display window. `x_offset = t` puts the hit at **x = 0**, exactly where a
|
||||||
|
held event's edge sits, so the two ways of looking at an event read alike and the x-label logic
|
||||||
|
needs no new case. The pin comes off the snapshot's own stamps and never off the request: a hit
|
||||||
|
near the oldest retained sample yields a shorter cut, and pinning the request leaves an empty
|
||||||
|
left margin that a zoom-in walks into.
|
||||||
|
|
||||||
|
Resuming or clearing the search resumes and unpins exactly as the pause button does. The hit
|
||||||
|
list survives a resume — it describes data the ring may still hold — but goes stale, so the
|
||||||
|
panel marks its age, and any structural rebuild (a CONFIG, an enable/disable, a calibration
|
||||||
|
change, or a window change that moves `time_stride`) clears it outright, because those drop the
|
||||||
|
history it refers to.
|
||||||
|
|
||||||
|
### Marks
|
||||||
|
|
||||||
|
Cursors are not dynamic: `RowSet` creates them up front in fixed arrays
|
||||||
|
(`kMeasureMarks = 6`, `src/plot_host.hpp:55`) because a `Plot` cannot give one up. So this adds
|
||||||
|
`kSearchMarks = 16` cursors per row, created in the same `install()` loop
|
||||||
|
(`src/plot_host.cpp:185-188`) and taking the same read-only dashed 0.75 px stroke the measure
|
||||||
|
marks take, parked when unused.
|
||||||
|
|
||||||
|
Every frame, the hits inside the visible window are placed on them, nearest to the view's centre
|
||||||
|
first, with the current hit in a distinct colour. When more hits are in view than there are
|
||||||
|
marks the panel reads `16 of 38 marks shown — zoom in to see the rest`, which is a true
|
||||||
|
statement about the picture rather than a silent truncation. Like the measure marks they are
|
||||||
|
re-placed every frame, which is what makes them read-only: a gesture moves one and this puts it
|
||||||
|
back.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
Four probes, each scheduled by an env var, each driving the real widgets, each printing tagged
|
||||||
|
lines to stderr. Added to `docs/claude/verification.md` and `docs/claude/probe-readings.md` with
|
||||||
|
the readings a correct run gives — measured, never assumed.
|
||||||
|
|
||||||
|
**The numbers in the sample lines below, and in the pane mock-up above, show the line's *shape*
|
||||||
|
only.** None of them has been measured; nothing may copy them into a document as a reading. The
|
||||||
|
figures that go into `verification.md` come from a run.
|
||||||
|
|
||||||
|
**`UDPSCOPE_PROBE_CONDITION_MS`** — for each of `Edge`, `PulseWidth` with `Less`, `PulseWidth`
|
||||||
|
with `Greater`, `Window` and `Dropout`: set the type and its parameters through the panel, arm,
|
||||||
|
wait for the latch, print
|
||||||
|
|
||||||
|
```
|
||||||
|
[condition-probe] type=pulsewidth cmp=less width=0.0005 t0=4.3021 want=4.3020 ok=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`want` comes from the mock's injection schedule. `ok=1` is the reading.
|
||||||
|
|
||||||
|
**`UDPSCOPE_PROBE_STATS_MS`** — add a `frequency` measurement on the mock's sine, turn the
|
||||||
|
statistics toggle on, let several recomputes land, print
|
||||||
|
|
||||||
|
```
|
||||||
|
[stats-probe] n=14 mean=5.0001 sd=0.0004 lo=4.9989 hi=5.0012 toggle=1 line2=1
|
||||||
|
```
|
||||||
|
|
||||||
|
then press Reset and print it again: `n=0` is the assertion. It must also hold a pause for a
|
||||||
|
stretch and show `n` advancing by **exactly 1** across it — that is what distinguishes
|
||||||
|
accumulating per recompute from accumulating per frame. With the `measure_last_valid` fallback
|
||||||
|
wrongly accumulated, `n` climbs through an injected dropout while the value does not change and
|
||||||
|
`sd` collapses; that is the second defect this probe exists for.
|
||||||
|
|
||||||
|
**`UDPSCOPE_PROBE_SEARCH_MS`** — configure a glitch condition, scan to completion against the
|
||||||
|
injected pulse train, print
|
||||||
|
|
||||||
|
```
|
||||||
|
[search-probe] matches=7 first=1.3020 last=6.8020 scanned=8.21/8.21 s truncated=0 capped=0
|
||||||
|
[search-probe] jump 1/7 t=1.3020 axis=[-1.0000..1.0000] snap=[-1.0000..1.0000] inside=1 x0_hit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
for three successive `next` presses. `matches` is asserted against the injection count,
|
||||||
|
`inside=1` is the pause-pin invariant, `x0_hit=1` is the hit sitting at x = 0.
|
||||||
|
|
||||||
|
**The cross-check that makes the shared evaluator a claim and not a hope.** The same probe run,
|
||||||
|
same condition, same mock, **armed** instead of searched: the first latch's t0 must equal the
|
||||||
|
scan's `first` exactly.
|
||||||
|
|
||||||
|
```
|
||||||
|
[search-probe] armed t0=1.3020 vs scanned first=1.3020 agree=1
|
||||||
|
```
|
||||||
|
|
||||||
|
A divergence between the live and the offline path appears here and nowhere else.
|
||||||
|
|
||||||
|
**Costs are printed, not asserted**, because an unmeasured figure drifts: `scan cost: N chunks,
|
||||||
|
X ms total, worst chunk Y ms` — the **worst chunk** is the invariant worth watching, since it is
|
||||||
|
what the producer waited on; the total is allowed to be long. Statistics accumulation is folded
|
||||||
|
into the existing `measure cost:` line.
|
||||||
|
|
||||||
|
**Mock additions:** `--glitch-at <s>` / `--glitch-width <s>` inject a narrow pulse into a pulse
|
||||||
|
train at a known instant, and `--quiet-at <s>` stops a signal crossing its level from a known
|
||||||
|
instant. Both are permanent regression cases, like `--time-glitch-at`.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`TriggerConfig`'s `level`/`edge` move into `EventCondition`**, so every reader changes —
|
||||||
|
the panel, the probes and the status line. Mechanical, but wide, and the
|
||||||
|
mitigation is that `Edge` behaviour must be bit-identical afterwards: the existing trigger
|
||||||
|
probes are the regression test and are run before anything new is added.
|
||||||
|
- **The scan's worst chunk is a latency the producer pays.** The chunk size is the one knob, it
|
||||||
|
is picked from the measured figure, and the probe prints the worst chunk every run.
|
||||||
|
- **`kSearchMarks = 16` is a fixed cost in cursors per row** on every plot whether or not a
|
||||||
|
search is running, as `kMeasureMarks = 6` already is.
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# A late packet leaves a hole it could have filled, and the clock it would be filed under is the wrong one
|
||||||
|
|
||||||
|
Two requests, and they are one design.
|
||||||
|
|
||||||
|
The first is that a reordered packet currently reads as permanent damage. `udps_client.c:504-514`
|
||||||
|
orders the stream by the wrapping `uint32` counter and discards anything that arrives behind the
|
||||||
|
newest counter — `stats.stale_packets++; return 0` — *before* spending anything on the payload.
|
||||||
|
The samples are gone, the span they should have filled stays empty, and udpscope never learns
|
||||||
|
there was a span at all. The asked-for remedy is to leave a hole of
|
||||||
|
`<N samples> × <LAST_PACKET_ID − NEW_PACKET_ID − 1>`, mark it invalid, and fill it when the
|
||||||
|
missing packets arrive.
|
||||||
|
|
||||||
|
The second is `todo.md` item 2: a relative-time mode in which the plot's x comes only from the
|
||||||
|
declared sample rate and the packet number, with absolute stream time kept for export.
|
||||||
|
|
||||||
|
They are one design because **the counter is what makes a hole addressable**. A reserved hole has
|
||||||
|
to be a known number of samples at a known position, and a late packet has to compute which
|
||||||
|
position it belongs to. Absolute stamps cannot do either — a lost packet's stamps were never
|
||||||
|
received, so its extent in time is a guess, and the guess is what the glitch bridge at
|
||||||
|
`src/acquisition.cpp:274-292` is already making. The counter gives the extent exactly. Relative
|
||||||
|
timing is not a cosmetic preference that happens to be requested at the same time; it is the
|
||||||
|
coordinate system in which resequencing is arithmetic rather than inference.
|
||||||
|
|
||||||
|
Two facts from the existing code shape almost every decision below, and both say the work is
|
||||||
|
smaller than it looks:
|
||||||
|
|
||||||
|
- **`frame->counter` and `frame->lost` are read nowhere in udpscope.** `frame.lost` is set by the
|
||||||
|
client at `udps_client.c:560` and appears in `src/acquisition.cpp` only inside comments
|
||||||
|
(`:309`, `:331`, `:336`). The client has been reporting the exact gap width for the life of the
|
||||||
|
project and nothing has ever consumed it. Neither input needs inventing.
|
||||||
|
- **`clamp_deadline()` already exists** (`udps_client.c:1102`), documented as *"Clamps `budget` so
|
||||||
|
a pending deadline is not slept through"* and already used for the keepalive and the silence
|
||||||
|
timeout. The reorder deadline is a third call to it.
|
||||||
|
|
||||||
|
## What was decided, and the constraint that decided it
|
||||||
|
|
||||||
|
The latency budget is **20 ms**, maximum. That one number settles the shape of the whole receive
|
||||||
|
path, because it is a *time* budget and not a packet count: at 1000 pkt/s it is twenty packet
|
||||||
|
spans of reorder depth, at 10 pkt/s it is a fifth of one. A hold policy counted in packets would
|
||||||
|
either be useless at high rates or add 400 ms at low ones. A policy counted in wall time works at
|
||||||
|
every rate, because network reorder is measured in milliseconds regardless of how fast the
|
||||||
|
producer sends.
|
||||||
|
|
||||||
|
It also means a *contiguous* arrival must be released immediately. The 20 ms is spent only when
|
||||||
|
there is a gap to wait on, never on the common case.
|
||||||
|
|
||||||
|
Past the window, a committed hole is still **backfilled for values only**. Math channels and the
|
||||||
|
trigger are not replayed. A math channel takes NaN in and gives NaN out, so after a repair the
|
||||||
|
raw trace is whole and the math trace derived from it keeps its gap. That is a deliberate,
|
||||||
|
documented state, not an oversight: the display then says *"this region arrived too late to be
|
||||||
|
part of the computation"*, which is true and is cheaper than any mechanism that could make it
|
||||||
|
false.
|
||||||
|
|
||||||
|
Resequencing and hole reservation run in **both** time modes. In relative mode a hole's stamps
|
||||||
|
are exact; in stream mode they are the declared cadence continued from the last good stamp, which
|
||||||
|
is the existing bridge mechanism reused. Relative timing is therefore an independent toggle a user
|
||||||
|
flips for jitter, not a prerequisite for recovery.
|
||||||
|
|
||||||
|
A hole is surfaced two ways: **status-bar counters** and a **shaded band** over the invalid span.
|
||||||
|
A NaN run already breaks the polyline, but a one-packet hole at a 10 s window is sub-pixel, and a
|
||||||
|
gap nobody can see is not a report.
|
||||||
|
|
||||||
|
## Relative timing
|
||||||
|
|
||||||
|
`build_stamps()` today picks its clock *by inspection*: `time_source_` latches 1 for the declared
|
||||||
|
time signal or 2 for the arrival clock and never flips back (`src/acquisition.cpp:357-367`).
|
||||||
|
Relative timing is not a third thing to infer. It is a user-selected mode that bypasses the
|
||||||
|
inference: `--relative-time` on the command line, a toggle in the UI, persisted in the session
|
||||||
|
file.
|
||||||
|
|
||||||
|
### The stamp
|
||||||
|
|
||||||
|
```
|
||||||
|
stamps_[k] = double(slot_base_ + k) / rate
|
||||||
|
```
|
||||||
|
|
||||||
|
`slot_base_` is a running `u64` of samples seen. Each packet the counter reports missing advances
|
||||||
|
it by `batch`, **before** the frame's own samples are stamped; the delivered frame then advances it
|
||||||
|
by its own `n`. The order is what puts the hole ahead of the data that revealed it.
|
||||||
|
|
||||||
|
This is the requested `counter × batch + sample_index` with the origin left at counter zero, but
|
||||||
|
**accumulated rather than multiplied**, and the difference earns three things:
|
||||||
|
|
||||||
|
- `batch` is then only ever needed to size what is *absent*. That is exactly the
|
||||||
|
`<n_samples_per_frame>` of the original formula, and it is the only place the value is load
|
||||||
|
bearing.
|
||||||
|
- The `uint32` wrap costs nothing. The counter is consumed only as a signed delta — the same
|
||||||
|
arithmetic `udps_client.c:505` already performs — so no unwrapping bookkeeping is needed.
|
||||||
|
- A frame whose `n` disagrees with the established `batch` cannot silently shift the whole
|
||||||
|
timeline, because the timeline is built from the `n` values actually seen.
|
||||||
|
|
||||||
|
`batch` is established from the first DATA frame and held for the life of the stream. A later
|
||||||
|
frame disagreeing is a genuine inconsistency in the producer, not a loss, so it warns once rather
|
||||||
|
than re-deriving.
|
||||||
|
|
||||||
|
### The rate is the declared one, and only the declared one
|
||||||
|
|
||||||
|
`build_stamps()` currently overrides a declared cadence with the arrival-measured one when the two
|
||||||
|
disagree by more than ±25% (`src/acquisition.cpp:152-158`), because a CONFIG declaring 1 MS/s
|
||||||
|
while delivering a hundred samples every fifth of a second reads as a staircase in the x column.
|
||||||
|
Relative mode must **not** do this. Discarding arrival timing is the entire point of the mode. The
|
||||||
|
override stays live in stream mode and is skipped in relative mode.
|
||||||
|
|
||||||
|
### The constant offset is already absorbed
|
||||||
|
|
||||||
|
`src/acquisition.cpp:555` stamps `rel = stamps_[k] - epoch`, and the `t0 =` caption names the
|
||||||
|
epoch. A counter that happens to read 500 000 on connect therefore shows up as an epoch value, not
|
||||||
|
as a 50 000 s axis. No origin subtraction is needed in the formula itself.
|
||||||
|
|
||||||
|
### The x axis needs no change at all
|
||||||
|
|
||||||
|
`RowSet::update_time_labels()` already goes to a relative label form on the tick step against the
|
||||||
|
label resolution at the axis' magnitude, with the `t0 =` caption naming the absolute anchor
|
||||||
|
(`docs/claude/invariants-time-axis-and-window.md:34`). "Counting backwards, like it is now" is
|
||||||
|
what that already does. Relative mode changes *where a stamp comes from* and nothing in the label
|
||||||
|
layer.
|
||||||
|
|
||||||
|
### What relative mode makes unreachable
|
||||||
|
|
||||||
|
The degeneracy guard, the implausible-jump test, `kFailureLimit`, the bridge and the monotonic
|
||||||
|
clamp are all dead in relative mode: the stamps are monotone by construction, so there is no bad
|
||||||
|
stamp to detect and nothing to clamp. (`kGlitchPackets` itself survives — not as the threshold of
|
||||||
|
that test, which is unreachable, but as the reservation cap below, where its value and its
|
||||||
|
reasoning carry over unchanged.)
|
||||||
|
|
||||||
|
That is not tidiness, it is a fix. Measured last session: a loss burst past `kGlitchPackets` (256)
|
||||||
|
is misread as a producer clock glitch; each bridge then advances the anchor by *one* packet span
|
||||||
|
while the true timeline is 300 ahead, so the next four packets fail too, `kFailureLimit` trips,
|
||||||
|
and the whole stream is thrown onto the arrival clock — the exact failure the bridge was added to
|
||||||
|
prevent (`--drop-burst 300` reads
|
||||||
|
`jumped implausibly far ahead ... (1.299..1.29999 against a previous 0.99899)` then
|
||||||
|
`timing samples by arrival instead`). In relative mode a 300-packet gap is a known quantity and
|
||||||
|
cannot be misdiagnosed.
|
||||||
|
|
||||||
|
### Absolute time for export
|
||||||
|
|
||||||
|
`todo.md` requires absolute stream time to remain available for export. There is exactly one f64
|
||||||
|
time ring per `BufferStore`, shared by every channel of that buffer, and relative mode takes it.
|
||||||
|
Export therefore needs a **second** f64 ring carrying the absolute stamp under the same group-mean
|
||||||
|
rule, written in **relative mode only** and read by export and by nothing else — eight bytes per
|
||||||
|
stored slot. The absolute ring is not kept unconditionally; it is budgeted along with the main
|
||||||
|
ring and the value rings, which is why it halves the retained span rather than costing a
|
||||||
|
proportional fraction (see SPECS.md for the measured figures and the power-of-two explanation).
|
||||||
|
|
||||||
|
This cost is paid rather than avoided. Without it, selecting relative mode silently degrades the
|
||||||
|
one output `todo.md` says must keep absolute time.
|
||||||
|
|
||||||
|
The measured history cost: **83.886 s** in stream mode and **41.943 s** in relative mode, at
|
||||||
|
100 kS/s, 4 channels, `--buffer-mb 200`. The plan's ~22% figure and the per-byte 75% figure are
|
||||||
|
both wrong because `Partition::time_capacity` is floored to a power of two.
|
||||||
|
|
||||||
|
## The receive path
|
||||||
|
|
||||||
|
### In the client: a reorder ring of reassembled payloads
|
||||||
|
|
||||||
|
Every `udps_frame_t` sets `frame.signals = c->sigs; frame.values = c->vals`
|
||||||
|
(`udps_client.c:566-567`) — one client-owned decode buffer, reused for every packet. A frame is
|
||||||
|
therefore valid only inside the callback, and holding one requires a copy *wherever* the holding
|
||||||
|
happens. The client has no advantage there.
|
||||||
|
|
||||||
|
It has a different one: it still holds the **raw datagram**. Resequencing undecoded wire bytes is
|
||||||
|
strictly cheaper than resequencing decoded doubles — an `f32` or `i16` payload is two to four
|
||||||
|
times smaller than what `c->valbuf` would hold — and the bytes exist nowhere else. That is why
|
||||||
|
this sits in the client and not in `Acquisition`.
|
||||||
|
|
||||||
|
The hook is the `decode_data()` call site at `udps_client.c:599`: after fragment reassembly, which
|
||||||
|
already tolerates arrival order through the slot table, and before decode. Each held entry carries
|
||||||
|
the payload bytes, their length, the counter and `recv_time`.
|
||||||
|
|
||||||
|
The policy, against `next_counter = last_delivered + 1`:
|
||||||
|
|
||||||
|
| arrival | action | added latency |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `== next_counter` | decode and deliver now, then drain held entries that became contiguous | **none** |
|
||||||
|
| `> next_counter` | copy into the ring; set the gap deadline to `now + 20 ms` if not already set | the newer packet waits |
|
||||||
|
| `< next_counter` | the gap is already committed — decode and deliver, flagged **late** | n/a |
|
||||||
|
| deadline fires | give up on the missing counters, advance `next_counter` to the lowest held entry, drain in order | capped at 20 ms |
|
||||||
|
|
||||||
|
The last row is where `stats.stale_packets++; return 0` at `:506-509` is replaced: a late packet
|
||||||
|
is delivered flagged instead of discarded, which is the single change that makes backfill possible
|
||||||
|
at all.
|
||||||
|
|
||||||
|
The deadline is served by a third `clamp_deadline()` call on the `select()` budget
|
||||||
|
(`udps_client.c:1145-1152`). Without it, a held packet on a quiet stream would wait for the next
|
||||||
|
arrival — 100 ms at 10 pkt/s — and blow the budget.
|
||||||
|
|
||||||
|
Two bounds on the ring, because 20 ms is a different number of packets at every rate: a fixed
|
||||||
|
entry count and a fixed byte budget. Overflow commits the gap early rather than growing, and
|
||||||
|
increments its own counter, so an undersized window is visible rather than silent.
|
||||||
|
|
||||||
|
The window itself is `udps_config_t::reorder_window_s`, defaulting to `0.020`, along
|
||||||
|
`recv_buffer_bytes` and `silence_timeout_s` (`udps_client.c:982`). Zero disables resequencing and
|
||||||
|
restores today's discard-on-late behaviour exactly, which is what makes the change bisectable
|
||||||
|
against a reported regression. The policy value is udpscope's; the mechanism is the client's.
|
||||||
|
|
||||||
|
### In `Acquisition`: reservation
|
||||||
|
|
||||||
|
On a frame with `lost > 0`, before pushing the frame's own samples, run `lost × batch` NaN samples
|
||||||
|
through the **existing** per-sample loop at `src/acquisition.cpp:553`.
|
||||||
|
|
||||||
|
That reuse is the design. `tick_` advances, so `tick % g == g - 1` keeps firing on the same phase
|
||||||
|
and the decimation grid does not shift; the group mean still lands on the mean stamp; and
|
||||||
|
`MinMaxPyramid::append` sees NaN, which its existing non-finite contract already handles as
|
||||||
|
*advances the positional count, excluded from every aggregate*. Nothing bespoke is needed for
|
||||||
|
decimation, stamping or the pyramid.
|
||||||
|
|
||||||
|
Stamps across the hole come from the active time source — exact in relative mode, the declared
|
||||||
|
cadence continued from the last good stamp in stream mode, which is the bridge at `:274-292`.
|
||||||
|
|
||||||
|
Math channels take NaN and give NaN, so a math trace keeps its gap. The trigger engine's
|
||||||
|
comparisons are false on NaN, so it cannot latch inside a hole; this is asserted by the probe
|
||||||
|
rather than assumed.
|
||||||
|
|
||||||
|
**Reservation is capped at 256 missing packets**, reusing `kGlitchPackets` and its existing
|
||||||
|
reasoning verbatim — *256 packet spans in one go is an outage, not loss*. Past the cap there is no
|
||||||
|
reservation: log once and continue as today. That cap is also what stops a ten-minute outage from
|
||||||
|
pushing billions of NaN samples through the ring and wiping all history to make room for a gap.
|
||||||
|
|
||||||
|
## Backfill
|
||||||
|
|
||||||
|
`plot::data::RingBuffer` and `MinMaxPyramid` are both strictly append-only today. Backfill needs
|
||||||
|
an amend in each, and the two belong on different threads.
|
||||||
|
|
||||||
|
`plot_host.hpp:183` calls `acquire(ScopeModel&)` from the frame path, so the **pyramid is mutated
|
||||||
|
only on the GTK main thread**, once per frame: the producer pushes into the ring and the main
|
||||||
|
thread folds what was pushed into the pyramid. That split carries straight over to the amend.
|
||||||
|
|
||||||
|
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)`, bounds-checked against
|
||||||
|
`[produced_ - capacity, produced_)`, a single relaxed store. The same race class as a push, and
|
||||||
|
an `f32` cannot tear. Measurements and export read the ring directly, so they see a repair
|
||||||
|
immediately.
|
||||||
|
- **Pyramid amend — main thread, inside `acquire()`.** The producer appends amended `(slot, value)`
|
||||||
|
pairs to a plain `std::vector<Amend>` member; `ChannelBuffer::acquire()` drains it **after** its
|
||||||
|
append pass, so every amended index is already published. No SPSC queue is needed: `acquire()` is
|
||||||
|
called under `model.mutex()` and the producer holds that same mutex for all of `on_data`, so the
|
||||||
|
vector is correctly serialised without lock-free machinery.
|
||||||
|
|
||||||
|
The thread split is not a stylistic choice. A bucket is 32 bytes and is not atomic, so amending it
|
||||||
|
from the producer would let a reader see a half-updated bucket whose `sum` does not match its
|
||||||
|
`count` — a wrong mean from a structure whose readers trust `count == 0` to mean *absent*. On the
|
||||||
|
main thread that cannot happen and no seqlock is needed.
|
||||||
|
|
||||||
|
`MinMaxPyramid::amend(index, v)` folds `v` into every covering bucket: at `count == 0`,
|
||||||
|
`min = max = v`, `sum = v`, `sumsq = v²`, `count = 1`; otherwise widen `min`/`max` and accumulate
|
||||||
|
`sum`/`sumsq`, `++count`. Its documented precondition is **non-finite → finite only**, which is
|
||||||
|
what makes it purely additive and O(log n) with no rebuild. An index already dropped by
|
||||||
|
`drop_before` is a no-op.
|
||||||
|
|
||||||
|
### The time ring is never amended
|
||||||
|
|
||||||
|
The hole was pre-stamped at reservation, so a backfill changes values only. `neighbours()` keeps
|
||||||
|
the monotonic array its binary search requires and `window_x0_` stays valid. This is the reason
|
||||||
|
reservation pre-stamps rather than leaving the ring short.
|
||||||
|
|
||||||
|
### Addressing a late packet
|
||||||
|
|
||||||
|
Reservation records `(counter_first, counter_last, tick_start, slot_start, slot_count)` in a
|
||||||
|
bounded side table. A late frame's counter indexes straight into it:
|
||||||
|
|
||||||
|
```
|
||||||
|
tick = tick_start + (counter - counter_first) * batch
|
||||||
|
```
|
||||||
|
|
||||||
|
Only slots lying **wholly** inside the arrived packet's tick range are amended. A slot straddling
|
||||||
|
a hole edge went NaN when its accumulator took a NaN, and its real members were never retained, so
|
||||||
|
it stays NaN. A displayed hole is therefore at most one slot wider per edge than the true loss —
|
||||||
|
invisible at any hole size worth repairing, and it makes a boundary slot need no special case.
|
||||||
|
|
||||||
|
The side table is bounded by the 256-packet reservation cap and a maximum entry count; it is also
|
||||||
|
the source the shaded band reads.
|
||||||
|
|
||||||
|
## Surfacing
|
||||||
|
|
||||||
|
`StreamStats` gains `late_resequenced`, `late_committed` (gave up past 20 ms, or overflowed the
|
||||||
|
reorder ring) and `late_backfilled`, and finally exposes `stale_packets` — which the client has
|
||||||
|
counted since the beginning and which `src/status_text.cpp:70` has never shown, so an inversion
|
||||||
|
today has to be inferred from `packets` and `frames` diverging. All four sit in the status bar's
|
||||||
|
second tier.
|
||||||
|
|
||||||
|
The band is a new `Plot::set_invalid_spans()` in `deps/plotter`, drawn as a tinted quad behind the
|
||||||
|
trace. Unlike the `t0` caption this *is* data-anchored — it must pan and zoom with the data it
|
||||||
|
marks — so it is annotation-class, not chrome. The invariant it must respect is that it reserves
|
||||||
|
no layout space and cannot be picked, so a hole cannot move the layout the labels were measured
|
||||||
|
into.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
udpscope has no unit test suite; the only automated check is the application's own headless
|
||||||
|
self-check (`CLAUDE.md`). This adds exit-dump lines and a `UDPSCOPE_PROBE_RESEQ_MS` probe,
|
||||||
|
asserting: a reorder inside the window is resequenced with no hole; a reorder past the window
|
||||||
|
leaves a hole and then backfills it; the trigger does not latch inside a hole; a 300-packet burst
|
||||||
|
hits the cap and does not throw the stream onto the arrival clock; and relative mode's stamps are
|
||||||
|
monotone with the declared spacing.
|
||||||
|
|
||||||
|
Runs are serial and short, against the two standing baselines: four build warnings on a clean
|
||||||
|
build (two on an incremental build) and zero `Gtk-CRITICAL`. Batching several `xvfb` runs back to
|
||||||
|
back starves the mock past its 2 s `silence_timeout_s` and reads as a reconnect, which is a
|
||||||
|
measurement artefact and not a defect (`docs/claude/verification.md`).
|
||||||
|
|
||||||
|
**One gap in the mock has to be closed first.** `--reorder-every` releases the held packet
|
||||||
|
immediately after the one that overtook it, so it is ~0 ms late and would always land inside the
|
||||||
|
20 ms window. That exercises resequencing but leaves the **backfill path untestable**. The mock
|
||||||
|
needs `--reorder-delay-ms` so a packet can be released deliberately past the window.
|
||||||
|
|
||||||
|
## Documentation this lands against
|
||||||
|
|
||||||
|
- `docs/claude/invariants-data-path.md` — append-only stops being the whole truth; the amend's
|
||||||
|
precondition and its thread belong here.
|
||||||
|
- `docs/claude/invariants-plot-and-layout.md` — the band, and the layout space it must not take.
|
||||||
|
- `docs/claude/invariants-trigger-and-pinning.md` — the trigger does not latch inside a hole.
|
||||||
|
- `docs/claude/verification.md` — the new probe and dump lines, and `--reorder-delay-ms`.
|
||||||
|
- `docs/claude/build-and-run.md` — the vendored `udpsclient` diff: the reorder ring, the late
|
||||||
|
delivery replacing the discard, and the third `clamp_deadline()`.
|
||||||
|
- `README.md` and a `SPECS.md` deviation note for relative-time mode.
|
||||||
|
|
||||||
|
## Deliberately not in scope
|
||||||
|
|
||||||
|
- **Replaying math channels or the trigger over a backfilled region.** Decided against above.
|
||||||
|
- **Interpolating across an unrepaired hole.** A hole is reported, not invented. There is no
|
||||||
|
interpolation anywhere in the data path today and this does not add the first.
|
||||||
|
- **Making relative mode the default.** It is a toggle. The default stays the declared time
|
||||||
|
signal, so no existing session or run changes what it shows.
|
||||||
|
- **The `(min, max, mean, rms)` gather mode** that would fix the separate solid-band degeneration
|
||||||
|
at wide time windows. Related to the pyramid, unrelated to this; still open.
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# A square wave's riser is sometimes drawn and sometimes not, and the trace is thinner than the library's own default
|
||||||
|
|
||||||
|
The report is that a vertical line — the rising or falling edge of `square` in
|
||||||
|
`tools/mock_udpstreamer.py` — is too thin to see, and that it *glitches in and out of
|
||||||
|
existence*. Those are two complaints, not one, and they have different causes. The second is a
|
||||||
|
defect with a derivable mechanism and a single line of code behind it. The first is a styling
|
||||||
|
choice this app made and then never revisited: udpscope asks for a trace **thinner** than
|
||||||
|
`deps/plotter`'s own default.
|
||||||
|
|
||||||
|
The three remedies named in `todo.md` — grade with more strength, thicken the lines, enlarge the
|
||||||
|
sample dots — are all taken. They are not, on their own, enough: the glitch is a row being
|
||||||
|
dropped *before* any of those knobs is consulted, so a thicker, brighter trace would glitch in
|
||||||
|
and out at a thicker, brighter width.
|
||||||
|
|
||||||
|
## The defect: `lit_floor` decides existence, and `intensity_floor` only decides alpha
|
||||||
|
|
||||||
|
`Renderer::stage_trace_density()` (`deps/plotter/src/render/renderer.cpp`) accumulates one
|
||||||
|
column's dwell into `column_rows_`, where **a segment deposits one unit spread over the rows it
|
||||||
|
spans** — `w = 1 / (r1 - r0 + 1)`. It then decides which rows are inked at all:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const f32 lit_floor = 0.01f * peak;
|
||||||
|
...
|
||||||
|
const bool lit = r < rows && cover > lit_floor;
|
||||||
|
```
|
||||||
|
|
||||||
|
`peak` is the column's own maximum cover. That makes the existence test a *share of the
|
||||||
|
brightest row in the same column*, and for a square wave the brightest row in a column is a
|
||||||
|
flat level, whose segments each deposit a full 1.0 into one row.
|
||||||
|
|
||||||
|
Take a column that holds both a flat level and the edge. At ~105 samples a column the gather is
|
||||||
|
on raw samples, so the column holds ~105 segments: all but one are flat and deposit a full 1.0
|
||||||
|
into one row, while the riser is a single segment spread over the whole axis and deposits
|
||||||
|
`1/312 = 0.0032` into each of 312 rows. `peak` is then ~50 and `lit_floor` ~0.5 — **over a
|
||||||
|
hundred times the riser's own deposit**. Every row of the riser is unlit. The connector at the
|
||||||
|
bottom of `emit_column()` does not rescue it either: the top run and the bottom run are both lit,
|
||||||
|
so the column's `band_lo…band_hi` already spans the full height, neither gap test fires, and no
|
||||||
|
connector is staged. The column is drawn as a bright mark at the top and a bright mark at the
|
||||||
|
bottom with nothing between them.
|
||||||
|
|
||||||
|
In the phase where the riser lands in a column *by itself*, `peak` is the riser's own deposit,
|
||||||
|
the floor is a hundredth of it, every row clears it, and the riser is drawn full height. Which
|
||||||
|
case a frame lands in depends on the phase of the bucket grid against the pixel grid, and the
|
||||||
|
trace scrolls, so the phase sweeps. That is the whole of "glitches in and out of existence" — not
|
||||||
|
a redraw race and not a data gap.
|
||||||
|
|
||||||
|
### Measured, before writing any of this down
|
||||||
|
|
||||||
|
A true square wave (period 2098 samples, so ~20 columns a period), 60 000 x-units over the 572 px
|
||||||
|
axis of `deps/plotter`'s own headless EGL harness — 104.8 samples a column — drawn twice, once
|
||||||
|
with `intensity_grading` off and once on, with the pixels read back both times over the **same**
|
||||||
|
axes rect. The reading counts the columns the *ungraded* frame inked as a full-amplitude band
|
||||||
|
(the riser columns) and then asks how many of exactly those rows the graded frame left dark:
|
||||||
|
|
||||||
|
| | riser columns | inked rows over them | intact | gutted | worst dark run | segments |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| ungraded | 116 | 35 884 | — | — | — | 627 |
|
||||||
|
| graded, before | 116 | **2 916** | 10 | **106** | **311 of 312 rows** | 684 |
|
||||||
|
| graded, after | 116 | 35 572 | 115 | 1 | — | 732 |
|
||||||
|
|
||||||
|
So the graded path was drawing **8 %** of the ungraded path's ink over the riser columns, and 106
|
||||||
|
of 116 of them were gutted outright while 10 survived — the 10 being the phase that put a riser
|
||||||
|
in a column of its own, which is the flicker, measured. The fix restores 99.1 % of the ink and
|
||||||
|
costs 48 segments on a 572 px axis.
|
||||||
|
|
||||||
|
Two notes on that table, both of which the committed test must carry rather than smooth over:
|
||||||
|
|
||||||
|
- **The one column still gutted after the fix is column 11**, the second column of the window's
|
||||||
|
first riser. The ungraded path inks *two* full-height columns per riser (its per-column min/max
|
||||||
|
plus the connectivity extension, and the gathered window's slack sample) where the graded path
|
||||||
|
inks one, which is also why 116 riser columns appear for ~58 risers. The graded path is the
|
||||||
|
more precise of the two here; the test must assert against the riser's own column rather than
|
||||||
|
against every column the flat path happened to fill.
|
||||||
|
- **The existing grading test is unchanged by the fix** — `a_dense_trace_can_be_graded_by_dwell`
|
||||||
|
still reads `flat edge=191 middle=191` against `graded edge=191 middle=131` at `seg=8303`, the
|
||||||
|
documented figures to the digit, and all 376 cases pass. The fix only reaches rows that were
|
||||||
|
below the share floor, which is what makes it a correction and not a restyling.
|
||||||
|
|
||||||
|
The app-level A/B this design originally proposed — `UDPSCOPE_NO_PHOSPHOR=1` against the mock's
|
||||||
|
`square` — was dropped in favour of the above: udpscope has no probe that can see a dropped row,
|
||||||
|
so the app-level run could only have shown that the picture differed, while the library harness
|
||||||
|
reads the pixels and names the rows. The mechanism is measured at the layer it lives in.
|
||||||
|
|
||||||
|
`Style::intensity_floor`'s own documentation already promised what `lit_floor` broke:
|
||||||
|
|
||||||
|
> Not 0: a column the signal merely passed through is still a column the signal *reached*, and a
|
||||||
|
> riser dimmed out of existence is a square wave drawn as two disconnected lines.
|
||||||
|
|
||||||
|
`intensity_floor` guards a lit row's **alpha**. Nothing guarded its **existence**, and the two
|
||||||
|
floors were never reconciled.
|
||||||
|
|
||||||
|
### The fix, already applied and measured
|
||||||
|
|
||||||
|
`lit_floor` may not exceed the smallest deposit the column actually made, so that **a row one
|
||||||
|
segment covers is always lit**. The column's minimum `w` is tracked as the deposits are written
|
||||||
|
— one scalar, reset by `emit_column()`, no allocation, one `min` added to the per-segment path —
|
||||||
|
and the floor becomes:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
|
||||||
|
```
|
||||||
|
|
||||||
|
The share term stays: it is what stops a row inking on the running-sum reconstruction's residue
|
||||||
|
alone. The second term is what makes the test *about a measurement* rather than about a ratio to
|
||||||
|
an unrelated row. The half is slack against that reconstruction, not a tuning knob.
|
||||||
|
|
||||||
|
This changes no geometry bound — the runs are still maximal stretches of equal tier, O(rows) per
|
||||||
|
column at worst — and it touches neither the gather level, the pyramid, nor
|
||||||
|
`stage_trace_columns()`. It is in the working tree (`deps/plotter/src/render/renderer.cpp`) with
|
||||||
|
the figures above in the comment at its site, which is what Task 2 below is reduced to reviewing.
|
||||||
|
|
||||||
|
## The second defect, found by testing the first: grading was gated on the column reduction
|
||||||
|
|
||||||
|
The `lit_floor` fix was built and run against the real app, and the report back was that the
|
||||||
|
edges are now always visible — and that a new thing is wrong: *"when I zoom in on an edge the
|
||||||
|
brightness increases a lot compared to when I am looking at the whole waveform."* Clarified: the
|
||||||
|
dimming itself is wanted, **a discontinuity should never be drawn as brightly as a flat top**, at
|
||||||
|
any zoom. So the complaint is not about the amount of dimming but about its *inconsistency*.
|
||||||
|
|
||||||
|
Measured in the same harness, the riser's own ink at mid-height (the rows neither flat level can
|
||||||
|
reach) against the flat level's 191, over a zoom sweep from 101 samples a pixel down to 0.01:
|
||||||
|
|
||||||
|
| samples/pixel | 101 | 33.7 | 10.1 | 3.4 | 1.01 | 0.34 | 0.10 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| riser ink, before | 71 | 71 | 71 | 71 | **191** | **191** | **191** |
|
||||||
|
| riser ink, after | 71 | 71 | 71 | 71 | 71 | 71 | 68 |
|
||||||
|
|
||||||
|
A 2.7× jump in a single step, at `dense_threshold_spp = 1.5`. The cause is a coupling, not a
|
||||||
|
constant: grading is staged *inside* `stage_trace_density()`, which only runs when the column
|
||||||
|
reduction runs, and the reduction is gated on two gathered points per pixel column. That gate is
|
||||||
|
a **density condition whose only purpose is removing the moiré beat** between the bucket pitch
|
||||||
|
and the pixel pitch. Dwell has nothing to do with that beat, so zooming past the threshold
|
||||||
|
switched the whole phosphor model off — and switched it off exactly where the user is looking at
|
||||||
|
an edge closely, which is why it was reported as the edge *lighting up*.
|
||||||
|
|
||||||
|
### The fix: grade the polyline by each segment's slope
|
||||||
|
|
||||||
|
`Renderer::stage_trace_slopes()` stages the sparse polyline into the same `intensity_steps`
|
||||||
|
batches, differing from the dense path only where it must:
|
||||||
|
|
||||||
|
- **The measure is `1 / (1 + |Δy_screen|)`** — dwell per row for a single segment. The `1 +` is
|
||||||
|
not a division guard: a flat segment, span 0 and the commonest case in a sparse window, must be
|
||||||
|
the *brightest* tier, and this makes the measure dimensionless and **absolute**. There is no
|
||||||
|
per-column peak down here to normalise against, and that is an improvement — a sparse frame's
|
||||||
|
tiers mean the same thing from frame to frame, so panning does not reshade the trace.
|
||||||
|
- **No gather mode is excluded.** `grade` is not reused as the gate: it requires `Envelope`,
|
||||||
|
because turning a bucket's min/max into a band answers a different question from the one a
|
||||||
|
`Nearest` or `Mean` host asked. A segment's slope is a property of the polyline itself.
|
||||||
|
- **The geometry is bit-for-bit `stage_xy()`'s**, same `snap = false`; only the batch differs. A
|
||||||
|
segment with one non-finite end is dropped rather than clamped, which is the conclusion
|
||||||
|
`add_polyline_xy()` reaches too.
|
||||||
|
- **`FrameStats::slope_traces` is disjoint from `column_traces`**, so a host asserting
|
||||||
|
`graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
|
||||||
|
Tier distribution sanity-checked by hand against the defaults (4 steps, gamma 0.45, floor 0.3):
|
||||||
|
span 0 → tier 3, span 1 → 2, span 3 → 2, span 10 → 1, span 312 → 0. A gently sloping sparse sine
|
||||||
|
therefore stays near the top tier and is not dimmed into invisibility, which was the risk.
|
||||||
|
|
||||||
|
### One false alarm, recorded because the metric was the bug
|
||||||
|
|
||||||
|
A first reading of this sweep showed the *frame's brightest pixel* falling from 191 to 167–172 on
|
||||||
|
the sparse path whenever grading was on, which the arithmetic said was impossible (a flat segment
|
||||||
|
is tier 3, alpha exactly 1.0). It was the measurement: the y range was −1…1, so both flat levels
|
||||||
|
sat **on** the boundary rows and were half clipped, and the 191 the ungraded frame reported came
|
||||||
|
from pixels near the riser where two segments overlap and double-composite — which grading splits
|
||||||
|
across tiers, so they no longer saturate. With the range at −2…2 and the level read well clear of
|
||||||
|
the riser, the level is 191 with grading on or off at every zoom. No regression existed. The
|
||||||
|
committed test uses −2…2 and reads the level and the riser as two separate figures for this
|
||||||
|
reason, stated at the site.
|
||||||
|
|
||||||
|
## The styling: the host asked for a thinner trace than the library's default
|
||||||
|
|
||||||
|
`RowSet::install()` (`src/plot_host.cpp:175`) builds every trace with `1.2f`. The library's
|
||||||
|
`Trace::width` default is `1.5f`, and `Style::crosshair_width`'s documentation treats 1.5 as *the
|
||||||
|
trace width a crosshair must read as thinner than*. So the app inverted its own stroke hierarchy
|
||||||
|
at the only place it states it, and `1.2f` is also, in both staging paths, the **minimum span a
|
||||||
|
short run is stroked at** — the `if (hi - lo < width)` widen — so it sets the floor on how
|
||||||
|
visible a one-row run can ever be.
|
||||||
|
|
||||||
|
`marker_px` (3.0) and `marker_below_samples` (50) are library defaults udpscope never restated;
|
||||||
|
`ui.cpp`'s one-shot style statement sets `intensity_grading` and nothing else.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- `deps/plotter`: the `lit_floor` correction in `stage_trace_density()`, a library test that fails
|
||||||
|
without it, and the `PLOT_TEST_FILTER` the measurement needed.
|
||||||
|
- `deps/plotter`: `Renderer::stage_trace_slopes()` — grading for the sparse polyline — plus
|
||||||
|
`FrameStats::slope_traces` and a library test that fails without it.
|
||||||
|
- `deps/plotter`: `Style::line_aa_px` and the grading constants remain *fields* — no new knob is
|
||||||
|
added, and `intensity_grading` stays **one** switch covering both rasterisation paths. The one
|
||||||
|
new field considered and rejected is named under **Out**.
|
||||||
|
- udpscope: the trace width at `src/plot_host.cpp:175`, and the style one-shot in `ui.cpp`
|
||||||
|
extended to state `marker_px`, `marker_below_samples`, `intensity_floor` and `line_aa_px`
|
||||||
|
rather than inheriting them.
|
||||||
|
- One new probe over the mock's `square`, and the `frame path:` reading extended to report the
|
||||||
|
dropped-row case so a regression is visible rather than merely ugly.
|
||||||
|
- `docs/claude/invariants-rendering.md`, `README.md`, and upstream's
|
||||||
|
`docs/data-and-performance.md` + `README.md`.
|
||||||
|
- `SPECS.md` is not changed.
|
||||||
|
|
||||||
|
**Out, and deliberately:**
|
||||||
|
|
||||||
|
- **A `Style` field for "always light a riser".** The dropped row is a bug in a test, not a
|
||||||
|
policy a host should get to choose; a flag would preserve the broken behaviour as an option and
|
||||||
|
oblige every host to know about it.
|
||||||
|
- **Changing the connector's tier.** Drawing the inferred bridge at the faintest step is a
|
||||||
|
deliberate honesty claim ("this is not a place the signal was measured to dwell"). Once
|
||||||
|
`lit_floor` is fixed the riser is *real* ink at its own tier and the connector is back to
|
||||||
|
bridging only genuine disjointness, which is what it was written for.
|
||||||
|
- **Per-trace or per-row width.** One width for every trace in the app, as now.
|
||||||
|
- **A separate switch for sparse grading.** Two switches would let a host reproduce the very
|
||||||
|
inconsistency this fixes, and the user's own framing settles it: the dimming is wanted *at all
|
||||||
|
zooms*, so it is one look and not two.
|
||||||
|
- **Normalising the sparse tiers against the frame.** The dense path normalises per column
|
||||||
|
because a frame-wide scale needs the whole image before anything can be drawn; the sparse
|
||||||
|
measure needs no normalisation at all, and leaving it absolute is what makes a pan stop
|
||||||
|
reshading the trace. Matching the dense path here would be matching a compromise.
|
||||||
|
- **A user-facing preference for any of these values.** `todo.md` item 9 owns the preferences
|
||||||
|
dialog; this work states defaults in code and leaves hooks for nothing.
|
||||||
|
- No change to the gather level, `MinMaxPyramid`, `dense_threshold_spp`,
|
||||||
|
`intensity_sub_columns`, the decimation plan, or the two-thread split.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
These bind every task below and are not restated in each one.
|
||||||
|
|
||||||
|
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||||
|
- **`stage_trace_density()` stays allocation-free on the frame path.** `column_rows_` is already
|
||||||
|
sized once per call and reused; the minimum-deposit tracking adds a scalar, not a container.
|
||||||
|
- **No figure is written into a document unless it was measured on this machine.** The arithmetic
|
||||||
|
above is derived and is labelled as derived; every number that reaches `README.md` or
|
||||||
|
`docs/claude/` comes from a probe run quoted from that run.
|
||||||
|
- **There is no unit test suite in udpscope.** Every assertion here is either a `deps/plotter`
|
||||||
|
test (that project has one) or a probe line on stderr driven through the real widgets, read
|
||||||
|
against the documented baselines: the documented build-warning count and zero `Gtk-CRITICAL`.
|
||||||
|
- **Run one probe at a time.**
|
||||||
|
- **The look is the user's call, the drop is not.** The `lit_floor` fix is correct or incorrect
|
||||||
|
and a test says which. The four style values are judgments; they are stated in one place, with
|
||||||
|
the before-value in a comment, so a second opinion is a one-line change.
|
||||||
|
|
||||||
|
## Task 0 — the test harness filter, already applied
|
||||||
|
|
||||||
|
`plottest::run_all()` ran all 376 cases unconditionally, so reading one case's output meant
|
||||||
|
running the suite. `PLOT_TEST_FILTER` is a substring match honoured in `run_all()`
|
||||||
|
(`deps/plotter/tests/harness.hpp`), unset meaning "all", which is what made the A/B above cheap
|
||||||
|
enough to iterate on. Documented in upstream's `docs/building-and-testing.md`, including the trap
|
||||||
|
that the summary line still counts the whole suite — so a filtered run says `377 cases, 0 failed`
|
||||||
|
having run one, and is never evidence that the suite passes.
|
||||||
|
|
||||||
|
## Task 1 — the library test that fails now
|
||||||
|
|
||||||
|
Upstream, beside `a_dense_trace_can_be_graded_by_dwell`:
|
||||||
|
`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`.
|
||||||
|
|
||||||
|
The scratch measurement above is the shape of it, and was deliberately **not** committed: it
|
||||||
|
reports numbers rather than asserting, and it leans on the flat path as its reference, which the
|
||||||
|
column-11 note shows is not exact. The committed test keeps the construction and replaces the
|
||||||
|
reference.
|
||||||
|
|
||||||
|
The series and window are the measured ones — a true square of period 2098 over 60 000 x-units,
|
||||||
|
~105 samples a column — because that density puts the gather on raw samples and makes a flat
|
||||||
|
level's deposit a hundred times the riser's, which is the condition the defect needs. The riser
|
||||||
|
must **share** its column, pinned by construction rather than by luck: it is the phase that
|
||||||
|
survived in 10 of 116 columns, so a test that lets the phase float passes for the wrong reason
|
||||||
|
about one time in twelve.
|
||||||
|
|
||||||
|
The assertion is *continuity over the riser's own column*, not a brightness profile — the
|
||||||
|
existing grading test already profiles fifths and passes either way. Prefer the staged geometry
|
||||||
|
over a pixel readback so the failure names the dropped rows rather than reporting that the
|
||||||
|
picture changed, and have the failure print the gap's row range.
|
||||||
|
|
||||||
|
It must fail against the pre-fix `lit_floor`; verify that by reverting the one line, not by
|
||||||
|
trusting the figures in this document.
|
||||||
|
|
||||||
|
**Done.** `a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`, beside the dwell test.
|
||||||
|
It chooses its riser columns by the property at stake — a band spanning more than half the axis,
|
||||||
|
which on a square wave is a transition and nothing else — from the ungraded frame, then re-reads
|
||||||
|
exactly those columns from the graded one and asserts 95 % of them are still tall. Reading
|
||||||
|
`kept=115` of `flat=116`, and `kept=10` with `lit_floor` reverted to `0.01f * peak`, which is the
|
||||||
|
required failure verified rather than assumed. The column-11 slack is in the assertion's 95 % and
|
||||||
|
its reason is in the comment.
|
||||||
|
|
||||||
|
## Task 1b — the library test for slope grading
|
||||||
|
|
||||||
|
`a_sparse_trace_is_graded_by_slope_like_a_dense_one`: one window either side of
|
||||||
|
`dense_threshold_spp` over the same transition at the same place, asserting that the two frames
|
||||||
|
took the two paths (`graded_traces` against `slope_traces`), that **neither dims the level**
|
||||||
|
(191 either way), that **both dim the riser** to under 55 % of it, and that the two riser
|
||||||
|
readings agree to within one tier. Reading 71/191 against 71/191; with the slope staging gated
|
||||||
|
off the sparse frame reads 191/191 and the path assertion fails first.
|
||||||
|
|
||||||
|
## Task 2 — review the fix
|
||||||
|
|
||||||
|
The change is in the tree. What is left is to read the comment at its site against the measured
|
||||||
|
numbers it quotes, and to confirm `Style::intensity_floor`'s doc comment now describes reality,
|
||||||
|
since it is cited above as having promised what the code did not deliver.
|
||||||
|
|
||||||
|
## Task 3 — the four stated values
|
||||||
|
|
||||||
|
`src/plot_host.cpp:175` from `1.2f` to at least the library's `1.5f`. The comment records that
|
||||||
|
the app had been asking for less than the default and that this width is also the minimum span a
|
||||||
|
short run is stroked at.
|
||||||
|
|
||||||
|
`ui.cpp`'s style one-shot (`src/ui.cpp:6149`) states, each with the inherited value in a comment:
|
||||||
|
|
||||||
|
- `marker_px` — `todo.md` 1.3, "the sample points should be bigger". The dots only appear below
|
||||||
|
`marker_below_samples` raw samples in the window, so this is the deep-zoom reading and is
|
||||||
|
independent of everything above.
|
||||||
|
- `marker_below_samples` — raising it brings the dots back at windows that currently show a bare
|
||||||
|
polyline.
|
||||||
|
- `intensity_floor` and `line_aa_px` — the two readings of `todo.md` 1.1, "the antialiasing
|
||||||
|
strategy with phosphore effect should be applied with more stregth", and they are different
|
||||||
|
things: `intensity_floor` lifts the *faintest graded tier*, which is what a riser is drawn at
|
||||||
|
even once it is lit; `line_aa_px` widens the anti-aliased fringe on **every** stroke in the
|
||||||
|
library, which is literally more anti-aliasing and makes a thin line read fatter at the cost of
|
||||||
|
a softer grid and axis. Both are stated; the values are the ones to look at on screen and
|
||||||
|
adjust, and the task's report says what each one did on its own.
|
||||||
|
|
||||||
|
Nothing here is conditional on Task 2, and nothing in Task 2 is conditional on these.
|
||||||
|
|
||||||
|
## Task 4 — the probe
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_EDGEINK_MS`: plot the mock's `square`, settle on a window dense enough for the
|
||||||
|
graded path, and report — per the line reference in `docs/claude/probe-readings.md` — the edge
|
||||||
|
column's staged span against the band it should cover, and the count of unlit rows inside it.
|
||||||
|
**It must read 0 dropped rows**, and it must be run with and without `UDPSCOPE_NO_PHOSPHOR=1`:
|
||||||
|
the ungraded path is the control, and the two must agree about where the trace is.
|
||||||
|
|
||||||
|
`frame path:` gains nothing new if the probe above covers the drop; the brief for this task
|
||||||
|
decides that from the probe's output, and whichever way it goes,
|
||||||
|
`docs/claude/probe-readings.md` gets the per-line entry.
|
||||||
|
|
||||||
|
## Task 5 — the documents
|
||||||
|
|
||||||
|
`docs/claude/invariants-rendering.md` already carries the grading invariant and the sentence
|
||||||
|
"the floor so that a fast riser, one sub-bucket deep everywhere, is never dimmed out of
|
||||||
|
existence". That is the claim this defect falsified, so the bullet gains the distinction the code
|
||||||
|
was missing — **an alpha floor is not an existence floor** — and names the symptom, the phase
|
||||||
|
dependence, and `UDPSCOPE_NO_PHOSPHOR=1` as the A/B that separates the two.
|
||||||
|
|
||||||
|
`README.md` records the stroke and marker values as deliberate deviations from the library's
|
||||||
|
defaults, under the existing deviations section.
|
||||||
|
|
||||||
|
Upstream `docs/data-and-performance.md` and `README.md` carry the `lit_floor` rule and the slope
|
||||||
|
path, since both are the library's contract and not this app's. **Done**, together with
|
||||||
|
`Style::intensity_grading`'s own doc comment (which described the dense path as the whole of the
|
||||||
|
feature), `docs/building-and-testing.md`'s `PLOT_TEST_FILTER` entry, and the case counts in all
|
||||||
|
three places that quote them — 350 → 377, and 333 → 360 for the coverage preset.
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# UDP packet capture: design
|
||||||
|
|
||||||
|
`todo.md` item 4: *optional udp frame storage for debugging of strange behaviours (store last
|
||||||
|
`<N>`, default = 1000, udp packets binary content on disk for forensic analysis).*
|
||||||
|
|
||||||
|
The strange behaviours worth forensics on are all *wire* behaviours — a counter that skips, a
|
||||||
|
datagram that arrives twice, a fragment that never completes, a CONFIG whose header does not parse,
|
||||||
|
a burst that outruns the reorder ring. Every one of them is invisible by the time a
|
||||||
|
`udps_frame_t` reaches `Acquisition::on_data`: the client has already validated the magic,
|
||||||
|
reassembled the fragments, resequenced what it could and dropped what it could not. A capture that
|
||||||
|
is to answer *what did the wire actually send* must therefore sit at `recv()`, and must store the
|
||||||
|
bytes without interpreting them.
|
||||||
|
|
||||||
|
This is a debugging aid, not a feature of the instrument. It is off by default, it adds nothing to
|
||||||
|
the glance tier of the status bar, and it must not be able to cost a single packet when it is on.
|
||||||
|
|
||||||
|
## The decisions taken, and what each one rules out
|
||||||
|
|
||||||
|
| Decision | Alternative rejected |
|
||||||
|
| --- | --- |
|
||||||
|
| Always-on rolling ring while enabled; the last N are on disk at all times | Keeping the ring in RAM and flushing on an anomaly or on demand — a crash or a `SIGKILL` then leaves nothing, which is the case a forensic capture exists for |
|
||||||
|
| A fixed-size file of N slots, plus a converter to pcap in `tools/` | Writing pcap directly (grows, so "last N" needs two rotating files and the newest N is split across both) or one file per datagram (1000 files, an open/write/close per packet in the receive path) |
|
||||||
|
| `mmap` once, `memcpy` per datagram | A `pwrite` per datagram (a syscall, and on a busy disk a blocking one, between two `recv()` calls) or a third thread with a queue (a second copy of every datagram, and this application's two-thread property is load-bearing) |
|
||||||
|
| One path per source, previous capture kept one deep | A timestamped file per enable (needs the retention policy `todo.md` item 9 defines and this does not build) or a single path overwritten (re-enabling destroys the capture you enabled for) |
|
||||||
|
| `--capture-packets[=N]` as well as the popover button | Button only — by the time the window is up and clickable, the first connect and its CONFIG are already history, and those are exactly the "strange behaviours" hardest to reproduce |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three parts, each with one responsibility and a seam that does not leak the other two.
|
||||||
|
|
||||||
|
### 1. The client hands over raw datagrams
|
||||||
|
|
||||||
|
`deps/udpsclient` gains **one additive entry point**:
|
||||||
|
|
||||||
|
```c
|
||||||
|
/** Every datagram as recv() returned it, before the magic check, reassembly or
|
||||||
|
* resequencing: the bytes a capture has to store, and the only place malformed
|
||||||
|
* or duplicate datagrams are still visible. Runs on the polling thread, inside
|
||||||
|
* udps_client_poll(), like every other callback. `bytes` is valid until the
|
||||||
|
* callback returns. */
|
||||||
|
typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len,
|
||||||
|
double recv_time, void *user);
|
||||||
|
|
||||||
|
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||||
|
udps_datagram_cb cb, void *user);
|
||||||
|
```
|
||||||
|
|
||||||
|
A separate setter, **not** a fourth parameter on `udps_client_set_callbacks`
|
||||||
|
(`udps_client.h:283`): that signature has call sites, and a capture hook is no reason to change
|
||||||
|
them.
|
||||||
|
|
||||||
|
The call site is `udps_client.c:1458`, immediately after
|
||||||
|
|
||||||
|
```c
|
||||||
|
ssize_t n = recv(c->udp_fd, c->rxbuf, UDPS_RX_BUF_BYTES, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
returns a positive `n`, and before anything is read out of `rxbuf`. `recv_time` is the same clock
|
||||||
|
the client already stamps arrivals with, so a capture's times and the stream statistics agree.
|
||||||
|
|
||||||
|
**Boundary, stated rather than discovered later:** in multicast mode CONFIG arrives over TCP
|
||||||
|
(`udps_client.c:1142`) and this hook does not see it. The capture is of the UDP socket. A malformed
|
||||||
|
*multicast* CONFIG is therefore out of reach, and `README.md` will say so.
|
||||||
|
|
||||||
|
### 2. `PacketCapture` owns the file
|
||||||
|
|
||||||
|
New unit, `src/capture.hpp` / `src/capture.cpp`. One class, one responsibility — own the ring file
|
||||||
|
and store a datagram into it. It includes no GTK, no `ScopeModel`, and no client types beyond
|
||||||
|
`<cstdint>`; its whole interface is:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class PacketCapture {
|
||||||
|
public:
|
||||||
|
/// Renames an existing capture to `capture-prev.bin`, creates and reserves the new file,
|
||||||
|
/// maps it. Returns false and leaves the object disabled on any failure, with `error()` set.
|
||||||
|
bool open(const std::string& path, std::uint32_t slot_count);
|
||||||
|
void close(); ///< unmap, close; idempotent
|
||||||
|
bool enabled() const noexcept;
|
||||||
|
|
||||||
|
/// One datagram. Called only from the producer thread. Never allocates, never blocks,
|
||||||
|
/// never fails: an over-long datagram is truncated to the slot and counted.
|
||||||
|
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||||
|
|
||||||
|
std::uint64_t stored() const noexcept; ///< datagrams since open()
|
||||||
|
std::uint64_t truncated() const noexcept;
|
||||||
|
std::uint32_t slot_count() const noexcept;
|
||||||
|
const std::string& path() const noexcept;
|
||||||
|
/// Copies the last open() failure out, or writes an empty string. Not a
|
||||||
|
/// `const std::string&`: see "What crosses between the threads" below.
|
||||||
|
void error(char* out, std::size_t cap) const noexcept;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### What crosses between the threads
|
||||||
|
|
||||||
|
The producer thread writes this state and the GTK thread reads it every frame for the popover, so
|
||||||
|
each field has to be publishable without a lock:
|
||||||
|
|
||||||
|
| Field | Type | Why it is safe |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled_`, `stored_`, `truncated_` | `std::atomic<…>`, relaxed | counters and a flag, not a consistent set — the same treatment `StreamStats` already gets |
|
||||||
|
| `path_` | `std::string` | written **once** by the GTK thread before it ever sets `capture_want_`, and only read afterwards |
|
||||||
|
| the open failure | `char error_[128]` plus `std::atomic<bool> error_set_` | the producer fills the buffer and *then* sets the flag with a release store; the GTK thread copies the buffer only when the flag reads true |
|
||||||
|
|
||||||
|
The error is deliberately **not** a `std::string` accessor. A `std::string` written by the producer
|
||||||
|
inside `open()` and read by the GTK thread on the next frame is a data race with a reallocating
|
||||||
|
buffer — the kind that reads correctly for months. The fixed buffer with a flag published after it
|
||||||
|
is the same release-store trick the slot's `seq` field uses, for the same reason.
|
||||||
|
|
||||||
|
#### The file
|
||||||
|
|
||||||
|
```
|
||||||
|
offset 0, 64 bytes — header
|
||||||
|
magic u8[8] "UDPSCAP\0"
|
||||||
|
version u32 1
|
||||||
|
slot_count u32 N
|
||||||
|
slot_bytes u32 sizeof(record header) + UDPS_RX_BUF_BYTES, 8-byte aligned
|
||||||
|
cursor u32 next slot index to write (a hint; see below)
|
||||||
|
t0_realtime f64 CLOCK_REALTIME at open(), the epoch recv_time is relative to
|
||||||
|
peer_addr u32 the source's IPv4, network order
|
||||||
|
peer_port u16
|
||||||
|
local_port u16
|
||||||
|
reserved to 64 bytes, zeroed
|
||||||
|
|
||||||
|
offset 64 + i * slot_bytes, for i in [0, N) — slot i
|
||||||
|
t_arrival f64 the client's recv_time
|
||||||
|
len u32 bytes stored (after truncation, if any)
|
||||||
|
orig_len u32 bytes the wire delivered, so a truncation is visible
|
||||||
|
bytes u8[len]
|
||||||
|
seq u64 1-based ordinal since open(); written LAST
|
||||||
|
```
|
||||||
|
|
||||||
|
`seq` is written last, after the bytes, with a release store. Three things follow, and they are the
|
||||||
|
reason the format is this and not simpler:
|
||||||
|
|
||||||
|
- **A slot carrying a non-zero `seq` is complete.** No `fsync` is needed for the file to be
|
||||||
|
readable after a crash: the kernel's dirty pages are the file, and a half-copied slot still has
|
||||||
|
`seq == 0`.
|
||||||
|
- **`seq`, not `cursor`, is the order.** After the ring wraps, the newest slot is the one with the
|
||||||
|
largest `seq`, and the reader needs no trust in a header field that is updated out of band.
|
||||||
|
- **A torn write is detectable, not silent.** The reader reports the count of empty and torn slots
|
||||||
|
rather than averaging over them.
|
||||||
|
|
||||||
|
`cursor` is maintained anyway, because a human running `xxd` on the file wants to know where to
|
||||||
|
look.
|
||||||
|
|
||||||
|
#### Why the file is reserved, not sparse
|
||||||
|
|
||||||
|
`posix_fallocate` reserves the whole file at `open()`. The obvious alternative — `ftruncate` and
|
||||||
|
let the file stay sparse, so it costs only the bytes actually written — is wrong with `mmap`: a
|
||||||
|
store into a page the filesystem cannot back raises **`SIGBUS`**, which kills udpscope outright.
|
||||||
|
A capture must not be able to take down the instrument it is observing, and an out-of-space
|
||||||
|
condition must be an error at the moment the user clicks, not a crash ten minutes later.
|
||||||
|
|
||||||
|
At the default `N = 1000` and `UDPS_RX_BUF_BYTES = 65552`, the file is **~62.6 MiB**. That is
|
||||||
|
stated in the help text, in the tooltip and in the popover row, because it is the one cost of
|
||||||
|
switching the feature on.
|
||||||
|
|
||||||
|
### 3. `Acquisition` drives it, and the GTK thread only ever sets an atomic
|
||||||
|
|
||||||
|
`Acquisition` owns the `PacketCapture`, installs the datagram callback next to the three it already
|
||||||
|
installs (`src/acquisition.cpp:62`), and does **every** file operation on the producer thread.
|
||||||
|
|
||||||
|
```
|
||||||
|
GTK thread (the button) producer thread (the datagram callback)
|
||||||
|
--------------------------- --------------------------------------------
|
||||||
|
capture_want_.store(true) ──► reads capture_want_ at the top of each datagram
|
||||||
|
capture_path_ written once rising edge → open() : rename, create, fallocate, mmap
|
||||||
|
before the first store falling edge → close(): munmap, close
|
||||||
|
then, if enabled, store(bytes, len, recv_time)
|
||||||
|
```
|
||||||
|
|
||||||
|
No mutex, no queue, no second copy of any datagram, and the application still has exactly two
|
||||||
|
threads.
|
||||||
|
|
||||||
|
The one-off cost of a toggle is the `open()`: a rename, a create, a `posix_fallocate` of 62.6 MiB
|
||||||
|
and an `mmap`, on the producer thread. It is a user-initiated act that happens once per toggle, and
|
||||||
|
`README.md` will say that it can cost the packets in flight during it. Moving it off the producer
|
||||||
|
thread would need the lock or the thread this design exists to avoid, for an event that happens
|
||||||
|
when a human clicks a button.
|
||||||
|
|
||||||
|
The duration is **measured, not asserted**: `open()` records its own wall time and the probe prints
|
||||||
|
it as `open_ms`. `posix_fallocate` on a local extent-based filesystem is a metadata operation and
|
||||||
|
should be a handful of milliseconds, but that is a claim about the filesystem, not about this code,
|
||||||
|
and a reading above ~50 ms is a finding worth putting in `README.md` rather than a number to
|
||||||
|
predict here.
|
||||||
|
|
||||||
|
### Error handling, and only at this boundary
|
||||||
|
|
||||||
|
This is a syscall boundary, so it is validated; nothing else here is.
|
||||||
|
|
||||||
|
| Condition | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `open`, `posix_fallocate` or `mmap` fails (ENOSPC, EACCES, a read-only cache directory) | capture stays off, `error()` carries `strerror`, one line on stderr, the toggle button returns to off and the popover row shows the reason |
|
||||||
|
| the cache directory does not exist | created with `g_mkdir_with_parents`, 0700; a failure there is the row above |
|
||||||
|
| `len > UDPS_RX_BUF_BYTES` | impossible — the slot is sized to the client's own receive buffer — but `store()` truncates to the slot, records `orig_len`, and counts it in `truncated()` rather than trusting the impossibility |
|
||||||
|
| the previous `capture-prev.bin` cannot be replaced | the capture still opens; the rename failure is reported, the old previous is kept |
|
||||||
|
| capture enabled with no connection yet | the button is insensitive until connected, because the path is keyed on the source; `--capture-packets` defers `open()` to the first datagram, which is what arms it before the first CONFIG |
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
### The popover button and row
|
||||||
|
|
||||||
|
The status bar's detail popover (`src/ui.cpp:7619-7672`) is a two-column grid: four bold headings
|
||||||
|
at rows 0, 3, 5, 6 and seven value labels at rows 0..6, in `app->popover_label[11]`. It gains a
|
||||||
|
fifth heading, **Capture**, and an eighth value label; the array becomes `[13]` and the
|
||||||
|
`kHeadings` / `kHeadingRow` tables gain their entry. `update_status_popover`
|
||||||
|
(`src/ui.cpp:6101`) writes the new label like the others — `gtk_label_set_text` only, no rebuild.
|
||||||
|
|
||||||
|
The row reads one of:
|
||||||
|
|
||||||
|
```
|
||||||
|
off
|
||||||
|
on · 412/1000 slots · 26.1 MiB · ~/.cache/udpscope/192.168.1.40-5000/capture.bin
|
||||||
|
failed: No space left on device
|
||||||
|
```
|
||||||
|
|
||||||
|
Below the grid, a `GtkToggleButton` labelled **Capture packets** with the
|
||||||
|
`media-record-symbolic` icon and a tooltip naming the full path and the file's size. `toggled`
|
||||||
|
sets the atomic and nothing else. Insensitive until connected.
|
||||||
|
|
||||||
|
Nothing is added to `format_detail()`'s note string: `src/status_text.cpp:86` is a `char
|
||||||
|
note[160]` already close to its worst case, and the capture state is not something the exit dump's
|
||||||
|
one line needs.
|
||||||
|
|
||||||
|
### The command line
|
||||||
|
|
||||||
|
```
|
||||||
|
--capture-packets N store the last N raw datagrams on disk for forensics, in a
|
||||||
|
ring under the cache directory (1000 slots is ~62.6 MiB);
|
||||||
|
off unless given, and toggleable from the status bar
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsed in `src/config.cpp`'s existing style — both `--capture-packets N` and
|
||||||
|
`--capture-packets=N`, `N` in 1..100000 — and the value is **required**, not optional.
|
||||||
|
`--capture-packets` with an optional value cannot be told apart from `--capture-packets` followed
|
||||||
|
by a value in this parser, and every other option in it takes the space-separated form, so a bare
|
||||||
|
flag would turn `--capture-packets 500` into `unknown argument: 500`. The 1000 default is the
|
||||||
|
slot count the **popover button** uses when the flag was not given; with the flag, the button uses
|
||||||
|
the N the flag named.
|
||||||
|
|
||||||
|
`Config::capture_packets` is a `std::uint32_t`: 0 means the flag was absent, and the button then
|
||||||
|
uses 1000.
|
||||||
|
|
||||||
|
The flag arms the ring before the first datagram; it is not persisted in the session file. That
|
||||||
|
persistence belongs with `todo.md` item 3's work, alongside the relative-time toggle deferred to
|
||||||
|
the same place.
|
||||||
|
|
||||||
|
### `tools/capture_to_pcap.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
$ tools/capture_to_pcap.py ~/.cache/udpscope/192.168.1.40-5000/capture.bin out.pcap
|
||||||
|
1000 slots, 412 stored, 0 torn, 0 truncated → 412 packets, 26.1 MiB
|
||||||
|
$ wireshark out.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads the header, validates the magic and version, walks the slots, orders by `seq`, skips
|
||||||
|
`seq == 0`, and emits a classic pcap (`LINKTYPE_ETHERNET`, microsecond timestamps from
|
||||||
|
`t0_realtime + t_arrival`) with a synthesised Ethernet/IPv4/UDP header per record built from the
|
||||||
|
header's `peer_addr`, `peer_port` and `local_port`. Pure standard library — the mock already
|
||||||
|
forces a hard `numpy` dependency on `tools/`, and this script has no reason to add to that.
|
||||||
|
|
||||||
|
It also prints the counts above to stderr, which is what makes it a diagnostic in its own right:
|
||||||
|
`torn` or `truncated` above zero is a finding about the capture, before anything about the stream.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
udpscope has no unit test suite; the capture is verified by the application's own headless
|
||||||
|
self-check, and the converter by running it.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_CAPTURE_MS` toggles capture **through the same handler the button uses**, waits a
|
||||||
|
scheduled interval, then reads the file back off disk and prints one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
[capture-probe] enabled=1 path=… slots=1000 stored=412 cursor=412 torn=0 truncated=0
|
||||||
|
header_ok=1 first_seq=1 last_seq=412 payload=1648000 file=65576064
|
||||||
|
open_ms=3.1 t0=1760000000.123
|
||||||
|
```
|
||||||
|
|
||||||
|
`payload` is the sum of every stored slot's `len` — the bytes the wire actually delivered, which is
|
||||||
|
what tells you whether the ring is holding what you think. `file` is the reserved size,
|
||||||
|
`64 + slot_count * slot_bytes`, and is a constant for a given N: it is in the line so that a
|
||||||
|
mismatch against the file on disk is a reading rather than something to go and check.
|
||||||
|
|
||||||
|
What each field has to read, and the defect a wrong reading names, goes in
|
||||||
|
`docs/claude/probe-readings.md` beside the other probes. Two cases in
|
||||||
|
`docs/claude/verification.md`:
|
||||||
|
|
||||||
|
1. **enable at runtime** — the probe toggles on mid-run; `stored` must equal the datagrams the
|
||||||
|
exit dump counts from that instant, `torn=0`, and `first_seq=1`.
|
||||||
|
2. **wrap** — `--capture-packets=16` against the standard mock, run long enough to wrap several
|
||||||
|
times; `stored` exceeds `slot_count`, `last_seq - first_seq == 15`, `torn=0`. A wrap that loses
|
||||||
|
ordering, or a `cursor` that disagrees with `seq`, shows here and nowhere else.
|
||||||
|
|
||||||
|
Then the converter, run over the wrap case's file: exit 0, a record count equal to `slot_count`,
|
||||||
|
and the pcap's own first and last record timestamps inside the run's wall-clock interval. The
|
||||||
|
check is the converter's printed counts plus a `struct.unpack` of the pcap's first and last record
|
||||||
|
headers — no Wireshark, no `capinfos`, nothing outside the standard library, so it runs wherever
|
||||||
|
the self-check runs.
|
||||||
|
|
||||||
|
The standing baselines apply unchanged: a clean build at exactly four warnings, an incremental at
|
||||||
|
two, and zero `Gtk-CRITICAL` in any headless run.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Replaying a capture back into udpscope.** That is `todo.md` item 5's territory (opening stored
|
||||||
|
waveforms) and a different feature with a different shape.
|
||||||
|
- **Retention beyond one deep.** `capture.bin` and `capture-prev.bin`, nothing more, until item 9
|
||||||
|
defines the cache-size preferences.
|
||||||
|
- **Session persistence of the enable state.** Deferred to item 3 with the relative-time toggle,
|
||||||
|
so one piece of work adds the session fields rather than two.
|
||||||
|
- **Capturing the multicast TCP CONFIG channel.** Stated as a limitation in `README.md`.
|
||||||
|
- **Compression, encryption or rotation of the ring.** A fixed file of raw bytes is what a
|
||||||
|
forensic reader wants, and `gzip` exists.
|
||||||
|
|
||||||
|
## Documentation to update
|
||||||
|
|
||||||
|
- `README.md` — the feature, the 62.6 MiB cost, the producer stall on toggle, the multicast CONFIG
|
||||||
|
limitation, the converter's usage.
|
||||||
|
- `SPECS.md` — nothing: this is a `todo.md` item, not a spec requirement, and not a deviation.
|
||||||
|
- `todo.md` — item 4 struck, on one line, with the flag's name (GFM strikethrough is inline and
|
||||||
|
cannot span list items).
|
||||||
|
- `docs/claude/build-and-run.md` — the new option; `verification.md` and `probe-readings.md` — the
|
||||||
|
probe and its two cases; `docs/claude/invariants-data-path.md` — one paragraph on why the hook is
|
||||||
|
at `recv()` and why the file is reserved rather than sparse.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# Noise rejection: design
|
||||||
|
|
||||||
|
Noise rejection in three places — the measurements pane, the trigger, and a new math node — aimed
|
||||||
|
at one problem: **electromagnetic peaks**. EM interference is *impulsive*. It arrives as a few
|
||||||
|
samples of large excursion, not as a raised noise floor, and that distinction decides every choice
|
||||||
|
below. An impulse recrosses a threshold several times per true crossing, so it inflates an edge
|
||||||
|
count; it moves a `min`, a `max` and a peak-to-peak outright; and it is the one kind of noise a
|
||||||
|
low-pass filter handles *badly*, smearing the spike across its settling time instead of removing
|
||||||
|
it while rounding the real edge beside it.
|
||||||
|
|
||||||
|
Each of the three places therefore gets two independent, optional controls:
|
||||||
|
|
||||||
|
| | amplitude qualifier | frequency qualifier |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Trigger** | hysteresis band about the level, in signal units | one-pole HF reject on the comparator's input, `fc` in Hz |
|
||||||
|
| **Measurements** | hysteresis band, % of the window's peak-to-peak | one-pole HF reject over the measured window, `fc` in Hz |
|
||||||
|
| **Math** | — | the `de-noise` node: median over N samples, then a one-pole at `fc` |
|
||||||
|
|
||||||
|
Nothing here changes a default. Every control is off, or set to the value the code already uses,
|
||||||
|
so the whole documented reading set must come out of this work unchanged. That is the first
|
||||||
|
verification assertion, not an afterthought.
|
||||||
|
|
||||||
|
## The decisions taken, and what each one rules out
|
||||||
|
|
||||||
|
| Decision | Alternative rejected |
|
||||||
|
| --- | --- |
|
||||||
|
| The trigger band is **absolute, in the channel's own units** | A percentage of a running peak-to-peak: the producer has no measured span, so it would need per-channel sliding-window state in the hot loop *and* a warm-up during which the band is wrong — which shows up as a missed or early latch, the one failure a trigger must not have |
|
||||||
|
| `band == 0` takes today's crossing test **verbatim**, with the latched-side comparator used only when the band is non-zero | One unified latched-side comparator: at `v == level` exactly the two are *not* equivalent (today it fires, a zero-width band decides nothing), and `feed_window`'s own comment records a ±1 square against a `[1, 1]` band firing 19 times on exact float equality. `probe-readings.md`'s `[condition-probe]` table of four exact latch times is a regression suite in disguise; unifying would require re-measuring all of it to prove a change nobody asked for |
|
||||||
|
| The band and the filter live **inside `EventDetector`** | Filtering in `Acquisition` before `engine.process()`: the buffer search deliberately runs the same evaluator (`condition.hpp:46-49`), so anything upstream of the detector has to be replicated there, and the live trigger and the search would drift apart |
|
||||||
|
| No shared hysteresis primitive extracted | A common `side_of()` header used by `measure.cpp` and `condition.hpp`: the genuinely shared part is about five lines, and the two call sites want different things from it — `measure.cpp` `continue`s on an in-band sample while counting enters, widths and folded buckets, the detector needs a side to feed four state machines. Three similar lines beat a premature abstraction; the *invariant* is stated once and pointed at |
|
||||||
|
| The measurement filter is a **real one-pole at a user `fc`**, and it disables the pyramid fast path | Measuring the pyramid's per-bucket means, which would be free and keep the fast path — but its corner is wherever the bucket size puts it (`fs/64`), so it is HF reject at one fixed, rate-dependent frequency rather than a cutoff the user can aim at the interference they actually have |
|
||||||
|
| The filter runs **inside each of the two raw walks** | Materializing a filtered copy of the window: `MeasureChunk`'s comment (`measure.hpp:108-115`) records that widening the window cost 60 % of the pane's time before a single statistic had been computed, and 17 MB at a 2.2 M-sample window |
|
||||||
|
| One combined `de-noise` node (median **then** pole) | A plain median node chained to the existing `LowPass`: two boxes for one idea, and it lets the user wire them in the wrong order — a pole *before* a median lets one spike contaminate the filter state for its whole settling time, which defeats the purpose |
|
||||||
|
| The median window is **N samples, fixed** | A duration converted to N from `dt`: N would then grow with decimation, so a modest duration at a long window becomes a large sort in the producer's hot loop and needs a hidden cap the user did not set |
|
||||||
|
| `MathOp::DeNoise` is **appended**, at index 12 | Inserting it beside the filters in `math_op_at()`, where it belongs in the dropdown: a node persists as its *index through that accessor* (`layout_store.cpp:358-365`), so inserting would silently reinterpret every node in every existing session file |
|
||||||
|
| The median's ring is **pooled in `MathRuntime`** | A `double[9]` member of `MathNodeRt`: 72 bytes added to *every* node, doubling a struct whose shape README's math-cost section records as a deliberate choice, to serve the one op whose state size is a parameter |
|
||||||
|
| A new **impulsive** mock source | Verifying against `--noise` (Gaussian): a median is specifically the wrong tool for a raised noise floor and the right one for impulses, so a Gaussian test would prove nothing about the feature |
|
||||||
|
|
||||||
|
## 1. The trigger
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
`EventCondition` (`condition.hpp:27`) gains two fields:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double noise_band = 0.0; ///< hysteresis half-width about `level`, signal units. 0 = off.
|
||||||
|
double hf_fc = 0.0; ///< HF reject corner on the comparator's input, Hz. 0 = off.
|
||||||
|
```
|
||||||
|
|
||||||
|
Both **must** join `operator==` (`condition.hpp:37-41`). This is not bookkeeping:
|
||||||
|
`EventDetector::configure()` returns early when the condition compares equal
|
||||||
|
(`condition.cpp:9`) and the producer calls it on every packet, so a field omitted from the
|
||||||
|
comparison is a control that silently never takes effect.
|
||||||
|
|
||||||
|
The filter needs the sample interval to turn `fc` into a coefficient, and the detector is fed
|
||||||
|
`(value, t)` pairs rather than a rate. `configure()` therefore takes `dt` as a second argument and
|
||||||
|
keeps it as a `dt_` member, and the early return compares the new `dt` against `dt_` as well as the
|
||||||
|
condition against the stored one — a re-plan changes `dt`, and a coefficient left over from the
|
||||||
|
previous partition is a filter at the wrong corner.
|
||||||
|
|
||||||
|
### Comparator
|
||||||
|
|
||||||
|
`EventDetector` gains `int side_ = 0` (0 undecided, ±1 above/below) and one pole state word.
|
||||||
|
`crossed_up`/`crossed_down` (`condition.hpp:77-82`) stop being pure functions of `prev_` and
|
||||||
|
become flags computed **once per sample** in `feed()`:
|
||||||
|
|
||||||
|
- `noise_band == 0` → `prev_ < level && v >= level` and its mirror, bit for bit as today.
|
||||||
|
- `noise_band > 0` → `v >= level + band` latches `+1`, `v <= level - band` latches `-1`, anything
|
||||||
|
between decides nothing; a crossing *is* a change of latch.
|
||||||
|
|
||||||
|
Computing the pair once also removes a duplicated computation: `feed_edge`, `feed_pulse` and
|
||||||
|
`feed_dropout` each call both predicates today.
|
||||||
|
|
||||||
|
`feed_window` (`condition.cpp:115`) does not use those predicates, so it takes the band
|
||||||
|
explicitly on each edge of its own band: becoming *inside* requires `[lo+band, hi-band]`,
|
||||||
|
becoming *outside* requires leaving `[lo-band, hi+band]`. A band wide enough to close that inner
|
||||||
|
region is clamped to just under half the window's width — the same shape of clamp
|
||||||
|
`measure_set_level()` already applies for the same reason, and it keeps the panel's existing
|
||||||
|
refusal of a zero-width window meaningful.
|
||||||
|
|
||||||
|
`reset()` (`condition.cpp:14`) clears `side_` and the pole state.
|
||||||
|
|
||||||
|
### The filter
|
||||||
|
|
||||||
|
One pole on the comparator's input, `y += a·(x − y)`, with `a = 1 − exp(−2π·fc·dt)` built in
|
||||||
|
`configure()` and clamped below Nyquist as `biquad()` does (`math_channel.cpp:28`). `fc = 0`
|
||||||
|
gives `a = 1`, i.e. `y = x`, so "off" needs no branch in the inner loop.
|
||||||
|
|
||||||
|
Two consequences, both stated rather than discovered:
|
||||||
|
|
||||||
|
- **The latch moves later** by roughly `1/(2π·fc)` — the pole's group delay. This is inherent,
|
||||||
|
it is what a bench scope's HF reject also does, and it is why the control is off by default.
|
||||||
|
- **A NaN stays benign.** `feed()`'s comment (`condition.hpp:68-70`) records that a NaN sample
|
||||||
|
cannot fire because every comparison against it is false, and that *rejecting* one would leave
|
||||||
|
`prev_` stale and let the next sample fire a crossing that today cannot happen. The filter
|
||||||
|
preserves this: a non-finite input returns NaN (which flows into the existing comparisons, all
|
||||||
|
false) and leaves the pole state alone.
|
||||||
|
|
||||||
|
`last_value()` (`condition.hpp:73`) comes to report the comparator's input rather than the raw
|
||||||
|
sample. Its only consumer is the `v=%.4g` field of the trig-engine diagnostic
|
||||||
|
(`acquisition.cpp:918`), whose purpose is to say what the comparator saw.
|
||||||
|
|
||||||
|
### Panel
|
||||||
|
|
||||||
|
A `trigger_band_spin` and a `trigger_hf_spin` in `build_trigger_panel()` (`ui_trigger.cpp:219`),
|
||||||
|
read in `publish_trigger_fields()` (`:56`), shown for all four condition types in
|
||||||
|
`sync_trigger_rows()` (`:94`). The band takes the level spin's unit, digits and range, floored at
|
||||||
|
0.
|
||||||
|
|
||||||
|
### The search gets both for free
|
||||||
|
|
||||||
|
`EventDetector` holds no arming state precisely so the buffer search can run the same evaluator
|
||||||
|
over retained samples (`condition.hpp:46-49`). A band or a filter set in the panel therefore
|
||||||
|
changes what a search finds, consistently, with no second code path and no second thing to keep
|
||||||
|
in step.
|
||||||
|
|
||||||
|
### Not persisted
|
||||||
|
|
||||||
|
The trigger has never been persisted, so neither control is either; a restart comes up with both
|
||||||
|
off. That is also what makes every current `[condition-probe]` latch time hold unchanged.
|
||||||
|
|
||||||
|
## 2. The measurements
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
`MeasureOptions` (`measure.hpp:37`) gains two fields:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double noise_pct = 10.0; ///< hysteresis half-width, % of the window's peak-to-peak. 0 = off.
|
||||||
|
double hf_fc = 0.0; ///< HF reject corner, Hz. 0 = off.
|
||||||
|
```
|
||||||
|
|
||||||
|
`noise_pct = 10.0` reproduces today's `kHysteresisFrac = 0.10` (`measure.cpp:36`) exactly, so
|
||||||
|
every current reading is identical. `measure_set_level()` (`measure.hpp:195`,
|
||||||
|
`measure.cpp:40`) already takes a `pct` — the *threshold* percent — so it gains a second,
|
||||||
|
separately named `noise_frac` argument for the band, replacing its read of the file-scope
|
||||||
|
`kHysteresisFrac`; the constant stays as `MeasureOptions::noise_pct`'s default so its
|
||||||
|
measured-value rationale — 5 % reads a 0.1 interferer correctly and fails at
|
||||||
|
0.2, 10 % holds to 0.3 and to white noise at σ=0.05 — does not become an orphan comment. The
|
||||||
|
clamp to half the room between the level and the nearer extreme survives untouched: it is what
|
||||||
|
keeps a threshold at 90 % measurable instead of reading `enters=0`.
|
||||||
|
|
||||||
|
### The band reaches the edge types only
|
||||||
|
|
||||||
|
`MeasureLevel::hysteresis`'s comment (`measure.hpp:165-169`) is explicit that the band moves the
|
||||||
|
*edge decision* only, and that the `sum`/`count` split stays at the bare level — because that
|
||||||
|
split **is** `Top`/`Bottom`, the mean of the samples above or below the threshold, and banding it
|
||||||
|
would change the documented exact `top = 2/π` on a unit sine.
|
||||||
|
|
||||||
|
So `noise_pct` is meaningful for `PulseDuration`, `Period` and `Frequency`, and does nothing for
|
||||||
|
`Top`/`Bottom`. The file's own rule (`measure.hpp:34-36`) is that an option which does nothing
|
||||||
|
for a type is left out of the dialog rather than stored and ignored, so this adds a
|
||||||
|
`measure_uses_noise()` beside `measure_uses_threshold()` and `measure_uses_side()`
|
||||||
|
(`measure.hpp:242-245`), and `measure_dialog_sync()` (`ui_measure.cpp:90`) shows the field for
|
||||||
|
those three types only.
|
||||||
|
|
||||||
|
A *raised* band also costs reads: `measure_accumulate_bucket()` already refuses a bucket where
|
||||||
|
the signal passes `level ± hysteresis` (`measure.hpp:211-216`), so a wider band opens more
|
||||||
|
buckets. The existing `measure cost:` line is where that shows up.
|
||||||
|
|
||||||
|
### HF reject reaches every type
|
||||||
|
|
||||||
|
The filter changes the samples rather than a decision, so `min`, `max`, `average`,
|
||||||
|
`peak to peak`, `amplitude`, `top`, `bottom` and every crossing type all read the filtered
|
||||||
|
signal. The control is therefore shown for all types.
|
||||||
|
|
||||||
|
It is seeded with the window's first sample, so the step transient at the left edge does not read
|
||||||
|
as a measurement. (Priming from samples *before* the window is possible — the ring holds the
|
||||||
|
history — and is deliberately not done: it would make a measurement depend on data outside the
|
||||||
|
window it reports on.)
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
`hf_fc > 0` disables **both** bucket fast paths, `measure_scan_buckets()` (`measure.hpp:147`) and
|
||||||
|
`measure_accumulate_bucket()` (`:217`): a bucket's `lo`/`hi`/`sum` describe raw samples and cannot
|
||||||
|
answer for a filtered signal. Both passes walk raw, which is the 1.2–1.6 ms → ~20 ms step at a
|
||||||
|
2.2 M-sample window that README's measurement-cost section was written about. Against the pane's
|
||||||
|
250 ms cadence that is a few percent of a core and a visible frame hitch.
|
||||||
|
|
||||||
|
This is accepted deliberately: the control is per-measurement and off by default, so nothing gets
|
||||||
|
slower unasked, and `measure cost:`'s `covered`/`buckets`/`raw` makes the cost a *reading* rather
|
||||||
|
than a mystery. `raw ≈ covered` is the signature of a filtered measurement.
|
||||||
|
|
||||||
|
The filter runs inside each of the two raw walks — three flops on a sample the walk is already
|
||||||
|
reading — rather than materializing a filtered window once. Filtering twice is deterministic and
|
||||||
|
free compared with the alternative measured in `MeasureChunk`'s comment.
|
||||||
|
|
||||||
|
NaN keeps the existing rule: the walks already skip a non-finite sample, and the filter state is
|
||||||
|
left alone, so a hole stays a hole and the filter resumes on real samples.
|
||||||
|
|
||||||
|
### Two documented deviations
|
||||||
|
|
||||||
|
1. `docs/claude/invariants-readouts-and-cursors.md` holds that a readout quotes a stored sample
|
||||||
|
and never a value between two. A filtered `max` is a value that was never stored, and with
|
||||||
|
`show_on_plot` its ruler sits at a level the drawn — raw — trace never reaches. This is
|
||||||
|
inherent to filtering, so it becomes a stated exception in that invariant rather than a silent
|
||||||
|
violation.
|
||||||
|
2. The documented exact baselines (`peak to peak = 2`, `top = 0.636620`) hold with the filter
|
||||||
|
off. With it on they move, correctly; the probe reads both.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
Free. A measurement is written as keyed TOML (`layout_store.cpp:659-672`) and read key by key
|
||||||
|
(`:544-556`), so two new keys load in an old session at their defaults.
|
||||||
|
|
||||||
|
## 3. The `de-noise` math node
|
||||||
|
|
||||||
|
### The op, appended
|
||||||
|
|
||||||
|
`MathOp::DeNoise` goes last in the enum (`math_channel.hpp:15-28`) and at index 12 in
|
||||||
|
`math_op_at()` (`math_channel.cpp:99-114`), with `Div` given an explicit `case 11` so the current
|
||||||
|
`default` no longer swallows it; `math_op_count()` becomes 13 and `math_op_name()` gains
|
||||||
|
`"de-noise"`.
|
||||||
|
|
||||||
|
Indices 0–11 keeping their meaning is exactly what leaves every existing session file reading
|
||||||
|
correctly, because a node persists as its index through that accessor. The price is that
|
||||||
|
de-noise sits last in the dialog's dropdown rather than beside the filters, which is the right
|
||||||
|
side of that trade.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
`MathNode` (`math_channel.hpp:48-72`) gains `int med_n = 5;` — odd, 3 to 9, rounded to odd and
|
||||||
|
clamped. `fc` is reused for the HF reject; `q` is unused. `fc = 0` means median only, and it
|
||||||
|
expresses "off" without a branch: the pole's coefficient `a = 1 − exp(−2π·fc·dt)` is exactly 1 at
|
||||||
|
`fc = 0`.
|
||||||
|
|
||||||
|
The coefficient is built in `math_prepare()` (`math_channel.cpp:140-165`) where `dt` is known and
|
||||||
|
rebuilt on a `dt` change, exactly as the biquads are, with the same sub-Nyquist clamp
|
||||||
|
(`:28`).
|
||||||
|
|
||||||
|
### Predicates
|
||||||
|
|
||||||
|
`math_op_uses_filter()` (`math_channel.cpp:135`) keeps meaning **biquad** — it gates coefficient
|
||||||
|
construction at `:162`, and a de-noise node must not be given biquad coefficients. The dialog
|
||||||
|
gets a new `math_op_uses_fc()` (filter ‖ de-noise) and a `math_op_uses_median()` for the new spin
|
||||||
|
row. `math_dialog_sync_form()` (`ui_math.cpp:889-981`) already shows and hides one widget set per
|
||||||
|
op, and `q`'s row being visible for `BandPass` alone is the precedent for a per-op subset of the
|
||||||
|
filter row.
|
||||||
|
|
||||||
|
### Node text
|
||||||
|
|
||||||
|
`math_node_text()` (`layout_store.cpp:358`) emits a 13th pipe field. `math_node_from_text()`
|
||||||
|
accepts **12 or 13** fields, defaulting `med_n` when it reads 12, so existing sessions load
|
||||||
|
unchanged. A file written by this build and read by an older one is rejected by that build's
|
||||||
|
`f.size() != 12u` check (`:385`); there is no forward-compatibility requirement here, but it is
|
||||||
|
recorded rather than left as a surprise.
|
||||||
|
|
||||||
|
### Runtime state, pooled
|
||||||
|
|
||||||
|
`MathRuntime` (`math_channel.hpp:115-120`) gains one `std::vector<double>` holding the median
|
||||||
|
rings of whichever nodes need them; `MathNodeRt` gains an offset into it, a ring position and the
|
||||||
|
effective N. Not a fixed array member, for the reason in the decisions table. One indirection on
|
||||||
|
a path already doing N compares is free, and the choice is reversible if measurement disagrees.
|
||||||
|
|
||||||
|
### Evaluation
|
||||||
|
|
||||||
|
In `math_step()` (`math_channel.cpp:167-230`), the new case is: push the input into the ring,
|
||||||
|
take the median, then one pole step. **Median first** — de-spiking before smoothing is the whole
|
||||||
|
point, and the reverse lets one spike contaminate the filter state for its entire settling time.
|
||||||
|
|
||||||
|
The median is an insertion sort of at most nine doubles in a local array: ~20 compares worst
|
||||||
|
case, ~4 at N=3, against a documented 80–100 ns per slot for a whole three-graph runtime. That
|
||||||
|
ratio is measured and reported through the existing math-cost line, not assumed.
|
||||||
|
|
||||||
|
**Warm-up:** before the ring holds `med_n` samples the node outputs the median of what it holds,
|
||||||
|
which is a correct median of a shorter window. Deliberately not NaN — in this application a NaN
|
||||||
|
output *means a hole in the data* (`math_channel.cpp:64-70`), and spending that meaning on a
|
||||||
|
start-up transient would make a gap ambiguous.
|
||||||
|
|
||||||
|
**NaN** matches `biquad_step` exactly: a non-finite input returns NaN and leaves both the median
|
||||||
|
ring and the pole state alone, so the hole stays visible and the node resumes on real samples.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
There is no unit test suite; the check is the application's own headless self-check under
|
||||||
|
`xvfb-run` against `tools/mock_udpstreamer.py`, read against two baselines (an exact warning
|
||||||
|
count, and no `Gtk-CRITICAL`).
|
||||||
|
|
||||||
|
### The regression assertion, first
|
||||||
|
|
||||||
|
Every default is today's behaviour, so the documented reading set must be **unchanged**:
|
||||||
|
|
||||||
|
- `[condition-probe]`'s four exact latch times (`t0=1.710000`, `2.160100`, `1.706700`,
|
||||||
|
`4.120200`);
|
||||||
|
- `peak to peak=2`, `amplitude=1`, `average=0`, `top=0.636620`;
|
||||||
|
- `measure cost:` with `raw ≪ covered`;
|
||||||
|
- the build's warning count — adding a field to `MathNode` leaves
|
||||||
|
`ui_probe.cpp:304` and `:313` at one `-Wmissing-field-initializers` each, so four on a clean
|
||||||
|
build and two on an incremental one still holds.
|
||||||
|
|
||||||
|
### The mock needs an impulsive source
|
||||||
|
|
||||||
|
`tools/mock_udpstreamer.py` offers `--noise` (Gaussian), `--glitch-at`/`--glitch-width` (a single
|
||||||
|
glitch at one time) and `--clock-jitter`. None of them is a *stream* of EM-like peaks, and a
|
||||||
|
median is the wrong tool for Gaussian noise, so none of the three pieces could be shown to work
|
||||||
|
against the mock as it stands.
|
||||||
|
|
||||||
|
Add `--spike-every S --spike-amp A --spike-width N`: a recurring impulse of N samples at
|
||||||
|
amplitude A, every S seconds. One flag set, and it is what every reading below is measured on.
|
||||||
|
|
||||||
|
### The four readings
|
||||||
|
|
||||||
|
1. **Trigger band** — a new `[condition-probe]` leg on a slow sine plus spikes. With
|
||||||
|
`noise_band = 0` the latch lands on a spike; with the band above the spike amplitude it lands
|
||||||
|
on the sine's own crossing time. The latched `t0` is the assertion, and the four existing
|
||||||
|
shapes are re-read unchanged in the same run.
|
||||||
|
2. **Trigger HF reject** — the same leg with the band off and `hf_fc` set. The latch moves
|
||||||
|
*later* by roughly `1/(2π·fc)`, reported beside the prediction. A latch that does not move is
|
||||||
|
the filter not being in the comparator's path.
|
||||||
|
3. **Measurements** — `Frequency` on the spiky mock read three ways: `noise_pct = 0` (counts
|
||||||
|
spikes, frequency far too high), `= 10` (the true frequency), and `hf_fc` set (the true
|
||||||
|
frequency *and* `raw ≈ covered`, which is the fast-path loss as a reading). Plus `peak to
|
||||||
|
peak` with the filter off and on, since that is the number the filter is supposed to move.
|
||||||
|
4. **The de-noise node** — `math_probe` (`ui_probe.cpp:304`) gains a de-noise graph on the spiky
|
||||||
|
channel. Its output's peak-to-peak must fall to the clean waveform's while a `LowPass` at the
|
||||||
|
same `fc` does not: that contrast is the entire justification for the node existing. Plus the
|
||||||
|
per-slot cost through the existing math-cost line.
|
||||||
|
|
||||||
|
## 5. Documentation
|
||||||
|
|
||||||
|
| File | What it gains |
|
||||||
|
| --- | --- |
|
||||||
|
| `README.md` | A noise-rejection section covering the three places, and the filtered-readout deviation stated rather than quietly taken |
|
||||||
|
| `docs/claude/invariants-readouts-and-cursors.md` | The stated exception: a filtered readout is not a stored sample, and its on-plot ruler can sit where the raw trace never goes |
|
||||||
|
| `docs/claude/invariants-measurements.md` | A filter on a measurement disables both bucket fast paths, and `raw ≈ covered` is its signature |
|
||||||
|
| `docs/claude/invariants-trigger-and-pinning.md` | The band, the `band == 0` verbatim path and why, and the latch delay a filter adds |
|
||||||
|
| `docs/claude/invariants-math-channels.md` | Op indices are append-only because the session format stores them; the pooled median state |
|
||||||
|
| `docs/claude/verification.md`, `docs/claude/probe-readings.md` | The four readings above and what a wrong value names |
|
||||||
|
| `docs/claude/build-and-run.md` | The new mock flags |
|
||||||
|
|
||||||
|
## 6. Out of scope
|
||||||
|
|
||||||
|
- Persisting the trigger. The trigger has never been persisted; the band and the filter follow
|
||||||
|
that, rather than this change being the one that introduces trigger persistence.
|
||||||
|
- Any averaging acquisition mode. Noise rejection here is per-measurement, per-trigger and
|
||||||
|
per-node; nothing changes what the ingest stores, so a raw trace stays raw and every reading
|
||||||
|
remains traceable to stored samples.
|
||||||
|
- Noise rejection on the cursors or the search's own UI. The search inherits the trigger's
|
||||||
|
condition and so inherits both controls; nothing further is added.
|
||||||
+523
-55
@@ -5,6 +5,13 @@
|
|||||||
|
|
||||||
#include <udps_client.h>
|
#include <udps_client.h>
|
||||||
|
|
||||||
|
#include "layout_store.hpp"
|
||||||
|
|
||||||
|
#include <glib/gstdio.h>
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -14,6 +21,12 @@
|
|||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
/// How far a packet's stamps may run ahead before the jump is read as a clock fault rather
|
||||||
|
/// than a stream gap. Also the cap for hole reservation: 256 packet spans in one go is an
|
||||||
|
/// outage, not a loss, and reserving one would push the entire retained history out of the
|
||||||
|
/// ring to make room for samples that are not coming.
|
||||||
|
constexpr double kGlitchPackets = 256.0;
|
||||||
|
|
||||||
SignalInfo to_info(const udps_signal_t& s) {
|
SignalInfo to_info(const udps_signal_t& s) {
|
||||||
SignalInfo info;
|
SignalInfo info;
|
||||||
info.name = s.name;
|
info.name = s.name;
|
||||||
@@ -55,6 +68,13 @@ void Acquisition::start() {
|
|||||||
}
|
}
|
||||||
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
|
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
|
||||||
&Acquisition::event_trampoline, this);
|
&Acquisition::event_trampoline, this);
|
||||||
|
udps_client_set_datagram_cb(client_, &Acquisition::datagram_trampoline, this);
|
||||||
|
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
|
||||||
|
// its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
|
||||||
|
// The open itself still happens on the producer thread, at the first datagram.
|
||||||
|
if (cfg_.capture_packets > 0) {
|
||||||
|
set_capture_want(true, cfg_.capture_packets);
|
||||||
|
}
|
||||||
|
|
||||||
started_ = std::chrono::steady_clock::now();
|
started_ = std::chrono::steady_clock::now();
|
||||||
running_.store(true, std::memory_order_release);
|
running_.store(true, std::memory_order_release);
|
||||||
@@ -85,11 +105,69 @@ void Acquisition::run() {
|
|||||||
out.packets.store(s.packets_received, std::memory_order_relaxed);
|
out.packets.store(s.packets_received, std::memory_order_relaxed);
|
||||||
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
|
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
|
||||||
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
|
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
|
||||||
|
out.resequenced.store(s.late_resequenced, std::memory_order_relaxed);
|
||||||
|
out.late.store(s.late_delivered, std::memory_order_relaxed);
|
||||||
|
out.stale.store(s.stale_packets, std::memory_order_relaxed);
|
||||||
|
out.overflows.store(s.reorder_overflows, std::memory_order_relaxed);
|
||||||
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
|
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
|
||||||
out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed);
|
out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Acquisition::set_capture_want(bool want, std::uint32_t slots) {
|
||||||
|
if (want) {
|
||||||
|
if (slots > 0) capture_slots_ = slots;
|
||||||
|
if (capture_path_.empty()) {
|
||||||
|
// Keyed on the source: one path per source, the previous capture kept one deep.
|
||||||
|
std::string host = cfg_.server;
|
||||||
|
for (char& ch : host) {
|
||||||
|
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
|
||||||
|
}
|
||||||
|
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.port);
|
||||||
|
g_mkdir_with_parents(dir.c_str(), 0700);
|
||||||
|
capture_path_ = dir + "/capture.bin";
|
||||||
|
std::uint32_t addr = 0;
|
||||||
|
struct ::in_addr a;
|
||||||
|
if (::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) {
|
||||||
|
addr = a.s_addr; // already network order, which is what the header wants
|
||||||
|
}
|
||||||
|
capture_.set_peer(addr, cfg_.port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
capture_want_.store(want, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time) {
|
||||||
|
const bool want = capture_want_.load(std::memory_order_acquire);
|
||||||
|
if (want && !capture_.enabled()) {
|
||||||
|
// A user-initiated act that happens once per toggle, on this thread: a rename, a
|
||||||
|
// create, a 62.6 MiB reserve and an mmap. It can cost the packets in flight, and
|
||||||
|
// the measured cost is `capture_.open_ms()`.
|
||||||
|
if (!capture_.open(capture_path_, capture_slots_)) {
|
||||||
|
char err[128];
|
||||||
|
capture_.error(err, sizeof(err));
|
||||||
|
std::fprintf(stderr, "[udpscope] packet capture failed to open: %s\n", err);
|
||||||
|
// Stay off rather than retrying every datagram against a full disk; the GTK
|
||||||
|
// thread reads the flag back and returns the button to off.
|
||||||
|
capture_want_.store(false, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "[udpscope] packet capture on: %s (%u slots, %.1f ms to open)\n",
|
||||||
|
capture_.path().c_str(), capture_.slot_count(), capture_.open_ms());
|
||||||
|
} else if (!want && capture_.enabled()) {
|
||||||
|
capture_.close();
|
||||||
|
std::fprintf(stderr, "[udpscope] packet capture off\n");
|
||||||
|
}
|
||||||
|
if (capture_.enabled()) {
|
||||||
|
capture_.store(bytes, len, recv_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
|
||||||
|
double recv_time, void* user) {
|
||||||
|
static_cast<Acquisition*>(user)->on_datagram(bytes, len, recv_time);
|
||||||
|
}
|
||||||
|
|
||||||
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
||||||
std::uint8_t publish_mode) {
|
std::uint8_t publish_mode) {
|
||||||
std::vector<SignalInfo> info;
|
std::vector<SignalInfo> info;
|
||||||
@@ -112,6 +190,18 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
|||||||
sig.time_signal_idx, sig.unit.c_str());
|
sig.time_signal_idx, sig.unit.c_str());
|
||||||
}
|
}
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
|
// The warn-once latches are per *connection*, not per process. A reconnect re-enters here, and
|
||||||
|
// a diagnostic that goes quiet after the first occurrence of a condition that recurs on the
|
||||||
|
// next session is the failure mode worth avoiding in a feature whose whole job is diagnosis.
|
||||||
|
warned_batch_ = false;
|
||||||
|
warned_outage_ = false;
|
||||||
|
warned_bridge_ = false;
|
||||||
|
warned_time_source_ = false;
|
||||||
|
// The clock choice is per connection too: a reconnect re-derives it from the frames
|
||||||
|
// the new session actually delivers, rather than trusting a decision made about a
|
||||||
|
// session that dropped.
|
||||||
|
time_source_ = 0;
|
||||||
|
reset_session_state();
|
||||||
model_.stats().have_config.store(true, std::memory_order_relaxed);
|
model_.stats().have_config.store(true, std::memory_order_relaxed);
|
||||||
model_.set_signals(std::move(info));
|
model_.set_signals(std::move(info));
|
||||||
}
|
}
|
||||||
@@ -122,9 +212,31 @@ void Acquisition::on_event(int event, const char* detail) {
|
|||||||
model_.stats().connected.store(true, std::memory_order_relaxed);
|
model_.stats().connected.store(true, std::memory_order_relaxed);
|
||||||
} else if (event == UDPS_EVENT_DISCONNECTED) {
|
} else if (event == UDPS_EVENT_DISCONNECTED) {
|
||||||
model_.stats().connected.store(false, std::memory_order_relaxed);
|
model_.stats().connected.store(false, std::memory_order_relaxed);
|
||||||
|
// The session dropped mid-flight: everything the producer knows about *that*
|
||||||
|
// session's numbering and clocks is now a lie about the next one. Clearing it here
|
||||||
|
// is what makes the next frame re-anchor instead of reading as late, as a glitch or
|
||||||
|
// as an outage -- and nothing here touches the buffer, whose history survives the
|
||||||
|
// drop (see `ScopeModel::set_signals`'s unchanged-CONFIG path).
|
||||||
|
reset_session_state();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Acquisition::reset_session_state() noexcept {
|
||||||
|
have_counter_ = false;
|
||||||
|
last_counter_ = 0;
|
||||||
|
have_stream_anchor_ = false;
|
||||||
|
last_stream_anchor_ = 0.0;
|
||||||
|
stamp_failures_ = 0;
|
||||||
|
// The arrival clock's *anchor* stays -- stamps are small seconds the ring's timeline
|
||||||
|
// is already scaled against, and re-anchoring mid-stream would move x -- but the
|
||||||
|
// smoothed interval and the previous arrival are per-connection measurements, and
|
||||||
|
// carrying them across a multi-second outage makes the first new frame read as a
|
||||||
|
// clock fault rather than as the resumption it is.
|
||||||
|
have_arrival_interval_ = false;
|
||||||
|
arrival_interval_ = 0.0;
|
||||||
|
arrival_prev_ = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
|
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
|
||||||
// The frame's span in base ticks: a scalar signal carries `num_samples` values
|
// The frame's span in base ticks: a scalar signal carries `num_samples` values
|
||||||
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
|
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
|
||||||
@@ -135,6 +247,71 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
|
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
|
||||||
}
|
}
|
||||||
stamps_.assign(n, 0.0);
|
stamps_.assign(n, 0.0);
|
||||||
|
abs_stamps_.assign(n, 0.0);
|
||||||
|
|
||||||
|
// Samples per packet, needed in both modes: relative stamping must advance `slot_base_`
|
||||||
|
// past the reserved hole before placing this frame's own stamps, and `on_data` must size
|
||||||
|
// the hole before calling this function -- both operations require knowing how many samples
|
||||||
|
// a missing packet represents, which is only knowable from the first frame that arrives.
|
||||||
|
if (batch_ == 0) {
|
||||||
|
batch_ = n;
|
||||||
|
if (cfg_.relative_time) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] relative time: batch=%u samples/packet, rate=%.6g S/s declared\n",
|
||||||
|
batch_, model_.partition().base_dt > 0.0 ? 1.0 / model_.partition().base_dt
|
||||||
|
: 0.0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
} else if (n != batch_) {
|
||||||
|
// A frame whose length disagrees with the first one is an inconsistency in the
|
||||||
|
// producer, not a loss: the counter still names one packet. Warn once and keep the
|
||||||
|
// established batch, which is only used to size holes -- the timeline itself is built
|
||||||
|
// from the lengths actually seen, so it stays exact for this frame either way.
|
||||||
|
if (!warned_batch_) {
|
||||||
|
warned_batch_ = true;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] warning: packet carries %u samples but the stream's first "
|
||||||
|
"carried %u; keeping %u as the packet length a lost packet is worth.\n",
|
||||||
|
n, batch_, batch_);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative mode: the stamp is a pure function of position and the *declared* rate. Nothing
|
||||||
|
// below this block runs -- not the arrival-cadence override, not the degeneracy guard, not
|
||||||
|
// the glitch bridge, not the monotonic clamp's reason for existing -- because a position
|
||||||
|
// cannot jitter, cannot fail to advance and cannot jump.
|
||||||
|
if (cfg_.relative_time) {
|
||||||
|
double dt = model_.partition().base_dt;
|
||||||
|
if (!(dt > 0.0)) dt = 1.0e-3;
|
||||||
|
// The reserved hole for the missing packets advances `slot_base_` so this frame's
|
||||||
|
// own stamps land *after* the hole rather than on top of it. The same conditions
|
||||||
|
// the reservation call uses: not late, gap present, batch known, within the cap.
|
||||||
|
if (!frame->late && frame->lost > 0 && batch_ > 0 &&
|
||||||
|
double(frame->lost) <= kGlitchPackets) {
|
||||||
|
slot_base_ += u64(frame->lost) * batch_;
|
||||||
|
}
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) {
|
||||||
|
stamps_[k] = double(slot_base_ + k) * dt;
|
||||||
|
}
|
||||||
|
slot_base_ += n;
|
||||||
|
// The absolute clock is still read, for export only. When the stream carries no usable
|
||||||
|
// time signal there is no absolute clock to record and the relative stamp is the honest
|
||||||
|
// answer -- an export then says what the counter says, which is all anyone had.
|
||||||
|
if (time_signal >= 0 && time_signal < static_cast<int>(frame->num_signals)) {
|
||||||
|
const udps_signal_t& ts = frame->signals[time_signal];
|
||||||
|
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) {
|
||||||
|
abs_stamps_[k] =
|
||||||
|
scalar_time
|
||||||
|
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
|
||||||
|
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
double period = model_.partition().base_dt;
|
double period = model_.partition().base_dt;
|
||||||
if (!(period > 0.0)) period = 1.0e-3;
|
if (!(period > 0.0)) period = 1.0e-3;
|
||||||
@@ -208,47 +385,89 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stream-time signal may be declared in a time unit rather than seconds ('ns' is
|
||||||
|
// the common one). The scale has to be known *here*, before the checks below, because
|
||||||
|
// they weigh the stamps' own spacing against the declared cadence, which is in
|
||||||
|
// seconds: on a 'ns' stream an unscaled comparison is off by a factor of 1e9, so
|
||||||
|
// every packet would read as an implausible jump.
|
||||||
|
double unit_scale = 1.0;
|
||||||
|
if (time_signal >= 0 && time_signal < static_cast<int>(model_.signals().size())) {
|
||||||
|
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
|
||||||
|
if (unit == "ns") unit_scale = 1e-9;
|
||||||
|
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
|
||||||
|
else if (unit == "ms") unit_scale = 1e-3;
|
||||||
|
}
|
||||||
|
|
||||||
if (used_stream_time && n > 0) {
|
if (used_stream_time && n > 0) {
|
||||||
|
// One sample's worth of the time signal's *own* unit, which is what a synthesised
|
||||||
|
// continuation has to be spaced by and what a jump is measured against.
|
||||||
|
const double step = cadence / unit_scale;
|
||||||
|
// How many packets in a row must fail before the declared time signal is abandoned
|
||||||
|
// for the arrival clock. A glitch is one packet; a signal that is not a time base
|
||||||
|
// at all fails every one of them.
|
||||||
|
constexpr unsigned kFailureLimit = 4;
|
||||||
|
|
||||||
// A time signal that does not actually vary -- a vector time signal, whose
|
// A time signal that does not actually vary -- a vector time signal, whose
|
||||||
// values the client reads one element at a time, or a producer that leaves it
|
// values the client reads one element at a time, or a producer that leaves it
|
||||||
// zero -- would put every sample at one x and collapse the axis to a point.
|
// zero -- would put every sample at one x and collapse the axis to a point.
|
||||||
// The arrival estimate is a worse clock but a usable one; a degenerate one is
|
// The arrival estimate is a worse clock but a usable one; a degenerate one is
|
||||||
// not a time base at all.
|
// not a time base at all.
|
||||||
bool usable = std::isfinite(stamps_[0]);
|
const char* fault = nullptr;
|
||||||
double lo = stamps_[0];
|
double lo = stamps_[0];
|
||||||
double hi = stamps_[0];
|
double hi = stamps_[0];
|
||||||
for (std::uint32_t k = 1; usable && k < n; ++k) {
|
if (!std::isfinite(stamps_[0])) fault = "carries a stamp that is not finite";
|
||||||
|
for (std::uint32_t k = 1; fault == nullptr && k < n; ++k) {
|
||||||
if (!std::isfinite(stamps_[k])) {
|
if (!std::isfinite(stamps_[k])) {
|
||||||
usable = false;
|
fault = "carries a stamp that is not finite";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
lo = std::min(lo, stamps_[k]);
|
lo = std::min(lo, stamps_[k]);
|
||||||
hi = std::max(hi, stamps_[k]);
|
hi = std::max(hi, stamps_[k]);
|
||||||
}
|
}
|
||||||
if (usable && hi > lo) {
|
if (fault == nullptr && !(hi > lo)) fault = "carries no span (every stamp is equal)";
|
||||||
// The stamps must also ADVANCE across packets. A producer that restarts its
|
if (fault == nullptr && have_stream_anchor_) {
|
||||||
// stamps every packet (or a signal mislabelled as time) keeps handing back
|
// The stamps must also ADVANCE across packets, and by a plausible amount. A
|
||||||
// the same values; the max-clamp would then pin every sample at one x, and
|
// producer that restarts its stamps every packet (or a signal mislabelled as
|
||||||
// the trigger's hold-off clock with it -- the engine waited forever and no
|
// time) keeps handing back the same values; the max-clamp would then pin every
|
||||||
// second event ever fired.
|
// sample at one x, and the trigger's hold-off clock with it -- the engine waited
|
||||||
if (have_stream_anchor_ && !(stamps_[n - 1] > last_stream_anchor_)) {
|
// forever and no second event ever fired.
|
||||||
usable = false;
|
if (!(stamps_[n - 1] > last_stream_anchor_)) {
|
||||||
} else {
|
fault = "does not advance across packets";
|
||||||
|
} else if (stamps_[0] - last_stream_anchor_ > kGlitchPackets * double(n) * step) {
|
||||||
|
fault = "jumped implausibly far ahead of the previous packet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fault == nullptr) {
|
||||||
|
stamp_failures_ = 0;
|
||||||
last_stream_anchor_ = stamps_[n - 1];
|
last_stream_anchor_ = stamps_[n - 1];
|
||||||
have_stream_anchor_ = true;
|
have_stream_anchor_ = true;
|
||||||
}
|
} else if (have_stream_anchor_ && ++stamp_failures_ < kFailureLimit) {
|
||||||
}
|
// A short run of bad packets is bridged rather than believed: the stamps are
|
||||||
if (!usable || !(hi > lo)) {
|
// replaced by a continuation of the accepted timeline at the declared cadence,
|
||||||
used_stream_time = false; // no span to time with; fall back to arrival
|
// so neither the monotonic mark nor the anchor is poisoned, and the next good
|
||||||
static bool warned = false;
|
// packet -- which carries on from where the true timeline was -- is accepted
|
||||||
if (!warned) {
|
// normally. Believing one glitching packet instead is what condemned the *next*,
|
||||||
warned = true;
|
// perfectly good one and threw the whole stream onto the arrival clock.
|
||||||
|
const double from = last_stream_anchor_;
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = from + double(k + 1) * step;
|
||||||
|
last_stream_anchor_ = stamps_[n - 1];
|
||||||
|
if (!warned_bridge_) {
|
||||||
|
warned_bridge_ = true;
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"[udpscope] warning: the stream's time signal carries no usable span "
|
"[udpscope] warning: the stream's time signal %s (%g..%g against a "
|
||||||
"(%g..%g) or does not advance across packets; timing samples by "
|
"previous %g); bridging that packet at the declared cadence and "
|
||||||
"arrival instead. Check the time signal's mode and index in the "
|
"keeping the stream clock.\n",
|
||||||
"CONFIG dump above.\n",
|
fault, lo, hi, from);
|
||||||
lo, hi);
|
}
|
||||||
|
} else {
|
||||||
|
used_stream_time = false; // not a time base; fall back to arrival
|
||||||
|
if (!warned_time_source_) {
|
||||||
|
warned_time_source_ = true;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] warning: the stream's time signal %s (%g..%g); timing "
|
||||||
|
"samples by arrival instead. Check the time signal's mode and index "
|
||||||
|
"in the CONFIG dump above.\n",
|
||||||
|
fault, lo, hi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,34 +525,30 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
if (time_source_ == 0) {
|
if (time_source_ == 0) {
|
||||||
time_source_ = used_stream_time ? 1 : 2;
|
time_source_ = used_stream_time ? 1 : 2;
|
||||||
} else if (time_source_ == 1 && !used_stream_time) {
|
} else if (time_source_ == 1 && !used_stream_time) {
|
||||||
// The declared time signal failed its checks: keep the arrival clock from here
|
// The declared time signal failed its checks for good: keep the arrival clock from
|
||||||
// on, discard the stamps already recorded on the old timeline, and make the
|
// here on, anchored so that it continues from the monotonic mark. The two timelines
|
||||||
// arrival clock continue from where the mark is, so the axis has no step.
|
// then share a zero *by construction*, which is the whole point of deriving the
|
||||||
|
// epoch from `last_stamp_` -- so the recorded history is still on one timeline and
|
||||||
|
// is kept. It used to be wiped here, on the reasoning that the old stamps belonged
|
||||||
|
// to a dead clock; they do not, and the wipe is what made a single glitching packet
|
||||||
|
// look like the stream restarting from zero.
|
||||||
time_source_ = 2;
|
time_source_ = 2;
|
||||||
const double end = frame->recv_time;
|
const double end = frame->recv_time;
|
||||||
arrival_epoch_ = end - (last_stamp_ + model_.epoch());
|
arrival_epoch_ = end - (last_stamp_ + model_.epoch());
|
||||||
arrival_prev_ = end;
|
arrival_prev_ = end;
|
||||||
last_stamp_ = 0.0;
|
|
||||||
history_reset_pending_ = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stream-time signal may be declared in a time unit rather than seconds
|
// The declared unit, applied last so the x axis is in seconds either way. It applies
|
||||||
// ('ns' is the common one): scale it to seconds so the x axis is in seconds
|
// ONLY to stream time: the arrival fallback already produces seconds off the socket
|
||||||
// either way. The scale applies ONLY to stream time: the arrival fallback
|
// clock -- scaling those by the time signal's unit shrank the whole time base by 1e9
|
||||||
// already produces seconds off the socket clock -- scaling those by the time
|
// on 'ns' streams, which froze the trigger's hold-off clock and pinned the axis at ~0.
|
||||||
// signal's unit shrank the whole time base by 1e9 on 'ns' streams, which
|
if (used_stream_time && unit_scale != 1.0) {
|
||||||
// froze the trigger's hold-off clock and pinned the axis at ~0.
|
|
||||||
if (used_stream_time && time_signal >= 0 &&
|
|
||||||
time_signal < static_cast<int>(model_.signals().size())) {
|
|
||||||
double unit_scale = 1.0;
|
|
||||||
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
|
|
||||||
if (unit == "ns") unit_scale = 1e-9;
|
|
||||||
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
|
|
||||||
else if (unit == "ms") unit_scale = 1e-3;
|
|
||||||
if (unit_scale != 1.0) {
|
|
||||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Stream mode: `time()` already holds the absolute clock, so the parallel ring repeats it
|
||||||
|
// rather than carrying a second timeline. One rule for the export either way.
|
||||||
|
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
||||||
|
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
@@ -343,10 +558,33 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
std::lock_guard<std::mutex> lock(model_.mutex());
|
std::lock_guard<std::mutex> lock(model_.mutex());
|
||||||
if (!running_.load(std::memory_order_relaxed)) return;
|
if (!running_.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
|
// The counter is this frame's identity and the hole's address. A *late* frame's counter is
|
||||||
|
// behind the sequence, so it must not move the mark -- the hole it belongs in was addressed
|
||||||
|
// from the counter that revealed it.
|
||||||
|
if (!have_counter_ || static_cast<std::int32_t>(frame->counter - last_counter_) > 0) {
|
||||||
|
last_counter_ = frame->counter;
|
||||||
|
have_counter_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
const Partition& part = model_.partition();
|
const Partition& part = model_.partition();
|
||||||
const std::vector<ChannelInfo>& channels = model_.channels();
|
const std::vector<ChannelInfo>& channels = model_.channels();
|
||||||
if (part.time_capacity == 0 || channels.empty()) return;
|
if (part.time_capacity == 0 || channels.empty()) return;
|
||||||
|
|
||||||
|
// A frame delivered behind the sequence: the slots it owns were reserved as NaN by
|
||||||
|
// `reserve_hole()`, so it writes values into them and does nothing else. It is deliberately
|
||||||
|
// not stamped, does not advance `tick_`, and never reaches the trigger or the math channels:
|
||||||
|
// the time ring was pre-stamped at reservation and those consumers are stateful and have
|
||||||
|
// already run past this region. That is the trade the design takes -- the raw trace repairs,
|
||||||
|
// a derived trace keeps the gap, and nothing that was computed from the gap is retrofitted.
|
||||||
|
//
|
||||||
|
// Placed here and not earlier because `part`/`channels` are not in scope above, and not
|
||||||
|
// later because `build_stamps()` is stateful: a late frame's stamps would walk the clock
|
||||||
|
// backwards and can throw the whole stream onto the arrival clock.
|
||||||
|
if (frame->late) {
|
||||||
|
backfill_from(frame, part, channels, model_.live(), part.time_stride);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The time reference, in order of preference: the signal the first channel's
|
// The time reference, in order of preference: the signal the first channel's
|
||||||
// CONFIG entry names via `time_signal_idx`, or -- when the stream instead
|
// CONFIG entry names via `time_signal_idx`, or -- when the stream instead
|
||||||
// carries its time base as an explicit full-array signal -- that signal.
|
// carries its time base as an explicit full-array signal -- that signal.
|
||||||
@@ -371,12 +609,6 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
}
|
}
|
||||||
const std::uint32_t n = build_stamps(frame, time_signal);
|
const std::uint32_t n = build_stamps(frame, time_signal);
|
||||||
if (n == 0) return;
|
if (n == 0) return;
|
||||||
// A change of time source invalidated the recorded history: empty the rings before
|
|
||||||
// anything more goes in, so no reader can find two timelines under one index.
|
|
||||||
if (history_reset_pending_) {
|
|
||||||
history_reset_pending_ = false;
|
|
||||||
model_.reset_live_history_locked(); // this thread holds the model lock
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model_.epoch() == 0.0) {
|
if (model_.epoch() == 0.0) {
|
||||||
model_.set_epoch_if_unset(stamps_[0]);
|
model_.set_epoch_if_unset(stamps_[0]);
|
||||||
@@ -394,6 +626,31 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// event window out into an immutable `EventSnapshot`.
|
// event window out into an immutable `EventSnapshot`.
|
||||||
BufferStore& store = model_.live();
|
BufferStore& store = model_.live();
|
||||||
const u64 g = part.time_stride;
|
const u64 g = part.time_stride;
|
||||||
|
// `tick % g == g - 1` as a mask. `plan_partition()` doubles `time_stride` from 1, so `g` is
|
||||||
|
// a power of two -- but it is a *runtime* value, so the modulo compiled to a real 64-bit
|
||||||
|
// division rather than the cheap test its comment there claims. See `acc_mask_` for the
|
||||||
|
// per-channel form and for what that cost on the hot loop.
|
||||||
|
const u64 g_mask = g - 1;
|
||||||
|
|
||||||
|
// A re-plan replaced the store, so every ring's `produced()` restarted at 0 while `tick_`
|
||||||
|
// kept counting. `backfill_from()` derives a slot index from a hole's `tick_begin`, which is
|
||||||
|
// only an absolute slot index while `tick_ / period == produced()`, so the tick counter has
|
||||||
|
// to restart with the buffer -- otherwise the amend lands outside the ring, repairs nothing,
|
||||||
|
// and the hole is still marked repaired, which is the band saying the opposite of the truth.
|
||||||
|
// The generation is the signal rather than the stride/channel-count comparison below: only
|
||||||
|
// a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
|
||||||
|
// keeps it, history and all (see `ScopeModel::set_signals`).
|
||||||
|
// `slot_base_` is deliberately *not* reset -- it is relative mode's clock origin, samples
|
||||||
|
// since the stream began, and relative time must not jump backwards when the user retypes
|
||||||
|
// the window.
|
||||||
|
if (const unsigned gen = model_.plan_generation(); gen != seen_plan_generation_) {
|
||||||
|
seen_plan_generation_ = gen;
|
||||||
|
tick_ = 0;
|
||||||
|
last_tick_ = ~std::uint64_t{0};
|
||||||
|
// Zeroing the stride makes the guard below fire too: the accumulators belong to the group
|
||||||
|
// `tick_` was counting through, so they are stale for the same reason.
|
||||||
|
acc_stride_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
|
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
|
||||||
// finish the one it was in with the wrong length.
|
// finish the one it was in with the wrong length.
|
||||||
@@ -403,10 +660,47 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
value_acc_.assign(channels.size(), 0.0);
|
value_acc_.assign(channels.size(), 0.0);
|
||||||
value_acc_n_.assign(channels.size(), 0);
|
value_acc_n_.assign(channels.size(), 0);
|
||||||
math_sample_.assign(channels.size(), std::nanf(""));
|
math_sample_.assign(channels.size(), std::nanf(""));
|
||||||
|
// The group boundaries, as masks, for the per-sample loops below. Here rather than in
|
||||||
|
// them: `g` and the strides are properties of the partition, and a partition change is
|
||||||
|
// exactly what this block already reacts to.
|
||||||
|
acc_mask_.resize(channels.size());
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
acc_mask_[i] = g * std::max<u64>(1, part.stride[i]) - 1;
|
||||||
|
}
|
||||||
time_acc_ = 0.0;
|
time_acc_ = 0.0;
|
||||||
|
abs_time_acc_ = 0.0;
|
||||||
time_acc_n_ = 0;
|
time_acc_n_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gap the counter reports, reserved as NaN before this frame's own samples are
|
||||||
|
// stamped: the hole belongs *ahead* of the data that revealed it. Running the missing
|
||||||
|
// samples through the same per-sample loop below is the whole trick -- `tick_` advances
|
||||||
|
// so the decimation phase does not shift, the group mean lands on the mean stamp, and
|
||||||
|
// `MinMaxPyramid::append` sees NaN, which its non-finite contract already stores as an
|
||||||
|
// empty aggregate every reader treats as absent.
|
||||||
|
//
|
||||||
|
// Capped at `kGlitchPackets`: 256 packet spans in one go is an outage, not loss, and
|
||||||
|
// reserving one would push the entire retained history out of the ring to make room for
|
||||||
|
// samples that are not coming.
|
||||||
|
//
|
||||||
|
// This block must follow the re-plan above: reserve_hole() runs the missing samples
|
||||||
|
// through the same decimation accumulators (value_acc_, value_acc_n_, math_sample_),
|
||||||
|
// which must already be sized to the current channel count and stride before any
|
||||||
|
// per-sample write.
|
||||||
|
const std::uint32_t missing = frame->lost;
|
||||||
|
if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
|
||||||
|
reserve_hole(missing, part, channels, store, g);
|
||||||
|
} else if (missing > 0 && !warned_outage_) {
|
||||||
|
warned_outage_ = true;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] warning: %u packets missing in one go, past the %g-packet "
|
||||||
|
"reservation cap; treating it as an outage and carrying on rather than "
|
||||||
|
"reserving %llu samples of hole.\n",
|
||||||
|
missing, kGlitchPackets,
|
||||||
|
static_cast<unsigned long long>(std::uint64_t(missing) * batch_));
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
// Calibration is read per frame; it only changes under the same lock.
|
// Calibration is read per frame; it only changes under the same lock.
|
||||||
TriggerEngine& engine = engine_;
|
TriggerEngine& engine = engine_;
|
||||||
const int trig_channel = tc.channel;
|
const int trig_channel = tc.channel;
|
||||||
@@ -427,7 +721,15 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
const double post_want = tc.post_span_s(event_span);
|
const double post_want = tc.post_span_s(event_span);
|
||||||
const double oldest_s =
|
const double oldest_s =
|
||||||
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
|
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
|
||||||
engine.configure(tc, post_want, event_span - post_want, oldest_s);
|
// The channel's *name* goes with it, because that is what identifies it across a
|
||||||
|
// rebuild of the channel list (see `TriggerEngine::configure`).
|
||||||
|
static const std::string kNoTriggerChannel;
|
||||||
|
const std::string& trig_name =
|
||||||
|
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
|
||||||
|
? channels[static_cast<std::size_t>(trig_channel)].name
|
||||||
|
: kNoTriggerChannel;
|
||||||
|
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s,
|
||||||
|
model_.partition().slot_dt());
|
||||||
// The engine's armed state follows the panel's, both ways: without the disarm
|
// The engine's armed state follows the panel's, both ways: without the disarm
|
||||||
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
||||||
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
||||||
@@ -508,6 +810,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
|
|
||||||
for (std::uint32_t k = 0; k < n; ++k) {
|
for (std::uint32_t k = 0; k < n; ++k) {
|
||||||
const u64 tick = tick_++;
|
const u64 tick = tick_++;
|
||||||
|
// The shared clock's group boundary, read once and reused by the time ring, the channel
|
||||||
|
// loop's own per-channel test and the math push: three tests of the same thing.
|
||||||
|
const bool group_end = (tick & g_mask) == g_mask;
|
||||||
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
||||||
last_stamp_used = rel;
|
last_stamp_used = rel;
|
||||||
last_stamp_ = rel;
|
last_stamp_ = rel;
|
||||||
@@ -516,10 +821,18 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// boxcar's group delay puts the averaged value, and stamping it at the end of the
|
// boxcar's group delay puts the averaged value, and stamping it at the end of the
|
||||||
// group would shift every trace half a stored sample late.
|
// group would shift every trace half a stored sample late.
|
||||||
time_acc_ += rel;
|
time_acc_ += rel;
|
||||||
|
last_abs_stamp_ = abs_stamps_[k] - epoch;
|
||||||
|
abs_time_acc_ += last_abs_stamp_;
|
||||||
++time_acc_n_;
|
++time_acc_n_;
|
||||||
if (tick % g == g - 1) {
|
if (group_end) {
|
||||||
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||||
time_acc_ = 0.0;
|
time_acc_ = 0.0;
|
||||||
|
// The absolute clock on the same slot and under the same group mean, so an export can
|
||||||
|
// pair value, relative stamp and absolute stamp by index with no interpolation.
|
||||||
|
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
||||||
|
abs->push(abs_time_acc_ / static_cast<double>(time_acc_n_));
|
||||||
|
}
|
||||||
|
abs_time_acc_ = 0.0;
|
||||||
time_acc_n_ = 0;
|
time_acc_n_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,8 +869,8 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
value_acc_[i] += raw * ch.gain + ch.offset;
|
value_acc_[i] += raw * ch.gain + ch.offset;
|
||||||
++value_acc_n_[i];
|
++value_acc_n_[i];
|
||||||
|
|
||||||
const u64 period = g * part.stride[i];
|
const u64 mask = acc_mask_[i];
|
||||||
if (tick % period != period - 1) continue;
|
if ((tick & mask) != mask) continue;
|
||||||
const f32 value =
|
const f32 value =
|
||||||
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
|
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
|
||||||
value_acc_[i] = 0.0;
|
value_acc_[i] = 0.0;
|
||||||
@@ -581,7 +894,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
|
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
|
||||||
// channel pushed after it would be missing its newest sample from the capture -- the same
|
// channel pushed after it would be missing its newest sample from the capture -- the same
|
||||||
// one-sample clamp the deferred latch above exists for.
|
// one-sample clamp the deferred latch above exists for.
|
||||||
if (tick % g == g - 1) model_.push_math_sample(math_sample_);
|
if (group_end) model_.push_math_sample(math_sample_);
|
||||||
if (latch_here) latch(latch_edge);
|
if (latch_here) latch(latch_edge);
|
||||||
}
|
}
|
||||||
last_stamp_ = last_stamp_used;
|
last_stamp_ = last_stamp_used;
|
||||||
@@ -614,6 +927,161 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
||||||
|
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
||||||
|
u64 g) {
|
||||||
|
const u64 raw = u64(missing) * u64(batch_);
|
||||||
|
Hole h;
|
||||||
|
h.tick_begin = tick_;
|
||||||
|
h.slot_begin = store.time().produced();
|
||||||
|
// `lost` counts the packets missing *immediately before* the frame that revealed them
|
||||||
|
// (see `udps_frame_t::lost`), and the counter mark at the top of `on_data` has already
|
||||||
|
// moved `last_counter_` to that frame's own counter -- so the hole spans
|
||||||
|
// [counter - missing, counter). This read `last_counter_ + 1u`, which addressed a span
|
||||||
|
// one past the revealing frame and `missing + 1` too high: no late packet's counter could
|
||||||
|
// fall in it, so every backfill lookup missed and `backfilled` read 0 against 154 holes.
|
||||||
|
h.counter_first = last_counter_ - missing;
|
||||||
|
h.packets = missing;
|
||||||
|
|
||||||
|
double dt = part.base_dt;
|
||||||
|
if (!(dt > 0.0)) dt = 1.0e-3;
|
||||||
|
// The same group tests as the live loop, written the same way: a hole's samples go through
|
||||||
|
// the same accumulators, so the two phases have to agree sample for sample.
|
||||||
|
const u64 g_mask = g - 1;
|
||||||
|
for (u64 j = 0; j < raw; ++j) {
|
||||||
|
const u64 tick = tick_++;
|
||||||
|
const bool group_end = (tick & g_mask) == g_mask;
|
||||||
|
// The hole is stamped at the declared cadence from the last good stamp -- exact in
|
||||||
|
// relative mode, and in stream mode the same continuation the glitch bridge makes.
|
||||||
|
// It must be *stamped* rather than left short: the time ring is binary-searched by
|
||||||
|
// `ChannelBuffer::neighbours()`, so it has to stay monotone and dense, and backfill
|
||||||
|
// then changes values only and never a stamp.
|
||||||
|
const double rel = last_stamp_ + dt;
|
||||||
|
last_stamp_ = rel;
|
||||||
|
last_abs_stamp_ += dt;
|
||||||
|
if (j == 0) h.x_begin = rel;
|
||||||
|
h.x_end = rel;
|
||||||
|
|
||||||
|
time_acc_ += rel;
|
||||||
|
abs_time_acc_ += last_abs_stamp_;
|
||||||
|
++time_acc_n_;
|
||||||
|
if (group_end) {
|
||||||
|
store.time().push(time_acc_ / double(time_acc_n_));
|
||||||
|
// Null in stream mode, where `time()` already is the absolute clock.
|
||||||
|
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
||||||
|
abs->push(abs_time_acc_ / double(time_acc_n_));
|
||||||
|
}
|
||||||
|
time_acc_ = 0.0;
|
||||||
|
abs_time_acc_ = 0.0;
|
||||||
|
time_acc_n_ = 0;
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
if (channels[i].math) continue;
|
||||||
|
// A NaN in the accumulator makes the whole group NaN, which is why a slot straddling
|
||||||
|
// a hole edge is NaN too: its real members were averaged into a sum that is now
|
||||||
|
// non-finite and were never retained, so the backfill cannot recover it and must not
|
||||||
|
// pretend to. One slot per edge, against a hole thousands of slots wide.
|
||||||
|
value_acc_[i] += std::numeric_limits<double>::quiet_NaN();
|
||||||
|
++value_acc_n_[i];
|
||||||
|
const u64 mask = acc_mask_[i];
|
||||||
|
if ((tick & mask) != mask) continue;
|
||||||
|
store.values(i).push(static_cast<f32>(value_acc_[i] / double(value_acc_n_[i])));
|
||||||
|
value_acc_[i] = 0.0;
|
||||||
|
value_acc_n_[i] = 0;
|
||||||
|
}
|
||||||
|
// The math channels are fed NaN like everything else, so a derived trace keeps the gap
|
||||||
|
// its source had. The trigger is *not* fed: a NaN comparison is false, so the engine
|
||||||
|
// could not latch here anyway, and feeding it would advance its hold-off clock over
|
||||||
|
// time nothing was sampled in.
|
||||||
|
if (group_end) {
|
||||||
|
std::fill(math_sample_.begin(), math_sample_.end(), std::nanf(""));
|
||||||
|
model_.push_math_sample(math_sample_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.slot_count = store.time().produced() - h.slot_begin;
|
||||||
|
model_.note_hole(h);
|
||||||
|
model_.stats().holes.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
model_.stats().hole_slots.fetch_add(h.slot_count, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part,
|
||||||
|
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
||||||
|
u64 g) {
|
||||||
|
// `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame
|
||||||
|
// before any good one therefore has no packet length to size its span with, and no hole
|
||||||
|
// could have been reserved for it either.
|
||||||
|
if (batch_ == 0) return;
|
||||||
|
|
||||||
|
// Which hole this packet belongs to, by counter: that is the whole reason relative timing
|
||||||
|
// and resequencing are one design. A stamp could not answer it -- the packet's own stamps
|
||||||
|
// are a claim about a timeline the ring has moved past -- but the counter indexes the
|
||||||
|
// reservation table directly.
|
||||||
|
// By signed delta, the idiom the client uses throughout: the counter is a wrapping uint32, so
|
||||||
|
// a raw `>=`/`<` pair matches nothing for a hole straddling the 2^32 wrap and every one of its
|
||||||
|
// late packets would be dropped on the next line.
|
||||||
|
Hole* hole = nullptr;
|
||||||
|
for (Hole& h : model_.holes()) {
|
||||||
|
const std::int32_t into = static_cast<std::int32_t>(frame->counter - h.counter_first);
|
||||||
|
if (into >= 0 && into < static_cast<std::int32_t>(h.packets)) {
|
||||||
|
hole = &h;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hole == nullptr) return; // its hole has already slid out of the ring, or never existed
|
||||||
|
|
||||||
|
// The packet's own span in absolute base ticks. `tick_begin` is the hole's first raw sample
|
||||||
|
// and the packets inside a hole are consecutive, so the counter's distance from
|
||||||
|
// `counter_first` places this one exactly.
|
||||||
|
const u64 lo = hole->tick_begin + u64(frame->counter - hole->counter_first) * u64(batch_);
|
||||||
|
const u64 hi = lo + u64(batch_);
|
||||||
|
|
||||||
|
u64 filled = 0;
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
// A derived channel has no packet signal to read, and the design does not retrofit it:
|
||||||
|
// its gap stays.
|
||||||
|
if (channels[i].math) continue;
|
||||||
|
const u64 period = g * part.stride[i];
|
||||||
|
// Only slots lying *wholly* inside this packet's span are repaired. A slot straddling the
|
||||||
|
// span's edge mixes this packet's samples with ones still missing, and its real members
|
||||||
|
// were never retained -- `reserve_hole()` averaged a NaN into them -- so it stays NaN, and
|
||||||
|
// a displayed hole is at most one slot wider per edge than the true loss.
|
||||||
|
const u64 first_slot = (lo + period - 1) / period;
|
||||||
|
const u64 last_slot = hi / period;
|
||||||
|
for (u64 s = first_slot; s < last_slot; ++s) {
|
||||||
|
// The same boxcar mean the live path stores, over the same group of raw samples: the
|
||||||
|
// whole group is inside this packet, so the average is exact and not an approximation
|
||||||
|
// of what the live path would have written.
|
||||||
|
double acc = 0.0;
|
||||||
|
for (u64 j = 0; j < period; ++j) {
|
||||||
|
const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1
|
||||||
|
const double raw =
|
||||||
|
channels[i].elements > 1
|
||||||
|
? udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal), 0,
|
||||||
|
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
|
||||||
|
channels[i].elements - 1))
|
||||||
|
: udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal),
|
||||||
|
static_cast<std::uint32_t>(k), 0);
|
||||||
|
acc += raw * channels[i].gain + channels[i].offset;
|
||||||
|
}
|
||||||
|
const f32 value = static_cast<f32>(acc / double(period));
|
||||||
|
// The ring first, by the producer that owns its writes; the pyramid is queued for the
|
||||||
|
// consumer (see `ChannelBuffer::queue_amend`). A slot the ring has already discarded
|
||||||
|
// returns false, and then there is no aggregate to amend either.
|
||||||
|
if (store.values(i).amend(s, value)) {
|
||||||
|
store.channel(i).queue_amend(s, value);
|
||||||
|
++filled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This packet's share of the hole has been delivered, whatever the ring still retains of it.
|
||||||
|
// Marked on *arrival* and not on `filled`: a decimation coarse enough that no whole slot lies
|
||||||
|
// inside one packet repairs no slot at all, and keying off `filled` would leave such a hole
|
||||||
|
// permanently unrepairable -- a solid band over data that is all there, which is the exact
|
||||||
|
// defect this task closes.
|
||||||
|
++hole->filled_packets;
|
||||||
|
model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
// Static trampolines: the client's C ABI cannot call a member function directly.
|
// Static trampolines: the client's C ABI cannot call a member function directly.
|
||||||
void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
||||||
std::uint8_t publish_mode, void* user) {
|
std::uint8_t publish_mode, void* user) {
|
||||||
|
|||||||
+93
-3
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "capture.hpp"
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
#include "trigger.hpp"
|
#include "trigger.hpp"
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <limits>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -37,6 +39,19 @@ class Acquisition {
|
|||||||
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
||||||
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// Turns the packet capture on or off -- the whole GTK side of the feature. The
|
||||||
|
/// path is built here, once, *before* the flag is published, so the producer reads
|
||||||
|
/// a complete string whenever it reads a true flag. `slots` is the ring's size;
|
||||||
|
/// zero means the 1000-slot default. The producer does the open or the close at the
|
||||||
|
/// top of the next datagram, so a toggle costs no lock and no second thread.
|
||||||
|
void set_capture_want(bool want, std::uint32_t slots = 0);
|
||||||
|
bool capture_want() const noexcept {
|
||||||
|
return capture_want_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
/// The capture, for the popover's row and the probe. Counters and errors are read
|
||||||
|
/// cross-thread through PacketCapture's own published fields.
|
||||||
|
const PacketCapture& capture() const noexcept { return capture_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void run();
|
void run();
|
||||||
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
|
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
|
||||||
@@ -47,18 +62,48 @@ class Acquisition {
|
|||||||
std::uint8_t publish_mode, void* user);
|
std::uint8_t publish_mode, void* user);
|
||||||
static void data_trampoline(const udps_frame_t* frame, void* user);
|
static void data_trampoline(const udps_frame_t* frame, void* user);
|
||||||
static void event_trampoline(udps_event_t event, const char* detail, void* user);
|
static void event_trampoline(udps_event_t event, const char* detail, void* user);
|
||||||
|
static void datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
|
||||||
|
double recv_time, void* user);
|
||||||
|
/// Drops the producer state that is per *connection*: the counter mark, the time
|
||||||
|
/// anchors and the clock choice. A reconnect re-sends the CONFIG and the stream may
|
||||||
|
/// have restarted its numbering; carrying the old session's marks across makes the
|
||||||
|
/// first new frames read as late, as glitches or as an outage, which is recovery
|
||||||
|
/// failing exactly when it matters. Called on the producer thread, from on_event().
|
||||||
|
void reset_session_state() noexcept;
|
||||||
|
/// The producer side of the toggle: open on a rising edge, close on a falling one,
|
||||||
|
/// then store if enabled. Every file operation happens here, on this thread.
|
||||||
|
void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time);
|
||||||
|
|
||||||
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
|
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
|
||||||
/// and returns how many were written (`frame->num_samples` unless the frame is
|
/// and returns how many were written (`frame->num_samples` unless the frame is
|
||||||
/// malformed).
|
/// malformed).
|
||||||
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
|
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
|
||||||
|
|
||||||
|
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
|
||||||
|
/// advances the decimation accumulators and tick_, records the hole in the model, and
|
||||||
|
/// bumps the hole counters. Called before the frame's own samples so the gap sits
|
||||||
|
/// *ahead* of the data that revealed it.
|
||||||
|
void reserve_hole(std::uint32_t missing, const Partition& part,
|
||||||
|
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||||
|
|
||||||
|
/// Writes a late frame's values into the NaN-filled holes their counter addresses,
|
||||||
|
/// amending the value ring directly (producer path) and queuing pyramid amends for
|
||||||
|
/// `ChannelBuffer::acquire()` to apply on the consumer thread.
|
||||||
|
void backfill_from(const udps_frame_t* frame, const Partition& part,
|
||||||
|
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||||
|
|
||||||
ScopeModel& model_;
|
ScopeModel& model_;
|
||||||
Config cfg_;
|
Config cfg_;
|
||||||
udps_client* client_ = nullptr;
|
udps_client* client_ = nullptr;
|
||||||
std::thread thread_;
|
std::thread thread_;
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
std::atomic<bool> paused_{false};
|
std::atomic<bool> paused_{false};
|
||||||
|
PacketCapture capture_;
|
||||||
|
std::atomic<bool> capture_want_{false};
|
||||||
|
std::uint32_t capture_slots_ = 1000;
|
||||||
|
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
|
||||||
|
/// the producer reads it only after the flag, so no lock is needed.
|
||||||
|
std::string capture_path_;
|
||||||
TriggerEngine engine_;
|
TriggerEngine engine_;
|
||||||
|
|
||||||
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
||||||
@@ -70,6 +115,11 @@ class Acquisition {
|
|||||||
/// included -- so the stream source is dropped in favour of arrival time.
|
/// included -- so the stream source is dropped in favour of arrival time.
|
||||||
double last_stream_anchor_ = 0.0;
|
double last_stream_anchor_ = 0.0;
|
||||||
bool have_stream_anchor_ = false;
|
bool have_stream_anchor_ = false;
|
||||||
|
/// Consecutive packets whose stream-time stamps failed those checks. A single bad
|
||||||
|
/// packet is a glitch in the producer's clock and is bridged at the declared cadence;
|
||||||
|
/// only a run of them means the declared signal is not a time base, and only then is
|
||||||
|
/// the arrival clock taken. Any accepted packet resets it.
|
||||||
|
unsigned stamp_failures_ = 0;
|
||||||
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
|
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
|
||||||
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
|
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
|
||||||
double arrival_epoch_ = 0.0;
|
double arrival_epoch_ = 0.0;
|
||||||
@@ -85,10 +135,40 @@ class Acquisition {
|
|||||||
/// signal carrying volt-like values, others from the arrival clock -- which is a
|
/// signal carrying volt-like values, others from the arrival clock -- which is a
|
||||||
/// time base no reader can make sense of (see `build_stamps`).
|
/// time base no reader can make sense of (see `build_stamps`).
|
||||||
int time_source_ = 0;
|
int time_source_ = 0;
|
||||||
/// Set when the source changed under the recorded history: the rings have to be
|
/// Relative-time mode's position counter: samples seen since the stream began, which is
|
||||||
/// emptied before more samples go in.
|
/// what the counter names. Advanced by `batch_` for every packet the counter reports
|
||||||
bool history_reset_pending_ = false;
|
/// missing (before the frame's own samples) and then by the frame's own `n`, so it is
|
||||||
|
/// exactly `counter * batch + sample_index` for a constant batch and degrades gracefully
|
||||||
|
/// when a frame's length disagrees.
|
||||||
|
std::uint64_t slot_base_ = 0;
|
||||||
|
/// Samples per DATA packet, fixed by the first frame. Only ever needed to size what is
|
||||||
|
/// *absent*: a delivered frame carries its own length. 0 until the first frame.
|
||||||
|
std::uint32_t batch_ = 0;
|
||||||
|
/// Latches the one warning about a frame whose length disagrees with the stream's first.
|
||||||
|
bool warned_batch_ = false;
|
||||||
|
/// The newest counter seen, and the hole's address. Tracked here rather than inferred from
|
||||||
|
/// `frame->lost` because a *late* frame's counter is behind the sequence and must not move
|
||||||
|
/// the mark forward.
|
||||||
|
std::uint32_t last_counter_ = 0;
|
||||||
|
bool have_counter_ = false;
|
||||||
|
/// Last absolute stamp written, epoch-relative, so a reserved hole can continue the
|
||||||
|
/// absolute clock at the declared cadence. The packets that would have carried the real
|
||||||
|
/// stamps never arrived, so a cadence continuation is the only honest value available.
|
||||||
|
double last_abs_stamp_ = 0.0;
|
||||||
|
/// Warn-once latch for a loss past the reservation cap. A member, not a function-local
|
||||||
|
/// `static`, matching the latch Task 2 added for the same reason.
|
||||||
|
bool warned_outage_ = false;
|
||||||
|
/// Warn-once latch for the one bridged packet whose time signal glitched.
|
||||||
|
bool warned_bridge_ = false;
|
||||||
|
/// Warn-once latch for the declared time signal failing for good, so the arrival clock is
|
||||||
|
/// taken instead.
|
||||||
|
bool warned_time_source_ = false;
|
||||||
|
/// The re-plan generation this object last reacted to. The buffer is re-planned whenever the
|
||||||
|
/// model rebuilds it, and the state tied to the old store (`tick_` and the accumulators) is
|
||||||
|
/// reset when this falls behind `ScopeModel::plan_generation()`.
|
||||||
|
unsigned seen_plan_generation_ = 0;
|
||||||
std::vector<double> stamps_;
|
std::vector<double> stamps_;
|
||||||
|
std::vector<double> abs_stamps_;
|
||||||
/// The decimation filter's state: a running sum per channel (and one for the shared
|
/// The decimation filter's state: a running sum per channel (and one for the shared
|
||||||
/// clock) over the group of base samples that becomes one stored sample. A boxcar
|
/// clock) over the group of base samples that becomes one stored sample. A boxcar
|
||||||
/// average, which is what turns decimation into a *resampling* rather than a stride:
|
/// average, which is what turns decimation into a *resampling* rather than a stride:
|
||||||
@@ -100,7 +180,17 @@ class Acquisition {
|
|||||||
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
|
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
|
||||||
std::vector<f32> math_sample_;
|
std::vector<f32> math_sample_;
|
||||||
std::vector<std::uint64_t> value_acc_n_;
|
std::vector<std::uint64_t> value_acc_n_;
|
||||||
|
/// Per channel, `time_stride * stride[i] - 1`: the group boundary as a **mask**, so the
|
||||||
|
/// innermost loop tests `(tick & mask) == mask` instead of `tick % period == period - 1`.
|
||||||
|
/// The divisor is a runtime value, so that modulo compiled to a real 64-bit `div` once per
|
||||||
|
/// channel per *wire* sample -- the filter reads every sample by design, so it was the
|
||||||
|
/// producer's largest avoidable cost. Both factors are powers of two (`plan_partition()`
|
||||||
|
/// doubles `time_stride` from 1, and every `stride[i]` is 1), which is what makes the mask
|
||||||
|
/// equivalent; it is sized and filled with the accumulators below, so a re-plan cannot
|
||||||
|
/// leave it addressing the previous partition's groups.
|
||||||
|
std::vector<std::uint64_t> acc_mask_;
|
||||||
double time_acc_ = 0.0;
|
double time_acc_ = 0.0;
|
||||||
|
double abs_time_acc_ = 0.0;
|
||||||
std::uint64_t time_acc_n_ = 0;
|
std::uint64_t time_acc_n_ = 0;
|
||||||
/// The partition the accumulators were sized for, so a re-plan resets them rather than
|
/// The partition the accumulators were sized for, so a re-plan resets them rather than
|
||||||
/// averaging across the boundary.
|
/// averaging across the boundary.
|
||||||
|
|||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "capture.hpp"
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The file header is one 64-byte block: fixed fields first, zeroed padding after.
|
||||||
|
constexpr std::uint64_t kHeaderBytes = 64;
|
||||||
|
/// `seq` lives at the *end* of a slot, at a fixed offset, because the reader must
|
||||||
|
/// find it without trusting any other field.
|
||||||
|
constexpr std::uint64_t kSeqBytes = 8;
|
||||||
|
|
||||||
|
void put_u32(std::uint8_t* p, std::uint32_t v) { std::memcpy(p, &v, 4); }
|
||||||
|
void put_u16(std::uint8_t* p, std::uint16_t v) { std::memcpy(p, &v, 2); }
|
||||||
|
void put_f64(std::uint8_t* p, double v) { std::memcpy(p, &v, 8); }
|
||||||
|
std::uint32_t get_u32(const std::uint8_t* p) {
|
||||||
|
std::uint32_t v;
|
||||||
|
std::memcpy(&v, p, 4);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
double get_f64(const std::uint8_t* p) {
|
||||||
|
double v;
|
||||||
|
std::memcpy(&v, p, 8);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
double wall_seconds() {
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_REALTIME, &ts);
|
||||||
|
return double(ts.tv_sec) + double(ts.tv_nsec) * 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `seq` is published with a release store *after* the bytes are down, so a reader
|
||||||
|
/// that sees a non-zero `seq` sees the whole slot -- the reason no `fsync` is needed
|
||||||
|
/// for the file to be readable after a crash. Plain __atomic builtins, not
|
||||||
|
/// `std::atomic_ref`: the target is mmap'd memory the class does not own as an object.
|
||||||
|
void publish_seq(std::uint8_t* slot, std::uint64_t seq) {
|
||||||
|
__atomic_store_n(reinterpret_cast<std::uint64_t*>(slot + kSlotBytes - kSeqBytes), seq,
|
||||||
|
__ATOMIC_RELEASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t read_seq(const std::uint8_t* slot) {
|
||||||
|
return __atomic_load_n(reinterpret_cast<const std::uint64_t*>(slot + kSlotBytes - kSeqBytes),
|
||||||
|
__ATOMIC_ACQUIRE);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void PacketCapture::set_error(const char* what) noexcept {
|
||||||
|
std::snprintf(error_, sizeof(error_), "%s", what);
|
||||||
|
error_set_.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PacketCapture::error(char* out, std::size_t cap) const noexcept {
|
||||||
|
if (cap == 0) return;
|
||||||
|
out[0] = '\0';
|
||||||
|
if (!error_set_.load(std::memory_order_acquire)) return;
|
||||||
|
std::snprintf(out, cap, "%s", error_);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PacketCapture::open(const std::string& path, std::uint32_t slot_count) {
|
||||||
|
close();
|
||||||
|
path_ = path;
|
||||||
|
slot_count_ = slot_count;
|
||||||
|
seq_ = 0;
|
||||||
|
cursor_ = 0;
|
||||||
|
stored_.store(0, std::memory_order_relaxed);
|
||||||
|
truncated_.store(0, std::memory_order_relaxed);
|
||||||
|
error_set_.store(false, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
const std::chrono::steady_clock::time_point t0 = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
// Keep the previous capture one deep: re-enabling must not destroy the capture the
|
||||||
|
// user enabled *for*. A failure here is reported, and the old previous kept.
|
||||||
|
std::string prev = path;
|
||||||
|
const std::size_t slash = prev.rfind('/');
|
||||||
|
const std::size_t dot = prev.rfind('.');
|
||||||
|
if (dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
|
||||||
|
prev.insert(dot, "-prev");
|
||||||
|
} else {
|
||||||
|
prev += "-prev";
|
||||||
|
}
|
||||||
|
::rename(path.c_str(), prev.c_str());
|
||||||
|
|
||||||
|
fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
|
||||||
|
if (fd_ < 0) {
|
||||||
|
set_error(strerror(errno));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved, not sparse: a store into a page the filesystem cannot back raises
|
||||||
|
// SIGBUS and kills udpscope outright, and an out-of-space condition must be an
|
||||||
|
// error at the moment the user clicked, not a crash ten minutes later.
|
||||||
|
const std::uint64_t total = kHeaderBytes + std::uint64_t(slot_count) * kSlotBytes;
|
||||||
|
if (::posix_fallocate(fd_, 0, off_t(total)) != 0) {
|
||||||
|
set_error(strerror(errno));
|
||||||
|
::close(fd_);
|
||||||
|
fd_ = -1;
|
||||||
|
::unlink(path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
map_ = static_cast<std::uint8_t*>(
|
||||||
|
::mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0));
|
||||||
|
if (map_ == MAP_FAILED) {
|
||||||
|
map_ = nullptr;
|
||||||
|
set_error(strerror(errno));
|
||||||
|
::close(fd_);
|
||||||
|
fd_ = -1;
|
||||||
|
::unlink(path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
map_bytes_ = total;
|
||||||
|
|
||||||
|
std::uint8_t* h = map_;
|
||||||
|
std::memset(h, 0, kHeaderBytes);
|
||||||
|
std::memcpy(h, "UDPSCAP\0", 8);
|
||||||
|
put_u32(h + 8, 1); // version
|
||||||
|
put_u32(h + 12, slot_count);
|
||||||
|
put_u32(h + 16, kSlotBytes);
|
||||||
|
put_u32(h + 20, 0); // cursor: a hint, `seq` is the order
|
||||||
|
put_f64(h + 24, wall_seconds()); // the epoch recv_time is relative to
|
||||||
|
put_u32(h + 32, peer_addr_);
|
||||||
|
put_u16(h + 36, peer_port_);
|
||||||
|
put_u16(h + 38, 0); // local_port: the client does not expose its own socket
|
||||||
|
const double ms =
|
||||||
|
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||||
|
open_ms_.store(ms, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
enabled_.store(true, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PacketCapture::close() noexcept {
|
||||||
|
if (map_ != nullptr) {
|
||||||
|
::munmap(map_, map_bytes_);
|
||||||
|
map_ = nullptr;
|
||||||
|
}
|
||||||
|
if (fd_ >= 0) {
|
||||||
|
::close(fd_);
|
||||||
|
fd_ = -1;
|
||||||
|
}
|
||||||
|
map_bytes_ = 0;
|
||||||
|
enabled_.store(false, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PacketCapture::store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept {
|
||||||
|
if (!enabled_.load(std::memory_order_relaxed) || map_ == nullptr) return;
|
||||||
|
|
||||||
|
std::uint8_t* slot = map_ + kHeaderBytes + std::uint64_t(cursor_) * kSlotBytes;
|
||||||
|
std::uint32_t orig = std::uint32_t(len);
|
||||||
|
std::size_t fit = len;
|
||||||
|
if (fit > kMaxDatagramBytes) {
|
||||||
|
fit = kMaxDatagramBytes;
|
||||||
|
truncated_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
put_f64(slot, recv_time);
|
||||||
|
put_u32(slot + 8, std::uint32_t(fit));
|
||||||
|
put_u32(slot + 12, orig);
|
||||||
|
std::memcpy(slot + kRecordHeaderBytes, bytes, fit);
|
||||||
|
++seq_;
|
||||||
|
publish_seq(slot, seq_); // last: a non-zero `seq` is what makes a slot complete
|
||||||
|
|
||||||
|
cursor_ = (cursor_ + 1) % slot_count_;
|
||||||
|
put_u32(map_ + 20, cursor_); // a hint for a human in xxd; `seq` is the order
|
||||||
|
stored_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool read_capture(const std::string& path, CaptureReadStats* out) {
|
||||||
|
if (out == nullptr) return false;
|
||||||
|
*out = CaptureReadStats{};
|
||||||
|
|
||||||
|
FILE* f = std::fopen(path.c_str(), "rb");
|
||||||
|
if (f == nullptr) return false;
|
||||||
|
std::fseek(f, 0, SEEK_END);
|
||||||
|
const long size = std::ftell(f);
|
||||||
|
std::fseek(f, 0, SEEK_SET);
|
||||||
|
out->file_bytes = std::uint64_t(size < 0 ? 0 : size);
|
||||||
|
|
||||||
|
std::uint8_t h[kHeaderBytes];
|
||||||
|
if (std::fread(h, 1, sizeof(h), f) != sizeof(h)) {
|
||||||
|
std::fclose(f);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out->header_ok = std::memcmp(h, "UDPSCAP\0", 8) == 0 && get_u32(h + 8) == 1;
|
||||||
|
out->slot_count = get_u32(h + 12);
|
||||||
|
out->slot_bytes = get_u32(h + 16);
|
||||||
|
out->cursor = get_u32(h + 20);
|
||||||
|
out->t0_realtime = get_f64(h + 24);
|
||||||
|
if (!out->header_ok || out->slot_count == 0 || out->slot_bytes < kRecordHeaderBytes + 8) {
|
||||||
|
std::fclose(f);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint32_t payload = out->slot_bytes - kRecordHeaderBytes - kSeqBytes;
|
||||||
|
std::vector<std::uint8_t> slot(out->slot_bytes);
|
||||||
|
for (std::uint32_t i = 0; i < out->slot_count; ++i) {
|
||||||
|
if (std::fseek(f, long(kHeaderBytes + std::uint64_t(i) * out->slot_bytes), SEEK_SET) != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (std::fread(slot.data(), 1, slot.size(), f) != slot.size()) break;
|
||||||
|
const std::uint64_t seq = read_seq(slot.data());
|
||||||
|
const std::uint32_t len = get_u32(slot.data() + 8);
|
||||||
|
const std::uint32_t orig = get_u32(slot.data() + 12);
|
||||||
|
if (seq == 0 && len == 0 && orig == 0) {
|
||||||
|
++out->empty;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seq == 0) {
|
||||||
|
++out->torn; // bytes down, ordinal never published: a capture that died mid-copy
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++out->stored;
|
||||||
|
out->payload += len;
|
||||||
|
if (orig > payload) ++out->truncated;
|
||||||
|
if (out->first_seq == 0 || seq < out->first_seq) out->first_seq = seq;
|
||||||
|
if (seq > out->last_seq) out->last_seq = seq;
|
||||||
|
}
|
||||||
|
std::fclose(f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// The largest datagram the client's socket can deliver, and therefore the payload
|
||||||
|
/// capacity of one capture slot. Kept equal to the client's own receive buffer: an
|
||||||
|
/// over-long datagram is impossible by construction, and `store()` counts the
|
||||||
|
/// impossibility rather than trusting it.
|
||||||
|
inline constexpr std::uint32_t kMaxDatagramBytes = 65535 + 17;
|
||||||
|
|
||||||
|
/// One slot's stride: the fixed record header, the largest datagram, and the `seq`
|
||||||
|
/// word that is written last. 8-byte aligned.
|
||||||
|
inline constexpr std::uint32_t kRecordHeaderBytes = 16; ///< t_arrival f64, len u32, orig_len u32
|
||||||
|
inline constexpr std::uint32_t kSlotBytes =
|
||||||
|
kRecordHeaderBytes + kMaxDatagramBytes + 8; ///< the trailing 8 are `seq`
|
||||||
|
|
||||||
|
/// Owns the ring file a packet capture stores raw datagrams into, and stores a
|
||||||
|
/// datagram into it. It includes no GTK, no `ScopeModel`, and no client types: its
|
||||||
|
/// whole world is bytes, a clock reading and one file.
|
||||||
|
///
|
||||||
|
/// Everything here runs on the producer thread except `open()`'s caller choice and
|
||||||
|
/// the GTK thread's reads of the counters and the error, which are publishable
|
||||||
|
/// without a lock: see the field table in the design note
|
||||||
|
/// (`docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md`).
|
||||||
|
class PacketCapture {
|
||||||
|
public:
|
||||||
|
PacketCapture() = default;
|
||||||
|
~PacketCapture() { close(); }
|
||||||
|
|
||||||
|
PacketCapture(const PacketCapture&) = delete;
|
||||||
|
PacketCapture& operator=(const PacketCapture&) = delete;
|
||||||
|
|
||||||
|
/// Renames an existing capture to `<path>` minus extension plus `-prev.bin`,
|
||||||
|
/// creates and reserves the new file, maps it, writes the header. Returns false
|
||||||
|
/// and leaves the object disabled on any failure, with `error()` set.
|
||||||
|
bool open(const std::string& path, std::uint32_t slot_count);
|
||||||
|
|
||||||
|
/// Unmaps and closes. Idempotent; keeps the counters, which read "since open()".
|
||||||
|
void close() noexcept;
|
||||||
|
|
||||||
|
bool enabled() const noexcept { return enabled_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// One datagram. Called only from the producer thread. Never allocates, never
|
||||||
|
/// blocks, never fails: an over-long datagram is truncated to the slot and counted.
|
||||||
|
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||||
|
|
||||||
|
std::uint64_t stored() const noexcept { return stored_.load(std::memory_order_relaxed); }
|
||||||
|
std::uint64_t truncated() const noexcept {
|
||||||
|
return truncated_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint32_t slot_count() const noexcept { return slot_count_; }
|
||||||
|
const std::string& path() const noexcept { return path_; }
|
||||||
|
|
||||||
|
/// The source's IPv4, in network order, and its port: written into the header at
|
||||||
|
/// open() so the pcap converter can synthesise a UDP header. Call before open(),
|
||||||
|
/// from the thread that owns the decision -- the same write-once discipline as the
|
||||||
|
/// path. `local_port` stays zero: the client does not expose its own socket.
|
||||||
|
void set_peer(std::uint32_t addr_net_order, std::uint16_t port) noexcept {
|
||||||
|
peer_addr_ = addr_net_order;
|
||||||
|
peer_port_ = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wall milliseconds `open()` took, measured rather than asserted: the one cost a
|
||||||
|
/// toggle has, and a reading above ~50 ms is a finding about the filesystem.
|
||||||
|
double open_ms() const noexcept { return open_ms_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// Copies the last open() failure out, or writes an empty string. Deliberately not
|
||||||
|
/// a `const std::string&`: the producer writes the fixed buffer and *then* publishes
|
||||||
|
/// the flag with a release store, and the GTK thread copies only when the flag reads
|
||||||
|
/// true -- a `std::string` shared like that is a data race with a reallocating
|
||||||
|
/// buffer, the kind that reads correctly for months.
|
||||||
|
void error(char* out, std::size_t cap) const noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void set_error(const char* what) noexcept;
|
||||||
|
|
||||||
|
std::string path_;
|
||||||
|
std::uint32_t slot_count_ = 0;
|
||||||
|
int fd_ = -1;
|
||||||
|
std::uint8_t* map_ = nullptr; ///< whole file, header first
|
||||||
|
std::uint64_t map_bytes_ = 0;
|
||||||
|
std::uint64_t seq_ = 0; ///< 1-based ordinal since open(); producer-only
|
||||||
|
std::uint32_t cursor_ = 0; ///< next slot index to write; producer-only
|
||||||
|
std::uint32_t peer_addr_ = 0; ///< the source's IPv4, network order
|
||||||
|
std::uint16_t peer_port_ = 0;
|
||||||
|
std::atomic<bool> enabled_{false};
|
||||||
|
std::atomic<std::uint64_t> stored_{0};
|
||||||
|
std::atomic<std::uint64_t> truncated_{0};
|
||||||
|
std::atomic<bool> error_set_{false};
|
||||||
|
std::atomic<double> open_ms_{0.0};
|
||||||
|
mutable char error_[128] = {0};
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What a capture file says when read back off disk. `stored` counts slots with a
|
||||||
|
/// non-zero `seq`; `torn` counts slots with bytes but no `seq` (a capture that died
|
||||||
|
/// mid-copy); `empty` counts all-zero slots, which a freshly reserved file is full of.
|
||||||
|
struct CaptureReadStats {
|
||||||
|
bool header_ok = false;
|
||||||
|
std::uint32_t slot_count = 0;
|
||||||
|
std::uint32_t slot_bytes = 0;
|
||||||
|
std::uint32_t cursor = 0;
|
||||||
|
double t0_realtime = 0.0;
|
||||||
|
std::uint64_t stored = 0;
|
||||||
|
std::uint64_t torn = 0;
|
||||||
|
std::uint64_t empty = 0;
|
||||||
|
std::uint64_t truncated = 0; ///< slots whose `orig_len` exceeds the payload capacity
|
||||||
|
std::uint64_t payload = 0; ///< sum of stored slots' `len`: the bytes the wire delivered
|
||||||
|
std::uint64_t first_seq = 0;
|
||||||
|
std::uint64_t last_seq = 0;
|
||||||
|
std::uint64_t file_bytes = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Reads a capture file back and validates it. Pure POSIX plus `<cstdio>`; used by the
|
||||||
|
/// headless probe, and `tools/capture_to_pcap.py` re-implements the walk in Python.
|
||||||
|
bool read_capture(const std::string& path, CaptureReadStats* out);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+14
-4
@@ -31,10 +31,9 @@ ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>>
|
|||||||
|
|
||||||
void ChannelBuffer::acquire() noexcept {
|
void ChannelBuffer::acquire() noexcept {
|
||||||
const u64 produced = values_->produced();
|
const u64 produced = values_->produced();
|
||||||
if (produced <= published_) {
|
// Nothing new skips the append pass only: the backfill drain at the end of this function
|
||||||
// Nothing new; still allow the window to slide if another reader dropped it.
|
// runs either way, because a late packet can land in a frame interval that pushed nothing.
|
||||||
return;
|
if (produced > published_) {
|
||||||
}
|
|
||||||
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
||||||
// not being published for a while -- makes the incremental append meaningless:
|
// not being published for a while -- makes the incremental append meaningless:
|
||||||
// the samples between `published_` and `produced_` are no longer in the ring.
|
// the samples between `published_` and `produced_` are no longer in the ring.
|
||||||
@@ -60,12 +59,23 @@ void ChannelBuffer::acquire() noexcept {
|
|||||||
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
|
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
|
||||||
// place in the object's life where that sample's slot is safe to read.
|
// place in the object's life where that sample's slot is safe to read.
|
||||||
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
||||||
|
}
|
||||||
|
// Backfills, applied after the append pass above: an amend is only ever queued for a slot
|
||||||
|
// the value ring already produced, so the append has reached it and the pyramid's bounds
|
||||||
|
// test will accept it. Unconditional, outside the `produced > published_` guard -- a late
|
||||||
|
// packet can land in a frame interval that pushed nothing new, and the plan's placement
|
||||||
|
// behind the old early return would have left the pyramid stale until the next push.
|
||||||
|
if (!pending_amends_.empty()) {
|
||||||
|
for (const Amend& a : pending_amends_) pyramid_.amend(a.absolute, a.value);
|
||||||
|
pending_amends_.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChannelBuffer::invalidate() noexcept {
|
void ChannelBuffer::invalidate() noexcept {
|
||||||
published_ = 0;
|
published_ = 0;
|
||||||
window_x0_ = 0.0;
|
window_x0_ = 0.0;
|
||||||
pyramid_.clear();
|
pyramid_.clear();
|
||||||
|
pending_amends_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// Consumer thread: forgets the published window (after a rebind or a reset).
|
/// Consumer thread: forgets the published window (after a rebind or a reset).
|
||||||
void invalidate() noexcept;
|
void invalidate() noexcept;
|
||||||
|
|
||||||
|
/// Producer thread, under `ScopeModel::mutex()`: records that absolute sample `absolute`
|
||||||
|
/// has been backfilled, for `acquire()` to fold into the pyramid.
|
||||||
|
///
|
||||||
|
/// The pyramid is mutated *only* on the consumer thread, because a bucket is a 32-byte
|
||||||
|
/// non-atomic aggregate the renderer reads unlocked at paint time: amending it from the
|
||||||
|
/// producer would let a reader see a half-updated bucket whose `sum` did not match its
|
||||||
|
/// `count`, which is a wrong mean out of a structure whose readers trust `count == 0` to
|
||||||
|
/// mean absent. The value ring is amended by the producer, which owns its writes; the
|
||||||
|
/// pyramid waits for here.
|
||||||
|
void queue_amend(u64 absolute, f32 v) { pending_amends_.push_back({absolute, v}); }
|
||||||
|
|
||||||
void set_nominal_dt(f64 dt) noexcept { dt_ = dt; }
|
void set_nominal_dt(f64 dt) noexcept { dt_ = dt; }
|
||||||
/// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make
|
/// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make
|
||||||
/// the axis read seconds from the trigger event instead of from the stream.
|
/// the axis read seconds from the trigger event instead of from the stream.
|
||||||
@@ -100,6 +111,13 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||||
f64 time_of(u64 absolute_index) const noexcept;
|
f64 time_of(u64 absolute_index) const noexcept;
|
||||||
|
|
||||||
|
struct Amend {
|
||||||
|
u64 absolute;
|
||||||
|
f32 value;
|
||||||
|
};
|
||||||
|
/// Backfills the producer has written into the value ring and the pyramid has not seen yet.
|
||||||
|
std::vector<Amend> pending_amends_;
|
||||||
|
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||||
u64 stride_ = 1;
|
u64 stride_ = 1;
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "condition.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
void EventDetector::configure(const EventCondition& c, double dt) noexcept {
|
||||||
|
if (c == cond_ && dt == dt_) return;
|
||||||
|
cond_ = c;
|
||||||
|
dt_ = dt;
|
||||||
|
// a = 1 - exp(-2*pi*fc*dt), clamped just under Nyquist so a never exceeds 1.
|
||||||
|
// fc == 0 leaves a == 1, which is a pass-through.
|
||||||
|
pole_a_ = 1.0;
|
||||||
|
if (cond_.hf_fc > 0.0 && dt > 0.0) {
|
||||||
|
const double nyquist = 0.5 / dt;
|
||||||
|
const double fc = cond_.hf_fc < nyquist * 0.99 ? cond_.hf_fc : nyquist * 0.99;
|
||||||
|
pole_a_ = 1.0 - std::exp(-2.0 * 3.14159265358979323846 * fc * dt);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventDetector::reset() noexcept {
|
||||||
|
have_prev_ = false;
|
||||||
|
prev_ = 0.0;
|
||||||
|
in_pulse_ = false;
|
||||||
|
pulse_up_ = false;
|
||||||
|
pulse_start_s_ = 0.0;
|
||||||
|
inside_ = false;
|
||||||
|
last_cross_s_ = 0.0;
|
||||||
|
dropped_ = false;
|
||||||
|
pole_y_ = 0.0;
|
||||||
|
pole_have_ = false;
|
||||||
|
side_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::filter(double v) noexcept {
|
||||||
|
// A NaN must stay a NaN and must not poison the state: a dropout is a real
|
||||||
|
// reading here, not an error.
|
||||||
|
if (!std::isfinite(v)) return v;
|
||||||
|
if (!pole_have_) {
|
||||||
|
pole_y_ = v;
|
||||||
|
pole_have_ = true;
|
||||||
|
return pole_y_;
|
||||||
|
}
|
||||||
|
pole_y_ += pole_a_ * (v - pole_y_);
|
||||||
|
return pole_y_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventDetector::decide(double v, bool& up, bool& down) noexcept {
|
||||||
|
up = false;
|
||||||
|
down = false;
|
||||||
|
if (cond_.noise_band <= 0.0) {
|
||||||
|
up = crossed_up(v);
|
||||||
|
down = crossed_down(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A Schmitt trigger: a sample inside the band decides nothing, and a crossing
|
||||||
|
// is a change between two *decided* sides. Seeding from the undecided side
|
||||||
|
// would fire on a signal that starts inside the band and leaves it without
|
||||||
|
// ever reaching `level`.
|
||||||
|
const int was = side_;
|
||||||
|
if (v >= cond_.level + cond_.noise_band) side_ = 1;
|
||||||
|
else if (v <= cond_.level - cond_.noise_band) side_ = -1;
|
||||||
|
up = was == -1 && side_ == 1;
|
||||||
|
down = was == 1 && side_ == -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed(double value, double t) noexcept {
|
||||||
|
value = filter(value);
|
||||||
|
if (!have_prev_) {
|
||||||
|
prev_ = value;
|
||||||
|
have_prev_ = true;
|
||||||
|
// The window condition needs a side to change *from*, and the dropout a last-seen time
|
||||||
|
// to count from; neither can be derived from a crossing that has not happened. Seeding
|
||||||
|
// them here is what lets both handlers run without a "have we started" guard.
|
||||||
|
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||||
|
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||||
|
inside_ = value >= lo && value <= hi;
|
||||||
|
last_cross_s_ = t;
|
||||||
|
dropped_ = false;
|
||||||
|
return kNoEvent;
|
||||||
|
}
|
||||||
|
double event = kNoEvent;
|
||||||
|
switch (cond_.type) {
|
||||||
|
case TriggerType::Edge:
|
||||||
|
event = feed_edge(value, t);
|
||||||
|
break;
|
||||||
|
case TriggerType::PulseWidth:
|
||||||
|
event = feed_pulse(value, t);
|
||||||
|
break;
|
||||||
|
case TriggerType::Window:
|
||||||
|
event = feed_window(value, t);
|
||||||
|
break;
|
||||||
|
case TriggerType::Dropout:
|
||||||
|
event = feed_dropout(value, t);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// After the handler: every crossing test above reads `prev_` as the previous sample.
|
||||||
|
prev_ = value;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_edge(double value, double t) noexcept {
|
||||||
|
bool up = false;
|
||||||
|
bool down = false;
|
||||||
|
decide(value, up, down);
|
||||||
|
const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||||
|
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||||
|
return fired ? t : kNoEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EventDetector::qualifies(double width_s) const noexcept {
|
||||||
|
const double a = cond_.width_s < cond_.width2_s ? cond_.width_s : cond_.width2_s;
|
||||||
|
const double b = cond_.width_s < cond_.width2_s ? cond_.width2_s : cond_.width_s;
|
||||||
|
switch (cond_.cmp) {
|
||||||
|
case WidthCmp::Less:
|
||||||
|
return width_s < cond_.width_s;
|
||||||
|
case WidthCmp::Greater:
|
||||||
|
return width_s > cond_.width_s;
|
||||||
|
case WidthCmp::Within:
|
||||||
|
return width_s >= a && width_s <= b;
|
||||||
|
case WidthCmp::Outside:
|
||||||
|
return width_s < a || width_s > b;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_pulse(double value, double t) noexcept {
|
||||||
|
const bool up = crossed_up(value);
|
||||||
|
const bool down = crossed_down(value);
|
||||||
|
if (!in_pulse_) {
|
||||||
|
// Which crossing opens a pulse is what `edge` selects: Rising measures the time spent
|
||||||
|
// above the level, Falling the time below, Both every excursion either way.
|
||||||
|
const bool opens = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||||
|
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||||
|
if (opens) {
|
||||||
|
in_pulse_ = true;
|
||||||
|
pulse_up_ = up;
|
||||||
|
pulse_start_s_ = t;
|
||||||
|
}
|
||||||
|
return kNoEvent;
|
||||||
|
}
|
||||||
|
const bool closes = pulse_up_ ? down : up;
|
||||||
|
if (!closes) return kNoEvent;
|
||||||
|
const double width = t - pulse_start_s_;
|
||||||
|
if (cond_.edge == TriggerEdge::Both) {
|
||||||
|
// The closing edge of one excursion is the opening edge of the next, so a square wave
|
||||||
|
// measures *every* half-period. Dropping the state here instead would measure every
|
||||||
|
// other one, and the hits would come out half as dense as the waveform.
|
||||||
|
pulse_up_ = !pulse_up_;
|
||||||
|
pulse_start_s_ = t;
|
||||||
|
} else {
|
||||||
|
in_pulse_ = false;
|
||||||
|
}
|
||||||
|
return qualifies(width) ? t : kNoEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_window(double value, double t) noexcept {
|
||||||
|
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||||
|
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||||
|
// The band is applied on the edge the signal must *leave*, and clamped to just
|
||||||
|
// under half the window so `lo + band` can never pass `hi - band`.
|
||||||
|
double band = cond_.noise_band > 0.0 ? cond_.noise_band : 0.0;
|
||||||
|
const double room = 0.49 * (hi - lo);
|
||||||
|
if (band > room) band = room;
|
||||||
|
const bool inside = inside_ ? (value >= lo - band && value <= hi + band)
|
||||||
|
: (value >= lo + band && value <= hi - band);
|
||||||
|
if (inside == inside_) return kNoEvent;
|
||||||
|
inside_ = inside;
|
||||||
|
// The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
|
||||||
|
// as "out of it". A band of zero width is not a band: `inside` reduces to an exact
|
||||||
|
// float-equality test against the level, so the condition fires on none of an ordinary
|
||||||
|
// signal's samples and on every transition of one that sits exactly at the level -- a
|
||||||
|
// +/-1 square against a band of [1, 1] fired 19 times in a standalone check. Neither
|
||||||
|
// answer is a band, so the panel refuses one.
|
||||||
|
const bool fired = (cond_.edge == TriggerEdge::Rising && inside) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && !inside) ||
|
||||||
|
cond_.edge == TriggerEdge::Both;
|
||||||
|
return fired ? t : kNoEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_dropout(double value, double t) noexcept {
|
||||||
|
const bool up = crossed_up(value);
|
||||||
|
const bool down = crossed_down(value);
|
||||||
|
const bool crossed = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||||
|
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||||
|
if (crossed) {
|
||||||
|
last_cross_s_ = t;
|
||||||
|
dropped_ = false;
|
||||||
|
return kNoEvent;
|
||||||
|
}
|
||||||
|
// One event per silence, not one per sample of it: without the latch a dead signal would
|
||||||
|
// report an event on every sample after the timeout and fill the search's hit list with
|
||||||
|
// one answer repeated.
|
||||||
|
if (dropped_) return kNoEvent;
|
||||||
|
if (t - last_cross_s_ <= cond_.timeout_s) return kNoEvent;
|
||||||
|
dropped_ = true;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Which way through a level counts. Shared by every condition type: for `Window` it is
|
||||||
|
/// the direction through the *band* (into it, out of it, or either), and for `Dropout` it
|
||||||
|
/// selects which crossings count as the signal still being alive.
|
||||||
|
///
|
||||||
|
/// It lives here rather than in `trigger.hpp` because the buffer search needs the
|
||||||
|
/// evaluator without the engine's arming state.
|
||||||
|
enum class TriggerEdge { Rising, Falling, Both };
|
||||||
|
|
||||||
|
/// What shape in the signal counts as an event.
|
||||||
|
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||||
|
|
||||||
|
/// How a measured pulse width is compared with the condition's bounds. `Less` is the
|
||||||
|
/// glitch trigger: "a pulse shorter than it should be".
|
||||||
|
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||||
|
|
||||||
|
/// Everything that decides whether a sample is an event. No arming, no capture window:
|
||||||
|
/// those belong to `TriggerConfig`.
|
||||||
|
struct EventCondition {
|
||||||
|
TriggerType type = TriggerType::Edge;
|
||||||
|
double level = 0.0;
|
||||||
|
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||||
|
TriggerEdge edge = TriggerEdge::Rising;
|
||||||
|
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||||
|
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||||
|
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||||
|
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||||
|
/// Half-width of the amplitude band around `level` a sample must leave before
|
||||||
|
/// a crossing counts. 0 is the bare comparator. In signal units.
|
||||||
|
double noise_band = 0.0;
|
||||||
|
/// Corner of a one-pole low pass applied to the sample before any comparison.
|
||||||
|
/// 0 is off. In Hz.
|
||||||
|
double hf_fc = 0.0;
|
||||||
|
|
||||||
|
bool operator==(const EventCondition& o) const noexcept {
|
||||||
|
return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge &&
|
||||||
|
cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s &&
|
||||||
|
timeout_s == o.timeout_s && noise_band == o.noise_band && hf_fc == o.hf_fc;
|
||||||
|
}
|
||||||
|
bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The event condition's state machine: one sample in, an event time or nothing out.
|
||||||
|
///
|
||||||
|
/// It holds no arming state and no capture state -- that is `TriggerEngine`'s job -- which
|
||||||
|
/// is what lets the buffer search run the *same* evaluator over retained samples and get
|
||||||
|
/// the answers the live trigger would have given.
|
||||||
|
///
|
||||||
|
/// Every condition but `Edge` is sequential: a pulse's width is only known at its closing
|
||||||
|
/// edge, and a dropout is a measured absence. Callers must therefore feed it **every**
|
||||||
|
/// sample in stream order, including samples they intend to ignore; a gap is a pulse it can
|
||||||
|
/// never close.
|
||||||
|
class EventDetector {
|
||||||
|
public:
|
||||||
|
/// What `feed()` returns when the sample is not an event.
|
||||||
|
static constexpr double kNoEvent = std::numeric_limits<double>::quiet_NaN();
|
||||||
|
|
||||||
|
/// Re-applying the same condition is a no-op, deliberately: the producer calls this on
|
||||||
|
/// every packet, and resetting there would restart the machine before any pulse could
|
||||||
|
/// close. A genuinely different condition resets, because its state means nothing.
|
||||||
|
///
|
||||||
|
/// `dt` is the stored sample interval (`Partition::slot_dt()`), needed for the
|
||||||
|
/// HF reject's pole. A no-op unless the condition or the interval changed.
|
||||||
|
void configure(const EventCondition& c, double dt) noexcept;
|
||||||
|
void reset() noexcept;
|
||||||
|
|
||||||
|
/// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
|
||||||
|
///
|
||||||
|
/// There is no finite-value check: a NaN sample is benign here (every comparison against
|
||||||
|
/// it is false, so it cannot fire), and rejecting one would leave `prev_` at the last good
|
||||||
|
/// value and let the sample *after* the NaN fire a crossing that today cannot happen.
|
||||||
|
double feed(double value, double t) noexcept;
|
||||||
|
|
||||||
|
double last_value() const noexcept { return prev_; }
|
||||||
|
bool have_last() const noexcept { return have_prev_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool crossed_up(double v) const noexcept {
|
||||||
|
return prev_ < cond_.level && v >= cond_.level;
|
||||||
|
}
|
||||||
|
bool crossed_down(double v) const noexcept {
|
||||||
|
return prev_ > cond_.level && v <= cond_.level;
|
||||||
|
}
|
||||||
|
/// The HF reject, applied to every sample before any comparison.
|
||||||
|
double filter(double v) noexcept;
|
||||||
|
/// `up`/`down` for the edge condition, through the band if there is one.
|
||||||
|
void decide(double v, bool& up, bool& down) noexcept;
|
||||||
|
double feed_edge(double value, double t) noexcept;
|
||||||
|
double feed_pulse(double value, double t) noexcept;
|
||||||
|
double feed_window(double value, double t) noexcept;
|
||||||
|
double feed_dropout(double value, double t) noexcept;
|
||||||
|
/// Whether a measured width satisfies `cond_.cmp`. `Within`/`Outside` take their two
|
||||||
|
/// bounds in either order, so a panel cannot make an empty band by typing the larger
|
||||||
|
/// figure into the first spin.
|
||||||
|
bool qualifies(double width_s) const noexcept;
|
||||||
|
|
||||||
|
EventCondition cond_{};
|
||||||
|
bool have_prev_ = false;
|
||||||
|
double prev_ = 0.0;
|
||||||
|
bool in_pulse_ = false; ///< PulseWidth: an opening edge has been seen.
|
||||||
|
bool pulse_up_ = false; ///< PulseWidth: its polarity, so we know which edge closes it.
|
||||||
|
double pulse_start_s_ = 0.0;
|
||||||
|
bool inside_ = false; ///< Window: which side of the band the previous sample was.
|
||||||
|
double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing.
|
||||||
|
bool dropped_ = false; ///< Dropout: the timeout has already been reported.
|
||||||
|
/// One-pole prefilter. `pole_a_ == 1` passes the input through, so "off"
|
||||||
|
/// costs no branch. `pole_y_` is the filtered value.
|
||||||
|
double dt_ = 0.0;
|
||||||
|
double pole_a_ = 1.0;
|
||||||
|
double pole_y_ = 0.0;
|
||||||
|
bool pole_have_ = false;
|
||||||
|
/// Which side of the band the signal was last *decided* to be on: +1 above,
|
||||||
|
/// -1 below, 0 undecided. A sample inside the band decides nothing.
|
||||||
|
int side_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -54,6 +54,14 @@ void print_usage(std::FILE* out, const char* argv0) {
|
|||||||
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
||||||
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
|
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
|
||||||
" added and removed from the layout editor\n"
|
" added and removed from the layout editor\n"
|
||||||
|
" --capture-packets N store the last N raw datagrams on disk for forensics, in a\n"
|
||||||
|
" ring under the cache directory (1000 slots is ~62.6 MiB);\n"
|
||||||
|
" off unless given, and toggleable from the status bar\n"
|
||||||
|
" --relative-time take the plot's time from the packet counter and the\n"
|
||||||
|
" declared rate, not the stream's time signal; absolute\n"
|
||||||
|
" time is then kept for export only\n"
|
||||||
|
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
|
||||||
|
" signals from its columns, no socket, no acquisition\n"
|
||||||
" -h, --help this text\n",
|
" -h, --help this text\n",
|
||||||
argv0);
|
argv0);
|
||||||
}
|
}
|
||||||
@@ -79,6 +87,7 @@ Config parse_args(int argc, char** argv) {
|
|||||||
c.interface_addr = value_of(argc, argv, i, "--interface");
|
c.interface_addr = value_of(argc, argv, i, "--interface");
|
||||||
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
|
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
|
||||||
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
|
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
|
||||||
|
c.buffer_mb_specified = true;
|
||||||
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
|
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
|
||||||
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
|
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
|
||||||
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
|
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
|
||||||
@@ -98,6 +107,15 @@ Config parse_args(int argc, char** argv) {
|
|||||||
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
|
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
|
||||||
}
|
}
|
||||||
c.plots = static_cast<int>(n);
|
c.plots = static_cast<int>(n);
|
||||||
|
} else if (a == "--capture-packets" || a.rfind("--capture-packets=", 0) == 0) {
|
||||||
|
const long n = std::strtol(value_of(argc, argv, i, "--capture-packets").c_str(), nullptr, 10);
|
||||||
|
if (n < 1 || n > 100000) fail("--capture-packets must be 1..100000");
|
||||||
|
c.capture_packets = static_cast<std::uint32_t>(n);
|
||||||
|
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
|
||||||
|
c.open_waveform = value_of(argc, argv, i, "--open");
|
||||||
|
} else if (a == "--relative-time") {
|
||||||
|
c.relative_time = true;
|
||||||
|
c.relative_time_specified = true;
|
||||||
} else {
|
} else {
|
||||||
fail("unknown argument: " + a);
|
fail("unknown argument: " + a);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ struct Config {
|
|||||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
/// Size of *each* of the two sample buffers, in megabytes. The application
|
||||||
/// always allocates two identical buffers (spec item 1).
|
/// always allocates two identical buffers (spec item 1).
|
||||||
double buffer_mb = 200.0;
|
double buffer_mb = 200.0;
|
||||||
|
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the
|
||||||
|
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
|
||||||
|
/// question as "asked for 200".
|
||||||
|
bool buffer_mb_specified = false;
|
||||||
|
|
||||||
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
||||||
/// sized to hold at least this much time (spec: decimation).
|
/// sized to hold at least this much time (spec: decimation).
|
||||||
@@ -35,6 +39,26 @@ struct Config {
|
|||||||
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
|
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
|
||||||
/// starting point: rows are added and removed from the layout editor.
|
/// starting point: rows are added and removed from the layout editor.
|
||||||
int plots = 1;
|
int plots = 1;
|
||||||
|
|
||||||
|
/// Take the plot's time from the packet counter and the declared sample rate rather than
|
||||||
|
/// from the stream's own time signal, so arrival and producer-clock jitter cannot move a
|
||||||
|
/// sample's x. Absolute stream time is still recorded, for export only (`todo.md` item 2).
|
||||||
|
bool relative_time = false;
|
||||||
|
|
||||||
|
/// Whether relative time was *stated* rather than left at its default -- by `--relative-time`, or
|
||||||
|
/// by the preferences, which the source chooser can reach before this run is planned. A source's
|
||||||
|
/// stored configuration is a statement from an *older* session, so it only fills in the gap.
|
||||||
|
bool relative_time_specified = false;
|
||||||
|
|
||||||
|
/// `--capture-packets N`: the ring size of the raw-datagram capture, 1..100000.
|
||||||
|
/// 0 means the flag was absent: the capture is off, and the popover button then
|
||||||
|
/// uses the 1000-slot default when the user toggles it.
|
||||||
|
std::uint32_t capture_packets = 0;
|
||||||
|
|
||||||
|
/// `--open FILE`: a `.npy` an earlier run exported, opened for viewing and analysis instead of a
|
||||||
|
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
|
||||||
|
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
||||||
|
std::string open_waveform;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ struct EventSnapshot {
|
|||||||
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||||
/// `publish_pause_view()` a straight copy.
|
/// `publish_pause_view()` a straight copy.
|
||||||
std::vector<f64> stamps;
|
std::vector<f64> stamps;
|
||||||
|
/// Absolute stream seconds on the same indices as `stamps`, or empty when the store had no
|
||||||
|
/// absolute ring. **Not** rebased by `x_offset` or by the trigger edge: a snapshot outlives
|
||||||
|
/// the epoch that produced it, so it stores the absolute value outright.
|
||||||
|
std::vector<f64> abs_stamps;
|
||||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||||
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
||||||
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "event_store.hpp"
|
||||||
|
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#include "layout_store.hpp"
|
||||||
|
#include "waveform.hpp"
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// One directory under the cache holds *every* source's events, and each source gets a folder inside
|
||||||
|
/// it: `<cache>/udpscope/events/<source>/`. The other way round -- a folder per source with an
|
||||||
|
/// `events` inside -- spreads one feature over as many directories as there are sources, and turns
|
||||||
|
/// "where are my events" into a question about which host you last used.
|
||||||
|
constexpr const char* kEventsSubdir = "/events";
|
||||||
|
|
||||||
|
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
|
||||||
|
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
|
||||||
|
/// the event it collided with.
|
||||||
|
std::string event_stem(double wall_time, std::uint64_t seq) {
|
||||||
|
const std::time_t t = static_cast<std::time_t>(wall_time);
|
||||||
|
std::tm tm{};
|
||||||
|
localtime_r(&t, &tm);
|
||||||
|
char buf[64];
|
||||||
|
std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm);
|
||||||
|
char stem[96];
|
||||||
|
std::snprintf(stem, sizeof(stem), "%s-%04llu", buf,
|
||||||
|
static_cast<unsigned long long>(seq % 10000ull));
|
||||||
|
return stem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a.npy` -> `a`; anything else -> "".
|
||||||
|
std::string stem_of(const std::string& file, const char* ext) {
|
||||||
|
const std::size_t n = std::strlen(ext);
|
||||||
|
if (file.size() <= n || file.compare(file.size() - n, n, ext) != 0) return {};
|
||||||
|
return file.substr(0, file.size() - n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event's picture, drawn from its samples: one horizontal strip per signal, min/max per pixel
|
||||||
|
/// column, the trigger edge as a brighter line. A 24-bit BMP -- the header is nine words and the rest
|
||||||
|
/// is the pixels, so it needs no encoder, and a person can open it with anything.
|
||||||
|
///
|
||||||
|
/// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at
|
||||||
|
/// a glance is one *event* from another, not one signal from another (for which the names are on the
|
||||||
|
/// row above).
|
||||||
|
///
|
||||||
|
/// The height is **per strip**, not for the whole picture: with a fixed total, a six-signal event got
|
||||||
|
/// 26 pixels a signal and an eight-signal one got 20, which is a smear and not a waveform -- the
|
||||||
|
/// picture has to grow with the signal count, and it is the *viewer* that scales it down to a row.
|
||||||
|
bool write_preview(const ExportTable& table, const std::string& path, int width = 480,
|
||||||
|
int strip_h = 44) {
|
||||||
|
if (table.empty() || table.columns.empty()) return false;
|
||||||
|
const std::size_t rows = table.rows();
|
||||||
|
const std::size_t strips = table.columns.size();
|
||||||
|
const int total_h = strip_h * static_cast<int>(strips);
|
||||||
|
static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u,
|
||||||
|
0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u};
|
||||||
|
|
||||||
|
// One byte per pixel per channel, bottom-up and **blue first** -- both are BMP's own order, and
|
||||||
|
// writing RGB here is what made every external viewer show the traces in the wrong colour.
|
||||||
|
const int row_bytes = ((width * 3) + 3) & ~3;
|
||||||
|
std::vector<unsigned char> pixels(std::size_t(row_bytes) * total_h, 0);
|
||||||
|
const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) {
|
||||||
|
if (x < 0 || y < 0 || x >= width || y >= total_h) return;
|
||||||
|
unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3];
|
||||||
|
const double a = std::clamp(alpha, 0.0, 1.0);
|
||||||
|
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + (rgb & 0xFF) * a);
|
||||||
|
p[1] = static_cast<unsigned char>(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a);
|
||||||
|
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
|
||||||
|
};
|
||||||
|
// The background: a dark field, so the traces read the way they do on screen.
|
||||||
|
for (int y = 0; y < total_h; ++y) {
|
||||||
|
for (int x = 0; x < width; ++x) put(x, y, 0x101418u, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (std::size_t c = 0; c < strips; ++c) {
|
||||||
|
const std::vector<f32>& values = table.columns[c].values;
|
||||||
|
if (values.empty()) continue;
|
||||||
|
double lo = values[0];
|
||||||
|
double hi = values[0];
|
||||||
|
for (f32 v : values) {
|
||||||
|
if (!std::isfinite(v)) continue;
|
||||||
|
lo = std::min(lo, double(v));
|
||||||
|
hi = std::max(hi, double(v));
|
||||||
|
}
|
||||||
|
if (!(hi > lo)) {
|
||||||
|
hi = lo + 1.0;
|
||||||
|
}
|
||||||
|
const int base = static_cast<int>(c) * strip_h;
|
||||||
|
const std::uint32_t colour = kPalette[c % (sizeof(kPalette) / sizeof(kPalette[0]))];
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const std::size_t from = rows * std::size_t(x) / std::size_t(width);
|
||||||
|
const std::size_t to = std::max(from + 1, rows * std::size_t(x + 1) / std::size_t(width));
|
||||||
|
double vmin = 1e300;
|
||||||
|
double vmax = -1e300;
|
||||||
|
for (std::size_t i = from; i < to && i < rows; ++i) {
|
||||||
|
const double v = values[i];
|
||||||
|
if (!std::isfinite(v)) continue;
|
||||||
|
vmin = std::min(vmin, v);
|
||||||
|
vmax = std::max(vmax, v);
|
||||||
|
}
|
||||||
|
if (vmin > vmax) continue;
|
||||||
|
const auto to_y = [&](double v) {
|
||||||
|
const double frac = (v - lo) / (hi - lo);
|
||||||
|
return base + strip_h - 1 - static_cast<int>(frac * (strip_h - 1));
|
||||||
|
};
|
||||||
|
const int y0 = std::clamp(to_y(vmax), base, base + strip_h - 1);
|
||||||
|
const int y1 = std::clamp(to_y(vmin), base, base + strip_h - 1);
|
||||||
|
for (int y = y0; y <= y1; ++y) put(x, y, colour, 1.0);
|
||||||
|
}
|
||||||
|
// The strip's own baseline, so an empty stretch reads as a flat trace and not as a gap.
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const int y = base + strip_h - 1;
|
||||||
|
put(x, y, 0x303840u, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trigger edge: the snapshot's x = 0, which is at the same fraction of every event's window.
|
||||||
|
if (!table.time.empty()) {
|
||||||
|
const double t0 = table.time.front();
|
||||||
|
const double t1 = table.time.back();
|
||||||
|
if (t1 > t0) {
|
||||||
|
const int x = static_cast<int>((0.0 - t0) / (t1 - t0) * (width - 1));
|
||||||
|
for (int y = 0; y < total_h; ++y) put(x, y, 0xFFE9A0u, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::FILE* f = std::fopen(path.c_str(), "wb");
|
||||||
|
if (f == nullptr) return false;
|
||||||
|
const std::uint32_t image_bytes = std::uint32_t(row_bytes) * std::uint32_t(total_h);
|
||||||
|
const std::uint32_t file_bytes = 54u + image_bytes;
|
||||||
|
unsigned char header[54] = {};
|
||||||
|
header[0] = 'B';
|
||||||
|
header[1] = 'M';
|
||||||
|
const auto put32 = [&](int at, std::uint32_t v) {
|
||||||
|
header[at] = static_cast<unsigned char>(v & 0xFF);
|
||||||
|
header[at + 1] = static_cast<unsigned char>((v >> 8) & 0xFF);
|
||||||
|
header[at + 2] = static_cast<unsigned char>((v >> 16) & 0xFF);
|
||||||
|
header[at + 3] = static_cast<unsigned char>((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
put32(2, file_bytes);
|
||||||
|
put32(10, 54u);
|
||||||
|
put32(14, 40u);
|
||||||
|
put32(18, std::uint32_t(width));
|
||||||
|
put32(22, std::uint32_t(total_h));
|
||||||
|
header[26] = 1; // planes
|
||||||
|
header[28] = 24; // bits per pixel
|
||||||
|
put32(34, image_bytes);
|
||||||
|
put32(38, 2835u); // 72 dpi, in pixels per metre
|
||||||
|
put32(42, 2835u);
|
||||||
|
std::fwrite(header, 1, sizeof(header), f);
|
||||||
|
std::fwrite(pixels.data(), 1, pixels.size(), f);
|
||||||
|
std::fclose(f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string event_meta_text(const EventRecord& r) {
|
||||||
|
char buf[512];
|
||||||
|
std::string out;
|
||||||
|
std::snprintf(buf, sizeof(buf), "wall_time = %.6f\n", r.wall_time);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "duration_s = %.6f\n", r.duration_s);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "pre_s = %.6f\n", r.pre_s);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "post_s = %.6f\n", r.post_s);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "edge = %.6f\n", r.edge);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "rate_hz = %.6g\n", r.rate_hz);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "signals = %zu\n", r.signals);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "samples = %zu\n", r.samples);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "seq = %llu\n", static_cast<unsigned long long>(r.seq));
|
||||||
|
out += buf;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool event_meta_parse(const std::string& text, EventRecord* out) {
|
||||||
|
if (out == nullptr) return false;
|
||||||
|
bool any = false;
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < text.size()) {
|
||||||
|
const std::size_t end = text.find('\n', i);
|
||||||
|
const std::string line = text.substr(i, end == std::string::npos ? std::string::npos : end - i);
|
||||||
|
i = end == std::string::npos ? text.size() : end + 1;
|
||||||
|
const std::size_t eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) continue;
|
||||||
|
std::string key = line.substr(0, eq);
|
||||||
|
std::string value = line.substr(eq + 1);
|
||||||
|
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
|
||||||
|
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(0, 1);
|
||||||
|
const double number = std::atof(value.c_str());
|
||||||
|
if (key == "wall_time") {
|
||||||
|
out->wall_time = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "duration_s") {
|
||||||
|
out->duration_s = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "pre_s") {
|
||||||
|
out->pre_s = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "post_s") {
|
||||||
|
out->post_s = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "edge") {
|
||||||
|
out->edge = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "rate_hz") {
|
||||||
|
out->rate_hz = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "signals") {
|
||||||
|
out->signals = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||||
|
any = true;
|
||||||
|
} else if (key == "samples") {
|
||||||
|
out->samples = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||||
|
any = true;
|
||||||
|
} else if (key == "seq") {
|
||||||
|
out->seq = static_cast<std::uint64_t>(std::strtoull(value.c_str(), nullptr, 10));
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
|
||||||
|
|
||||||
|
EventStore::EventStore(std::string source, std::string base_dir) {
|
||||||
|
if (source.empty()) return;
|
||||||
|
std::string safe;
|
||||||
|
for (char c : source) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||||
|
c == '.' || c == '_' || c == '-';
|
||||||
|
safe.push_back(ok ? c : '_');
|
||||||
|
}
|
||||||
|
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
|
||||||
|
if (dir_.empty() || table.empty()) return {};
|
||||||
|
mkdirs(dir_);
|
||||||
|
const std::string stem = event_stem(meta.wall_time > 0.0 ? meta.wall_time : double(std::time(nullptr)),
|
||||||
|
meta.seq);
|
||||||
|
const std::string samples = dir_ + "/" + stem + ".npy";
|
||||||
|
std::string error;
|
||||||
|
if (!write_npy(samples, table, error)) {
|
||||||
|
std::fprintf(stderr, "[udpscope] event cache: %s\n", error.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
EventRecord record = meta;
|
||||||
|
record.stem = stem;
|
||||||
|
std::string text = event_meta_text(record);
|
||||||
|
if (std::FILE* f = std::fopen((dir_ + "/" + stem + ".txt").c_str(), "wb"); f != nullptr) {
|
||||||
|
std::fwrite(text.data(), 1, text.size(), f);
|
||||||
|
std::fclose(f);
|
||||||
|
}
|
||||||
|
write_preview(table, dir_ + "/" + stem + ".bmp");
|
||||||
|
prune(keep);
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EventStore::redraw_preview(const EventRecord& record) const {
|
||||||
|
if (dir_.empty() || record.stem.empty() || record.file.empty()) return {};
|
||||||
|
WaveformReader reader;
|
||||||
|
std::string error;
|
||||||
|
if (!reader.open(record.file, &error)) return {};
|
||||||
|
// The whole event in one table, which an event can afford where an export cannot: it is bounded by
|
||||||
|
// the trigger's own pre/post window, so this is seconds of samples and not a session's worth.
|
||||||
|
ExportTable table;
|
||||||
|
table.columns.resize(reader.names().size());
|
||||||
|
for (std::size_t c = 0; c < table.columns.size(); ++c) {
|
||||||
|
table.columns[c].name = reader.names()[c];
|
||||||
|
table.columns[c].values.reserve(reader.rows());
|
||||||
|
}
|
||||||
|
table.time.reserve(reader.rows());
|
||||||
|
std::vector<float> values(reader.names().size(), 0.0f);
|
||||||
|
double t = 0.0;
|
||||||
|
double abs_t = 0.0;
|
||||||
|
while (reader.next(&t, &abs_t, values.data())) {
|
||||||
|
table.time.push_back(t);
|
||||||
|
for (std::size_t c = 0; c < table.columns.size(); ++c) {
|
||||||
|
table.columns[c].values.push_back(values[c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::string path = dir_ + "/" + record.stem + ".bmp";
|
||||||
|
if (!write_preview(table, path)) return {};
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<EventRecord> EventStore::list() const {
|
||||||
|
std::vector<EventRecord> out;
|
||||||
|
if (dir_.empty()) return out;
|
||||||
|
DIR* d = ::opendir(dir_.c_str());
|
||||||
|
if (d == nullptr) return out;
|
||||||
|
while (const dirent* e = ::readdir(d)) {
|
||||||
|
const std::string stem = stem_of(e->d_name, ".txt");
|
||||||
|
if (stem.empty()) continue;
|
||||||
|
std::FILE* f = std::fopen((dir_ + "/" + e->d_name).c_str(), "rb");
|
||||||
|
if (f == nullptr) continue;
|
||||||
|
std::string text;
|
||||||
|
char buf[512];
|
||||||
|
std::size_t n = 0;
|
||||||
|
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n);
|
||||||
|
std::fclose(f);
|
||||||
|
EventRecord r;
|
||||||
|
if (!event_meta_parse(text, &r)) continue;
|
||||||
|
r.stem = stem;
|
||||||
|
r.file = dir_ + "/" + stem + ".npy";
|
||||||
|
if (::access(r.file.c_str(), R_OK) != 0) continue; // a sidecar without its samples
|
||||||
|
struct stat sb {};
|
||||||
|
if (::stat((dir_ + "/" + stem + ".bmp").c_str(), &sb) == 0) {
|
||||||
|
r.thumbnail = dir_ + "/" + stem + ".bmp";
|
||||||
|
}
|
||||||
|
out.push_back(std::move(r));
|
||||||
|
}
|
||||||
|
::closedir(d);
|
||||||
|
// Newest first by the *record's* time, not the file's: a copy or a touch changes the mtime, and the
|
||||||
|
// list is about when the event happened.
|
||||||
|
std::stable_sort(out.begin(), out.end(), [](const EventRecord& a, const EventRecord& b) {
|
||||||
|
if (a.wall_time != b.wall_time) return a.wall_time > b.wall_time;
|
||||||
|
return a.seq > b.seq;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EventStore::forget(const std::string& stem) {
|
||||||
|
if (dir_.empty() || stem.empty()) return false;
|
||||||
|
const std::string base = dir_ + "/" + stem;
|
||||||
|
const bool gone = ::unlink((base + ".npy").c_str()) == 0;
|
||||||
|
::unlink((base + ".txt").c_str());
|
||||||
|
::unlink((base + ".bmp").c_str());
|
||||||
|
return gone;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventStore::prune(std::size_t keep) const {
|
||||||
|
const std::vector<EventRecord> all = list();
|
||||||
|
for (std::size_t i = keep; i < all.size(); ++i) {
|
||||||
|
const std::string base = dir_ + "/" + all[i].stem;
|
||||||
|
::unlink((base + ".npy").c_str());
|
||||||
|
::unlink((base + ".txt").c_str());
|
||||||
|
::unlink((base + ".bmp").c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "export.hpp"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// What a cached event is, without loading its samples: the sidecar's fields, and what the pane
|
||||||
|
/// shows in one row.
|
||||||
|
struct EventRecord {
|
||||||
|
std::string stem; ///< the file's name without the extension
|
||||||
|
std::string file; ///< `<stem>.npy`, the event's table in the export format
|
||||||
|
std::string thumbnail; ///< `<stem>.bmp`, the preview drawn from the samples; else empty
|
||||||
|
double wall_time = 0.0; ///< CLOCK_REALTIME at the latch, for the row's label
|
||||||
|
double duration_s = 0.0; ///< pre + post
|
||||||
|
double pre_s = 0.0;
|
||||||
|
double post_s = 0.0;
|
||||||
|
double edge = 0.0; ///< the stream time of the event's x = 0
|
||||||
|
double rate_hz = 0.0; ///< the stored sample rate at the latch
|
||||||
|
std::size_t signals = 0; ///< columns in the file
|
||||||
|
std::size_t samples = 0; ///< rows
|
||||||
|
std::uint64_t seq = 0; ///< the run's own event counter, so two events in one second differ
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The cache of captured events: `<cache_dir>/events/<source>/`, one `.npy` per event written in
|
||||||
|
/// the **export format** -- so `--open` and the chooser's Waveforms page read it back with the
|
||||||
|
/// loader that already exists -- beside a `.txt` sidecar holding what the list shows without
|
||||||
|
/// loading the samples, and whatever thumbnail the caller rendered.
|
||||||
|
///
|
||||||
|
/// No GTK: the caller renders the thumbnail and hands over a path, exactly as it hands over the
|
||||||
|
/// table. The store owns naming, the sidecar, pruning and listing, which is what makes it testable
|
||||||
|
/// without a window.
|
||||||
|
/// The directory every source's event folder is created under when no preference says otherwise:
|
||||||
|
/// `<cache>/udpscope/events`. Exposed because the preferences dialog shows it -- "the standard
|
||||||
|
/// location" is not an answer a user can act on, and a second copy of the path computed in the dialog
|
||||||
|
/// is a second thing to get wrong.
|
||||||
|
std::string default_events_dir();
|
||||||
|
|
||||||
|
class EventStore {
|
||||||
|
public:
|
||||||
|
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
|
||||||
|
/// `base_dir` is the directory every source's own folder is created under; empty means
|
||||||
|
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small
|
||||||
|
/// home partition and a second disk should not have to choose between events and free space.
|
||||||
|
explicit EventStore(std::string source = {}, std::string base_dir = {});
|
||||||
|
|
||||||
|
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
|
||||||
|
/// preferences are where this becomes the user's number.
|
||||||
|
static constexpr std::size_t kDefaultKeep = 32;
|
||||||
|
|
||||||
|
const std::string& dir() const noexcept { return dir_; }
|
||||||
|
bool empty_dir() const noexcept { return dir_.empty(); }
|
||||||
|
|
||||||
|
/// Writes `table` as one event, with `meta` in the sidecar and a preview drawn beside it. Returns
|
||||||
|
/// the samples' path, or an empty string on failure -- a cache that cannot be written must not stop
|
||||||
|
/// the scope.
|
||||||
|
///
|
||||||
|
/// The preview is drawn from the *data* -- min/max per pixel column, one strip per signal -- and not
|
||||||
|
/// captured from the window: the plot is a `GtkGLArea`, and a GL area's ink is not in the widget's
|
||||||
|
/// paint tree, so a snapshot of it is empty (`gtk_snapshot_free_to_node()` returns NULL). Drawing it
|
||||||
|
/// here also keeps this class GTK-free, which is what makes it testable without a window.
|
||||||
|
std::string save(const ExportTable& table, const EventRecord& meta,
|
||||||
|
std::size_t keep = kDefaultKeep);
|
||||||
|
|
||||||
|
/// The cached events, newest first: the sidecars, never the sample files, so a listing is cheap
|
||||||
|
/// however large the captures are.
|
||||||
|
std::vector<EventRecord> list() const;
|
||||||
|
|
||||||
|
/// Draws `record`'s preview again from its samples and returns the thumbnail's path, or an empty
|
||||||
|
/// string when the samples cannot be read.
|
||||||
|
///
|
||||||
|
/// This is the repair for a preview that is missing or will not decode -- a write that failed, a
|
||||||
|
/// truncated file, a cache written by an older build. The `.npy` is the event, the `.bmp` is only a
|
||||||
|
/// picture of it, so a lost picture is recoverable and must not be shown as a blank row. It reads
|
||||||
|
/// the samples, so unlike `list()` it is **not** cheap: call it for the one event that needs it.
|
||||||
|
std::string redraw_preview(const EventRecord& record) const;
|
||||||
|
|
||||||
|
/// Deletes one event's samples, sidecar and thumbnail.
|
||||||
|
bool forget(const std::string& stem);
|
||||||
|
|
||||||
|
/// Deletes the oldest events beyond `keep`.
|
||||||
|
void prune(std::size_t keep) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string dir_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The sidecar's text, and back. One `key = value` per line, the project's own TOML subset, so a
|
||||||
|
/// human can read the cache with `cat` -- which is the point of a forensic directory.
|
||||||
|
std::string event_meta_text(const EventRecord& record);
|
||||||
|
bool event_meta_parse(const std::string& text, EventRecord* out);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+26
-2
@@ -127,7 +127,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
table.abs_seconds.resize(n);
|
table.abs_seconds.resize(n);
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
table.time[i] = st[first + i];
|
table.time[i] = st[first + i];
|
||||||
table.abs_seconds[i] = abs_base + table.time[i];
|
table.abs_seconds[i] = snap->abs_stamps.empty()
|
||||||
|
? abs_base + table.time[i]
|
||||||
|
: snap->abs_stamps[first + i];
|
||||||
}
|
}
|
||||||
for (const int c : chans) {
|
for (const int c : chans) {
|
||||||
const std::size_t ch = static_cast<std::size_t>(c);
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
@@ -159,9 +161,16 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
store.time().copy_from(first, n, stamps.data());
|
store.time().copy_from(first, n, stamps.data());
|
||||||
table.time.resize(static_cast<std::size_t>(n));
|
table.time.resize(static_cast<std::size_t>(n));
|
||||||
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
||||||
|
std::vector<f64> abs_stamps;
|
||||||
|
if (const plot::data::RingBuffer<f64>* abs_ring = store.abs_time(); abs_ring != nullptr) {
|
||||||
|
abs_stamps.resize(static_cast<std::size_t>(n));
|
||||||
|
abs_ring->copy_from(first, n, abs_stamps.data());
|
||||||
|
}
|
||||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||||
table.time[i] = stamps[i] - s0;
|
table.time[i] = stamps[i] - s0;
|
||||||
table.abs_seconds[i] = abs_base + table.time[i];
|
table.abs_seconds[i] = abs_stamps.empty()
|
||||||
|
? abs_base + table.time[i]
|
||||||
|
: model.epoch() + abs_stamps[i];
|
||||||
}
|
}
|
||||||
for (const int c : chans) {
|
for (const int c : chans) {
|
||||||
const std::size_t ch = static_cast<std::size_t>(c);
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
@@ -173,6 +182,21 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot) {
|
||||||
|
ExportTable table;
|
||||||
|
table.time = snapshot.stamps;
|
||||||
|
table.abs_seconds = snapshot.abs_stamps.empty() ? snapshot.stamps : snapshot.abs_stamps;
|
||||||
|
std::lock_guard<std::mutex> lock(model.mutex());
|
||||||
|
const std::vector<ChannelInfo>& channels = model.channels();
|
||||||
|
for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) {
|
||||||
|
ExportTable::Column column;
|
||||||
|
column.name = channels[c].name;
|
||||||
|
column.values = snapshot.values[c];
|
||||||
|
table.columns.push_back(std::move(column));
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
|
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
|
||||||
if (table.empty()) {
|
if (table.empty()) {
|
||||||
error = "nothing to export: no signal is plotted in the visible range";
|
error = "nothing to export: no signal is plotted in the visible range";
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ struct ExportTable {
|
|||||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||||
|
|
||||||
|
/// The held event as a table, built from the **snapshot** rather than from the rows: a cache of the
|
||||||
|
/// acquisition, every channel the event holds, independent of which traces a row happens to show
|
||||||
|
/// when the latch lands. The channel names are the model's, in channel order -- the same order the
|
||||||
|
/// columns come out in, so `--open` restores the same signals.
|
||||||
|
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot);
|
||||||
|
|
||||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||||
/// about half the size of an all-`f8` one and invents no precision.
|
/// about half the size of an all-`f8` one and invents no precision.
|
||||||
|
|||||||
+13
-2
@@ -6,8 +6,19 @@ interface. Pass it on the command line, or let udpscope ask.
|
|||||||
<b>How to reach it</b>
|
<b>How to reach it</b>
|
||||||
|
|
||||||
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
|
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
|
||||||
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
|
<tt>--server</tt> and a chooser appears first: one row per stored <i>configuration</i>
|
||||||
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
|
(<tt>host:port · name</tt>, its note, and the settings it carries), newest first, plus a
|
||||||
|
<b>New host…</b> form that takes the address <i>and</i> the configuration it belongs to — its name
|
||||||
|
and note, the <b>buffer size</b>, and whether time is relative or absolute. A row's pencil button
|
||||||
|
opens the same form on that configuration, pre-filled, and changes to its name or address move it
|
||||||
|
(the old file is deleted).
|
||||||
|
|
||||||
|
The second page, <b>Waveforms</b>, opens an <i>exported</i> run instead of a live source: the files
|
||||||
|
opened before (newest first; one that has since been deleted is marked <i>missing</i>) and an
|
||||||
|
<b>Open…</b> button for a new one. The same file can be opened straight from the command line with
|
||||||
|
<tt>--open FILE</tt>. A waveform is a source that never arrives — no socket, no acquisition, no
|
||||||
|
starvation notice — and every instrument works on it: cursors, measurements, math channels and
|
||||||
|
another export. Only <tt>.npy</tt> files are read. form for one it has not seen. The chooser runs before any
|
||||||
buffer is allocated and before any socket is opened, so declining it costs nothing.
|
buffer is allocated and before any socket is opened, so declining it costs nothing.
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
<b>Worth knowing</b>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ the overflow menu opens the editor where the unit and the calibration live.
|
|||||||
|
|
||||||
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
|
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
|
||||||
nobody acquires can never fire, so arming a trigger enables its signal for you.
|
nobody acquires can never fire, so arming a trigger enables its signal for you.
|
||||||
|
• <b>Calibrate</b> in a signal's row menu opens the gain and offset it is <i>currently</i> drawn
|
||||||
|
through, so a trace already scaled by a gain of 2 shows 2 — pressing <i>Apply</i> confirms it
|
||||||
|
rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0.
|
||||||
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
|
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
|
||||||
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
|
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
|
||||||
for a second after one of those is the ring refilling, not a fault.
|
for a second after one of those is the ring refilling, not a fault.
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ corner leaves it.
|
|||||||
|
|
||||||
<b>Worth knowing</b>
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• A plot's own <b>right-click menu</b> (the same compact rows as the app's menu) holds the
|
||||||
|
y-axis scale, the two fits, and <b>Remove signal</b>, which swaps the menu to that plot's traces
|
||||||
|
plus <i>All traces on this plot</i> — with a <b>Back</b> row to return. That is how a trace comes
|
||||||
|
off a row without going through the layout editor.
|
||||||
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
||||||
traces already on a row the drop is refused.
|
traces already on a row the drop is refused.
|
||||||
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ pointer is over, and because the rows share one time axis, an x change moves all
|
|||||||
<b>How to reach it</b>
|
<b>How to reach it</b>
|
||||||
|
|
||||||
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
||||||
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
|
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
|
||||||
|
<b>Alt+drag</b> on values only.
|
||||||
|
The area being chosen is framed while you drag it, so you can see what the release will apply.
|
||||||
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
||||||
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
||||||
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||||
|
|||||||
+44
-3
@@ -1,16 +1,40 @@
|
|||||||
<b>Trigger</b>
|
<b>Trigger</b>
|
||||||
|
|
||||||
The trigger watches one signal for an edge through a level and captures a window around it.
|
The trigger watches one signal for a condition and captures a window around it. The
|
||||||
|
<b>Shape</b> dropdown chooses what counts as an event; the arming, hold-off, pre/post window
|
||||||
|
and Normal/Single mode are the same for all four shapes.
|
||||||
The captured window is frozen on screen until you re-arm.
|
The captured window is frozen on screen until you re-arm.
|
||||||
|
|
||||||
<b>How to reach it</b>
|
<b>How to reach it</b>
|
||||||
|
|
||||||
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
|
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
|
||||||
the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger share.
|
the shape, the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger
|
||||||
<b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
|
share. <b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
|
||||||
|
<b>Search the buffer</b> is at the foot of the same panel.
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
|
||||||
|
(<tt>~/.cache/udpscope/events/<source>/</tt>) as a <tt>.npy</tt> of the event, a
|
||||||
|
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
|
||||||
|
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
|
||||||
|
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
|
||||||
|
(time, duration, signals, rate), each row with its own picture beside the numbers. Clicking a row
|
||||||
|
<i>replays</i> it: the stored event is loaded exactly like an exported file, <b>without changing the
|
||||||
|
layout</b> -- the rows, the colours, the window, the cursors and the measurements stay where they
|
||||||
|
are and read the stored data instead. A replay leaves trigger mode, because a file has no edge to
|
||||||
|
wait for, and the pane stays open. Drag the pane's edge to resize it. <b>Live</b> goes back to the
|
||||||
|
stream; the status bar names the file until you do.
|
||||||
|
Select two or more events in the list and the two buttons under the header act on them:
|
||||||
|
<b>Compare</b> puts two events in two plots side by side, keeping the layout you have -- one plot per
|
||||||
|
event, the axes synchronised, so zooming a row in one zooms the same row in the other -- and
|
||||||
|
<b>Accumulate</b> overlays up to four of them in the same traces, translucent, so where they agree
|
||||||
|
the trace is brighter. Your own traces stay where they are in both. They need events with the same
|
||||||
|
signals and the same length. A single click selects, a <i>double</i> click replays.
|
||||||
|
<b>Record events</b>, in the trigger panel with the trigger's other options, is what decides whether
|
||||||
|
anything is kept. It is <b>off by default</b> -- nothing is written to disk until you ask -- and
|
||||||
|
with it off the history button goes with it.
|
||||||
|
|
||||||
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
|
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
|
||||||
can never fire.
|
can never fire.
|
||||||
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
|
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
|
||||||
@@ -27,6 +51,23 @@ the edge (rising, falling, either), the mode (Normal or Single) and the pre-trig
|
|||||||
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
|
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
|
||||||
A spike narrower than one decimation group is filtered away rather than triggering on data
|
A spike narrower than one decimation group is filtered away rather than triggering on data
|
||||||
the buffer does not contain.
|
the buffer does not contain.
|
||||||
|
• The four shapes: <b>edge</b> — a crossing of the level in the chosen direction, the classic
|
||||||
|
behaviour. <b>Pulse width</b> — a crossing and its return, qualified by how long the excursion
|
||||||
|
lasted: less than, greater than, within or outside a pair of widths; the event is timed at the
|
||||||
|
closing edge. <b>Window</b> — the signal entering or leaving a band between two levels; the
|
||||||
|
panel keeps the two levels at least one increment apart, because a zero-width band is not a
|
||||||
|
band: the condition becomes an exact float-equality test against the level, which an ordinary
|
||||||
|
signal never satisfies and a signal sitting at the level satisfies on every transition.
|
||||||
|
<b>Dropout</b> —
|
||||||
|
the absence of a crossing for longer than a timeout; one event per outage, re-arms when a
|
||||||
|
crossing returns.
|
||||||
|
• The shape's own rows are the only ones shown; the rest are hidden, so the panel never offers
|
||||||
|
a pulse width to a window trigger.
|
||||||
|
• <b>Search the buffer</b> runs the same condition over the whole retained ring and lists every
|
||||||
|
place it holds. The arrows step through hits; each step pauses on the hit, centred, exactly as
|
||||||
|
the Pause button does. Up to 16 hits in the visible window are marked on every row; when there
|
||||||
|
are more the panel says how many and suggests zooming in. The search uses the trigger's own
|
||||||
|
condition, set in one place, so a hit is by construction an instant the trigger would have fired.
|
||||||
|
|
||||||
<b>Example</b>
|
<b>Example</b>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ set, with <b>Save</b> and <b>Delete</b>.
|
|||||||
small value is <tt>0.00</tt>.
|
small value is <tt>0.00</tt>.
|
||||||
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
||||||
window. The values are still exact: a bucket's sum is a sum.
|
window. The values are still exact: a bucket's sum is a sum.
|
||||||
|
• <b>Statistics</b> adds a second line to every measurement showing <tt>n</tt>, the mean, the
|
||||||
|
standard deviation and the range. Each observation is one <i>recompute</i> of the pane, so
|
||||||
|
<tt>n</tt> counts windows measured, not samples — a mean frequency over many windows is what
|
||||||
|
that claims to be. <b>Reset</b> clears every accumulator and starts fresh. A change of display
|
||||||
|
window, a CONFIG or a measurement edit also resets; a pause, a resume, a zoom, a pan and the
|
||||||
|
toggle itself do not, so turning the line off to read the plot and back on again keeps the history.
|
||||||
|
|
||||||
<b>Example</b>
|
<b>Example</b>
|
||||||
|
|
||||||
|
|||||||
+41
-15
@@ -1,34 +1,60 @@
|
|||||||
<b>Sessions and settings</b>
|
<b>Sessions and settings</b>
|
||||||
|
|
||||||
udpscope stores one session file per source, so reopening a source gives you the scope you
|
udpscope stores named sessions — <i>configurations</i> — per source, so reopening a source gives
|
||||||
closed. There is no global preferences file: the session <i>is</i> the settings.
|
you the scope you closed, and one source can hold several arrangements for different experiments.
|
||||||
|
There is no global preferences file: the session <i>is</i> the settings.
|
||||||
|
|
||||||
<b>How to reach it</b>
|
<b>How to reach it</b>
|
||||||
|
|
||||||
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
|
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
|
||||||
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
|
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
|
||||||
unset. Each file is named <tt><host>_<port>.toml</tt>.
|
unset. Each source has a directory <tt><host>_<port>/</tt> holding one
|
||||||
|
<tt><name>.toml</tt> per configuration and an <tt>active</tt> file naming the one it is on.
|
||||||
|
|
||||||
|
<b>Configurations</b>
|
||||||
|
|
||||||
|
<b>Configurations…</b> in the menu lists the running source's configurations: click a row to open
|
||||||
|
it, <tt>×</tt> to delete it, or type a name and a note and press <i>Save this session</i> to keep
|
||||||
|
the current arrangement under a new one. Opening a configuration saves the current session first,
|
||||||
|
so switching never loses what it switches from.
|
||||||
|
|
||||||
|
The startup chooser (start with no <tt>--server</tt>) lists one row per configuration, with its
|
||||||
|
note and its settings, so two arrangements of one source are told apart before they open.
|
||||||
|
|
||||||
|
A configuration also carries the settings that can only be decided when the buffers are
|
||||||
|
allocated: the <b>buffer size</b> (<tt>--buffer-mb</tt>), relative time, and the packet capture's
|
||||||
|
ring size. Opening a configuration from the chooser applies all three; switching inside a running
|
||||||
|
app applies the rest and says in the status line that these take effect on the next start.
|
||||||
|
|
||||||
|
The chooser's form sets the first two for a new host, and its pencil button edits them on an
|
||||||
|
existing configuration — along with the name and the note. Renaming a configuration, or editing
|
||||||
|
the address it points at, moves it: the old file is removed rather than left behind.
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
• What a session persists: the time window, the rows and each row's slot signals, the
|
• What a configuration persists: the time window, the rows and each row's slot signals, the
|
||||||
calibration, which signals are enabled, the math channels with their graphs, and the
|
calibration, the trace colours, which signals are enabled, the math channels with their graphs,
|
||||||
measurements pane.
|
the measurements pane — and the startup settings: buffer size, relative time and the packet
|
||||||
|
capture's ring size.
|
||||||
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
|
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
|
||||||
• A file written by a run against a source whose streamer was down would name no signals at all,
|
• A file written by a run against a source whose streamer was down would name no signals at all,
|
||||||
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
|
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
|
||||||
configured nothing.
|
configured nothing. (The chooser hides such a file for the same reason.)
|
||||||
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
|
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
|
||||||
carry them. A file from an older build has neither key and loads as unicast on the default
|
carry them. A file from an older build has neither key and loads as unicast on the default
|
||||||
route.
|
route.
|
||||||
• The startup chooser lists only files that name at least one signal, which is what keeps it the
|
• The chooser lists only configurations that name at least one signal, which is what keeps it the
|
||||||
sources you configured rather than every port you have ever tested against.
|
sessions you configured rather than every port you have ever tested against.
|
||||||
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
|
• Deleting a configuration is a supported way to start over; the chooser's <tt>×</tt> and the
|
||||||
exactly that.
|
dialog's do exactly that.
|
||||||
|
• A session from before configurations existed is imported once as the configuration
|
||||||
|
<tt>default</tt>, and the old file is kept beside the directory as
|
||||||
|
<tt><host>_<port>.toml.imported</tt>.
|
||||||
|
|
||||||
<b>Example</b>
|
<b>Example</b>
|
||||||
|
|
||||||
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
|
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1, set a 2 s window, and save the
|
||||||
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
|
session as <tt>bench</tt> with a note. <tt>~/.config/udpscope/127.0.0.1_8000/bench.toml</tt> now
|
||||||
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
|
holds <tt>window_s=2 comment="…"</tt> with <tt>signals = ["sine", "ramp", "noise", ""]</tt>, and
|
||||||
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
|
the next run reports <tt>layout loaded from …/127.0.0.1_8000/bench.toml</tt> with those signals
|
||||||
|
already acquired.
|
||||||
+82
-118
@@ -16,12 +16,34 @@ namespace {
|
|||||||
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
||||||
/// One pointer per window, file-scope because there is one application.
|
/// One pointer per window, file-scope because there is one application.
|
||||||
///
|
///
|
||||||
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
|
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an
|
||||||
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
|
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
|
||||||
/// `adw_dialog_close()`.
|
/// `::closed` and closes with `adw_dialog_close()`.
|
||||||
GtkWidget* g_help = nullptr;
|
GtkWidget* g_help = nullptr;
|
||||||
|
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
|
||||||
|
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
|
||||||
|
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
|
||||||
|
/// gone.
|
||||||
|
GtkBuilder* g_help_ui = nullptr;
|
||||||
|
GtkWidget* g_help_body = nullptr;
|
||||||
|
GtkWidget* g_help_shots = nullptr;
|
||||||
AdwDialog* g_about = nullptr;
|
AdwDialog* g_about = nullptr;
|
||||||
AdwDialog* g_shortcuts = nullptr;
|
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
|
||||||
|
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
||||||
|
/// builder is replaced on the next opening rather than reused.
|
||||||
|
GtkWidget* g_shortcuts = nullptr;
|
||||||
|
GtkBuilder* g_shortcuts_ui = nullptr;
|
||||||
|
|
||||||
|
/// The help window's own close handler, rather than the shared one: this window also owns the label a
|
||||||
|
/// page is written into and the column its screenshots are built in, and a page switch that ran after
|
||||||
|
/// the window was gone would read both.
|
||||||
|
gboolean on_help_close(GtkWindow* window, gpointer) {
|
||||||
|
(void)window;
|
||||||
|
g_help = nullptr;
|
||||||
|
g_help_body = nullptr;
|
||||||
|
g_help_shots = nullptr;
|
||||||
|
return FALSE; // let the default handler destroy it
|
||||||
|
}
|
||||||
|
|
||||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||||
(void)window;
|
(void)window;
|
||||||
@@ -103,20 +125,18 @@ std::string load_page(const HelpPage& page, bool* ok) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
|
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
|
||||||
if (row == nullptr) return;
|
if (row == nullptr || g_help_body == nullptr) return;
|
||||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
|
||||||
if (idx < 0 || idx >= kPageCount) return;
|
if (idx < 0 || idx >= kPageCount) return;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
const std::string body = load_page(kPages[idx], &ok);
|
const std::string body = load_page(kPages[idx], &ok);
|
||||||
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
gtk_label_set_markup(GTK_LABEL(g_help_body), body.c_str());
|
||||||
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
// The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
|
||||||
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
// being found as the label's previous sibling: refilling it is a page *switch*, not a frame, so
|
||||||
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
// rebuilding it here is cheap and is also the only way to be sure the pictures are this page's and
|
||||||
// also the only way to be sure the pictures are this page's and not the last one's.
|
// not the last one's.
|
||||||
GtkWidget* label = GTK_WIDGET(body_label);
|
GtkWidget* shots = g_help_shots;
|
||||||
GtkWidget* parent = gtk_widget_get_parent(label);
|
|
||||||
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
|
||||||
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||||
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||||
gtk_box_remove(GTK_BOX(shots), old);
|
gtk_box_remove(GTK_BOX(shots), old);
|
||||||
@@ -196,47 +216,31 @@ void show_help(GtkWindow* parent) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
GtkWidget* window = gtk_window_new();
|
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and
|
||||||
g_help = window;
|
// `close-request` destroys the window, so the resource is parsed again rather than a dead window
|
||||||
gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
|
// fetched. This is where the previous one goes, and it is safe because that window has been closed
|
||||||
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
|
// for as long as it takes someone to ask for help again.
|
||||||
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
|
g_clear_object(&g_help_ui);
|
||||||
|
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
|
||||||
|
if (g_help_ui == nullptr) return;
|
||||||
|
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
|
||||||
|
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
|
||||||
|
GObject* body = gtk_builder_get_object(g_help_ui, "body");
|
||||||
|
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
|
||||||
|
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
|
||||||
|
// null widget on the first page switch.
|
||||||
|
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
|
||||||
|
g_help = GTK_WIDGET(window);
|
||||||
|
g_help_body = GTK_WIDGET(body);
|
||||||
|
g_help_shots = GTK_WIDGET(shots);
|
||||||
|
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
|
||||||
|
|
||||||
// One label, width-locked by the scrolled window, holding one page's markup. The
|
// The rows are the pages, which is data and not template: a heading row per section, then one row per
|
||||||
// sidebar swaps the markup rather than rebuilding anything.
|
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
|
||||||
GtkWidget* body = gtk_label_new(nullptr);
|
// what a GtkListView with one item factory can draw.
|
||||||
gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
|
|
||||||
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
|
||||||
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
|
||||||
gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
|
|
||||||
gtk_label_set_selectable(GTK_LABEL(body), TRUE);
|
|
||||||
gtk_widget_set_margin_start(body, 18);
|
|
||||||
gtk_widget_set_margin_end(body, 18);
|
|
||||||
gtk_widget_set_margin_top(body, 14);
|
|
||||||
gtk_widget_set_margin_bottom(body, 14);
|
|
||||||
|
|
||||||
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
|
||||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
|
||||||
GTK_POLICY_AUTOMATIC);
|
|
||||||
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
|
||||||
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
|
||||||
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
|
||||||
gtk_widget_set_margin_start(shot_box, 18);
|
|
||||||
gtk_widget_set_margin_end(shot_box, 18);
|
|
||||||
gtk_widget_set_margin_top(shot_box, 14);
|
|
||||||
gtk_widget_set_visible(shot_box, false);
|
|
||||||
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
|
||||||
gtk_box_append(GTK_BOX(page_box), shot_box);
|
|
||||||
gtk_box_append(GTK_BOX(page_box), body);
|
|
||||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
|
||||||
gtk_widget_set_hexpand(body_scroll, TRUE);
|
|
||||||
|
|
||||||
GtkWidget* list = gtk_list_box_new();
|
|
||||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
|
|
||||||
const char* current_section = nullptr;
|
const char* current_section = nullptr;
|
||||||
for (int i = 0; i < kPageCount; ++i) {
|
for (int i = 0; i < kPageCount; ++i) {
|
||||||
if (current_section == nullptr ||
|
if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
|
||||||
std::string(current_section) != kPages[i].section) {
|
|
||||||
current_section = kPages[i].section;
|
current_section = kPages[i].section;
|
||||||
GtkWidget* heading = gtk_label_new(nullptr);
|
GtkWidget* heading = gtk_label_new(nullptr);
|
||||||
char markup[128];
|
char markup[128];
|
||||||
@@ -263,89 +267,49 @@ void show_help(GtkWindow* parent) {
|
|||||||
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
|
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
|
||||||
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
||||||
}
|
}
|
||||||
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
|
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), nullptr);
|
||||||
|
|
||||||
GtkWidget* list_scroll = gtk_scrolled_window_new();
|
|
||||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
|
|
||||||
GTK_POLICY_AUTOMATIC);
|
|
||||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
|
|
||||||
gtk_widget_set_size_request(list_scroll, 220, -1);
|
|
||||||
|
|
||||||
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
|
||||||
gtk_box_append(GTK_BOX(split), list_scroll);
|
|
||||||
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
|
|
||||||
gtk_box_append(GTK_BOX(split), body_scroll);
|
|
||||||
gtk_window_set_child(GTK_WINDOW(window), split);
|
|
||||||
|
|
||||||
// Select the first page so the window never opens blank.
|
// Select the first page so the window never opens blank.
|
||||||
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
|
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
|
||||||
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
|
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
|
||||||
|
|
||||||
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
|
g_signal_connect(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
|
||||||
gtk_window_present(GTK_WINDOW(window));
|
gtk_window_present(GTK_WINDOW(g_help));
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
/// Appends one table row to `dialog`, opening a new section whenever the row's `section`
|
|
||||||
/// string differs from the last one. `*current` is the section being filled, `*title` the
|
|
||||||
/// name it carries; both are owned by the caller so the two tables share one run.
|
|
||||||
///
|
|
||||||
/// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and
|
|
||||||
/// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither
|
|
||||||
/// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse
|
|
||||||
/// "Wheel" costs a parse warning and draws nothing.
|
|
||||||
void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current,
|
|
||||||
const char** title, const char* section, const char* accel,
|
|
||||||
const char* subtitle, const char* description) {
|
|
||||||
if (*current == nullptr || std::string(*title) != section) {
|
|
||||||
*current = adw_shortcuts_section_new(section);
|
|
||||||
*title = section;
|
|
||||||
adw_shortcuts_dialog_add(dialog, *current);
|
|
||||||
}
|
|
||||||
AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel);
|
|
||||||
if (subtitle != nullptr && *subtitle != '\0') {
|
|
||||||
adw_shortcuts_item_set_subtitle(item, subtitle);
|
|
||||||
}
|
|
||||||
adw_shortcuts_section_add(*current, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void show_shortcuts(GtkWindow* parent) {
|
void show_shortcuts(GtkWindow* parent) {
|
||||||
if (g_shortcuts != nullptr) {
|
if (g_shortcuts != nullptr) {
|
||||||
adw_dialog_present(g_shortcuts, GTK_WIDGET(parent));
|
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AdwDialog* dialog = adw_shortcuts_dialog_new();
|
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
|
||||||
g_shortcuts = dialog;
|
// section per page, with the sidebar and the search box drawn by the window itself.
|
||||||
adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts");
|
//
|
||||||
|
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell
|
||||||
AdwShortcutsSection* section = nullptr;
|
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget --
|
||||||
const char* section_title = nullptr;
|
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic
|
||||||
int accel_count = 0;
|
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps
|
||||||
const Accel* accels = app_accels(&accel_count);
|
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
|
||||||
for (int i = 0; i < accel_count; ++i) {
|
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
|
||||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
//
|
||||||
accels[i].section, accels[i].accel, "", accels[i].description);
|
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
|
||||||
}
|
// destroys the window, so the resource is parsed again rather than an already-dead window fetched.
|
||||||
int gesture_count = 0;
|
g_clear_object(&g_shortcuts_ui);
|
||||||
const Gesture* gestures = plot_gestures(&gesture_count);
|
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
|
||||||
for (int i = 0; i < gesture_count; ++i) {
|
if (g_shortcuts_ui == nullptr) return;
|
||||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
GObject* overlay = gtk_builder_get_object(g_shortcuts_ui, "help_overlay");
|
||||||
gestures[i].section, gestures[i].accel, gestures[i].subtitle,
|
if (overlay == nullptr) return;
|
||||||
gestures[i].description);
|
g_shortcuts = GTK_WIDGET(overlay);
|
||||||
}
|
gtk_window_set_transient_for(GTK_WINDOW(g_shortcuts), parent);
|
||||||
|
g_signal_connect(g_shortcuts, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
|
||||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
|
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void close_help_windows() {
|
void close_help_windows() {
|
||||||
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
||||||
if (g_about != nullptr) adw_dialog_close(g_about);
|
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||||
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!-- The event history: a list of what was latched. A frame with three rows in it, the
|
||||||
|
last one short, so it reads as a list that ends rather than one that continues. Every
|
||||||
|
coordinate is a whole pixel of the 16px cell, and every subpath is a clockwise filled
|
||||||
|
`path`: a stroke, a line or a polyline keeps the colour written here and stays dark on
|
||||||
|
a dark theme, and the frame's hole needs `evenodd` because the rows sit inside it.
|
||||||
|
(No double hyphen anywhere in a comment here: xmllint, which preprocess= runs it
|
||||||
|
through, rejects one and fails the whole build.) -->
|
||||||
|
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#2e3436" fill-rule="evenodd" d="M0 1 H16 V15 H0 Z
|
||||||
|
M2 3 H14 V13 H2 Z"/>
|
||||||
|
<path fill="#2e3436" d="M4 4 H12 V6 H4 Z
|
||||||
|
M4 7 H12 V9 H4 Z
|
||||||
|
M4 10 H9 V12 H4 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!-- A boundary that can be dragged: a vertical double-headed arrow. It says *which* edge the handle
|
||||||
|
moves, which a plain grip does not. Every coordinate is a whole pixel of the 16px cell, every
|
||||||
|
subpath is a filled `path` wound the same way (a stroke would keep this colour on a dark theme),
|
||||||
|
and the two heads meet the shaft exactly, at x 5..11 and x 7..9. -->
|
||||||
|
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#2e3436" d="M7 4 H9 V12 H7 Z
|
||||||
|
M8 1 L11 5 H5 Z
|
||||||
|
M8 15 L5 11 H11 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 734 B |
@@ -21,6 +21,8 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
|
|||||||
+253
-34
@@ -31,6 +31,12 @@ std::string sanitize(const std::string& host) {
|
|||||||
return out.empty() ? "_" : out;
|
return out.empty() ? "_" : out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// Creates `path` and every missing component, the last one included, like `mkdir -p`. Outside the
|
||||||
|
/// anonymous namespace because the preferences file is written through it too: a bare `mkdir()` of
|
||||||
|
/// `<config>/udpscope` fails when `<config>` does not exist yet, which is what a fresh install looks
|
||||||
|
/// like -- and it fails *silently*, leaving the settings dialog reporting a write it did not make.
|
||||||
void mkdirs(const std::string& path) {
|
void mkdirs(const std::string& path) {
|
||||||
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
||||||
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
||||||
@@ -44,6 +50,8 @@ void mkdirs(const std::string& path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
std::string toml_escape(const std::string& s) {
|
std::string toml_escape(const std::string& s) {
|
||||||
std::string out = "\"";
|
std::string out = "\"";
|
||||||
for (char c : s) {
|
for (char c : s) {
|
||||||
@@ -114,43 +122,204 @@ std::string config_dir() {
|
|||||||
return std::string(home) + "/.config/udpscope";
|
return std::string(home) + "/.config/udpscope";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string cache_dir() {
|
||||||
|
const char* xdg = std::getenv("XDG_CACHE_HOME");
|
||||||
|
if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope";
|
||||||
|
const char* home = std::getenv("HOME");
|
||||||
|
if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-cache";
|
||||||
|
return std::string(home) + "/.cache/udpscope";
|
||||||
|
}
|
||||||
|
|
||||||
std::string layout_path(const std::string& host, std::uint16_t port) {
|
std::string layout_path(const std::string& host, std::uint16_t port) {
|
||||||
const std::string dir = config_dir();
|
const std::string dir = config_dir();
|
||||||
mkdirs(dir);
|
mkdirs(dir);
|
||||||
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<KnownSource> known_sources() {
|
std::string config_dir_for(const std::string& host, std::uint16_t port) {
|
||||||
std::vector<KnownSource> out;
|
return config_dir() + "/" + sanitize(host) + "_" + std::to_string(port);
|
||||||
const std::string dir = config_dir();
|
}
|
||||||
|
|
||||||
|
std::string configuration_path(const std::string& host, std::uint16_t port,
|
||||||
|
const std::string& name) {
|
||||||
|
const std::string dir = config_dir_for(host, port);
|
||||||
|
mkdirs(dir);
|
||||||
|
return dir + "/" + sanitize(name) + ".toml";
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool file_exists(const std::string& path) {
|
||||||
|
struct stat sb {};
|
||||||
|
return ::stat(path.c_str(), &sb) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session written before configurations existed is the configuration named `default`: copied
|
||||||
|
/// into the per-source directory, remembered as active, and the old file renamed out of the way
|
||||||
|
/// rather than deleted, so the import can be undone by hand. Runs before every read and write of
|
||||||
|
/// the per-source directory, so a machine that upgrades keeps its session.
|
||||||
|
void import_legacy(const std::string& host, std::uint16_t port) {
|
||||||
|
const std::string dir = config_dir_for(host, port);
|
||||||
|
if (file_exists(dir)) return;
|
||||||
|
const std::string legacy = layout_path(host, port);
|
||||||
|
if (!file_exists(legacy)) return;
|
||||||
|
mkdirs(dir);
|
||||||
|
FILE* in = std::fopen(legacy.c_str(), "rb");
|
||||||
|
if (in == nullptr) return;
|
||||||
|
const std::string target = dir + "/default.toml";
|
||||||
|
FILE* out = std::fopen(target.c_str(), "wb");
|
||||||
|
if (out == nullptr) {
|
||||||
|
std::fclose(in);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char buf[4096];
|
||||||
|
std::size_t n = 0;
|
||||||
|
while ((n = std::fread(buf, 1, sizeof(buf), in)) > 0) std::fwrite(buf, 1, n, out);
|
||||||
|
std::fclose(in);
|
||||||
|
std::fclose(out);
|
||||||
|
FILE* active = std::fopen((dir + "/active").c_str(), "wb");
|
||||||
|
if (active != nullptr) {
|
||||||
|
std::fputs("default\n", active);
|
||||||
|
std::fclose(active);
|
||||||
|
}
|
||||||
|
::rename(legacy.c_str(), (legacy + ".imported").c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a.toml` -> `a`; anything else -> "".
|
||||||
|
std::string toml_stem(const std::string& file) {
|
||||||
|
if (file.size() <= 5 || file.compare(file.size() - 5, 5, ".toml") != 0) return {};
|
||||||
|
return file.substr(0, file.size() - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host_port` -> {host, port}, or false. The *last* '_' is the split, because `sanitize()`
|
||||||
|
/// replaces anything unsafe in a host with one.
|
||||||
|
bool split_stem(const std::string& stem, std::string* host, std::uint16_t* port) {
|
||||||
|
const std::size_t cut = stem.rfind('_');
|
||||||
|
if (cut == std::string::npos || cut + 1 == stem.size()) return false;
|
||||||
|
char* end = nullptr;
|
||||||
|
const long p = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
||||||
|
if (end == nullptr || *end != '\0' || p <= 0 || p > 65535) return false;
|
||||||
|
*host = stem.substr(0, cut);
|
||||||
|
*port = static_cast<std::uint16_t>(p);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configurations a source directory holds, newest first, by file name.
|
||||||
|
std::vector<std::string> names_in_dir(const std::string& dir) {
|
||||||
|
std::vector<std::pair<std::int64_t, std::string>> found;
|
||||||
DIR* d = ::opendir(dir.c_str());
|
DIR* d = ::opendir(dir.c_str());
|
||||||
|
if (d == nullptr) return {};
|
||||||
|
while (const dirent* e = ::readdir(d)) {
|
||||||
|
const std::string stem = toml_stem(e->d_name);
|
||||||
|
if (stem.empty()) continue;
|
||||||
|
struct stat sb {};
|
||||||
|
const std::string path = dir + "/" + e->d_name;
|
||||||
|
std::int64_t mtime = 0;
|
||||||
|
if (::stat(path.c_str(), &sb) == 0) mtime = static_cast<std::int64_t>(sb.st_mtime);
|
||||||
|
found.emplace_back(mtime, stem);
|
||||||
|
}
|
||||||
|
::closedir(d);
|
||||||
|
std::stable_sort(found.begin(), found.end(),
|
||||||
|
[](const auto& a, const auto& b) { return a.first > b.first; });
|
||||||
|
std::vector<std::string> out;
|
||||||
|
out.reserve(found.size());
|
||||||
|
for (auto& f : found) out.push_back(std::move(f.second));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port) {
|
||||||
|
import_legacy(host, port);
|
||||||
|
return names_in_dir(config_dir_for(host, port));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string active_configuration(const std::string& host, std::uint16_t port) {
|
||||||
|
const std::vector<std::string> names = configuration_names(host, port);
|
||||||
|
if (names.empty()) return "default";
|
||||||
|
FILE* f = std::fopen((config_dir_for(host, port) + "/active").c_str(), "rb");
|
||||||
|
if (f != nullptr) {
|
||||||
|
char buf[512];
|
||||||
|
const std::size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);
|
||||||
|
std::fclose(f);
|
||||||
|
buf[n] = '\0';
|
||||||
|
const std::string want = trim(std::string(buf));
|
||||||
|
if (std::find(names.begin(), names.end(), want) != names.end()) return want;
|
||||||
|
}
|
||||||
|
// Newest, which is the one just saved when nothing wrote an `active`.
|
||||||
|
return names.front();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool set_active_configuration(const std::string& host, std::uint16_t port,
|
||||||
|
const std::string& name) {
|
||||||
|
const std::string dir = config_dir_for(host, port);
|
||||||
|
mkdirs(dir);
|
||||||
|
FILE* f = std::fopen((dir + "/active").c_str(), "wb");
|
||||||
|
if (f == nullptr) return false;
|
||||||
|
std::fputs(sanitize(name).c_str(), f);
|
||||||
|
std::fputs("\n", f);
|
||||||
|
std::fclose(f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name) {
|
||||||
|
const std::string path = configuration_path(host, port, name);
|
||||||
|
const bool gone = std::remove(path.c_str()) == 0;
|
||||||
|
const std::string active = active_configuration(host, port);
|
||||||
|
if (gone && sanitize(name) == sanitize(active)) {
|
||||||
|
std::remove((config_dir_for(host, port) + "/active").c_str());
|
||||||
|
}
|
||||||
|
return gone;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<KnownSource> known_sources() {
|
||||||
|
const std::string root = config_dir();
|
||||||
|
|
||||||
|
// A legacy single-file session is imported first, so the listing below has one shape: the
|
||||||
|
// per-source directory and its configuration files.
|
||||||
|
if (DIR* d = ::opendir(root.c_str())) {
|
||||||
|
while (const dirent* e = ::readdir(d)) {
|
||||||
|
const std::string stem = toml_stem(e->d_name);
|
||||||
|
if (stem.empty()) continue;
|
||||||
|
std::string host;
|
||||||
|
std::uint16_t port = 0;
|
||||||
|
if (!split_stem(stem, &host, &port)) continue;
|
||||||
|
// The scheme `layout_path()` writes, and nothing else: a stray `.toml` here is not ours.
|
||||||
|
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
||||||
|
import_legacy(host, port);
|
||||||
|
}
|
||||||
|
::closedir(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<KnownSource> out;
|
||||||
|
DIR* d = ::opendir(root.c_str());
|
||||||
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
||||||
if (d == nullptr) return out;
|
if (d == nullptr) return out;
|
||||||
while (const dirent* e = ::readdir(d)) {
|
while (const dirent* e = ::readdir(d)) {
|
||||||
const std::string name = e->d_name;
|
const std::string dir = root + "/" + e->d_name;
|
||||||
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
|
struct stat sb {};
|
||||||
const std::string stem = name.substr(0, name.size() - 5);
|
if (::stat(dir.c_str(), &sb) != 0 || !S_ISDIR(sb.st_mode)) continue;
|
||||||
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
|
std::string host;
|
||||||
// may hold them too and a first-match split would return a host cut in half.
|
std::uint16_t port = 0;
|
||||||
const std::size_t cut = stem.rfind('_');
|
if (!split_stem(e->d_name, &host, &port)) continue;
|
||||||
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
// The name must be one `config_dir_for()` could have written, or `forget_source()` would walk
|
||||||
char* end = nullptr;
|
// out of the directory the row came from.
|
||||||
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
if (sanitize(host) + "_" + std::to_string(port) != e->d_name) continue;
|
||||||
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
|
|
||||||
const std::string host = stem.substr(0, cut);
|
|
||||||
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
|
|
||||||
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
|
|
||||||
// any host that already came back out of a filename, so this is also true for hosts whose names
|
|
||||||
// contain `_` from the sanitizer.
|
|
||||||
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
|
||||||
|
|
||||||
|
// One row per *configuration*: the same source may hold several named sessions, which is the
|
||||||
|
// point of the feature, and the chooser is where they are told apart.
|
||||||
|
for (const std::string& name : names_in_dir(dir)) {
|
||||||
|
const std::string path = dir + "/" + name + ".toml";
|
||||||
LayoutState st;
|
LayoutState st;
|
||||||
const std::string path = dir + "/" + name;
|
|
||||||
if (!load_layout(path, st)) continue;
|
if (!load_layout(path, st)) continue;
|
||||||
|
|
||||||
KnownSource s;
|
KnownSource s;
|
||||||
s.host = host;
|
s.host = host;
|
||||||
s.port = static_cast<std::uint16_t>(port);
|
s.port = port;
|
||||||
|
s.config = name;
|
||||||
|
s.comment = st.comment;
|
||||||
|
s.buffer_mb = st.buffer_mb;
|
||||||
|
s.relative_time = st.relative_time;
|
||||||
|
s.capture_packets = st.capture_packets;
|
||||||
s.multicast = st.multicast;
|
s.multicast = st.multicast;
|
||||||
s.interface_addr = st.interface_addr;
|
s.interface_addr = st.interface_addr;
|
||||||
s.rows = static_cast<int>(st.rows.size());
|
s.rows = static_cast<int>(st.rows.size());
|
||||||
@@ -159,12 +328,18 @@ std::vector<KnownSource> known_sources() {
|
|||||||
if (!sig.empty()) ++s.signals;
|
if (!sig.empty()) ++s.signals;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (s.signals == 0) continue; // see the header: a session nobody configured
|
// A configuration nobody configured is not worth a row: a development machine accumulates one
|
||||||
|
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
|
||||||
struct stat sb {};
|
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
|
||||||
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
|
// set up from the chooser's form, and the settings are the whole point of editing one.
|
||||||
|
const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() ||
|
||||||
|
st.relative_time || st.capture_packets > 0;
|
||||||
|
if (!configured) continue;
|
||||||
|
struct stat fb {};
|
||||||
|
s.mtime = (::stat(path.c_str(), &fb) == 0) ? static_cast<std::int64_t>(fb.st_mtime) : 0;
|
||||||
out.push_back(std::move(s));
|
out.push_back(std::move(s));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
::closedir(d);
|
::closedir(d);
|
||||||
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
||||||
// swapping between runs.
|
// swapping between runs.
|
||||||
@@ -174,10 +349,10 @@ std::vector<KnownSource> known_sources() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool forget_source(const KnownSource& src) {
|
bool forget_source(const KnownSource& src) {
|
||||||
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
|
// Through `configuration_path()` rather than by rebuilding the name here: one expression of the
|
||||||
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
||||||
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
||||||
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
|
return forget_configuration(src.host, src.port, src.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string math_node_text(const MathNode& node) {
|
std::string math_node_text(const MathNode& node) {
|
||||||
@@ -191,7 +366,7 @@ std::string math_node_text(const MathNode& node) {
|
|||||||
std::ostringstream o;
|
std::ostringstream o;
|
||||||
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
|
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
|
||||||
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
|
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
|
||||||
<< node.x << '|' << node.y;
|
<< node.x << '|' << node.y << '|' << node.med_n;
|
||||||
return o.str();
|
return o.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +382,9 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.push_back(cur);
|
f.push_back(cur);
|
||||||
if (f.size() != 12u) return false;
|
// Twelve fields is a session file written before the de-noise node existed; its `med_n` stays at the
|
||||||
|
// struct's default, which is what that file meant.
|
||||||
|
if (f.size() != 12u && f.size() != 13u) return false;
|
||||||
const auto number = [&](std::size_t i, double& v) {
|
const auto number = [&](std::size_t i, double& v) {
|
||||||
try {
|
try {
|
||||||
v = std::stod(f[i]);
|
v = std::stod(f[i]);
|
||||||
@@ -227,8 +404,16 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
|
|||||||
out.source = f[1];
|
out.source = f[1];
|
||||||
out.in_a = static_cast<int>(in_a);
|
out.in_a = static_cast<int>(in_a);
|
||||||
out.in_b = static_cast<int>(in_b);
|
out.in_b = static_cast<int>(in_b);
|
||||||
return number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
|
if (!(number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
|
||||||
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y);
|
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (f.size() == 13u) {
|
||||||
|
double med = 0.0;
|
||||||
|
if (!number(12, med)) return false;
|
||||||
|
out.med_n = static_cast<int>(med);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool load_layout(const std::string& path, LayoutState& out) {
|
bool load_layout(const std::string& path, LayoutState& out) {
|
||||||
@@ -309,10 +494,26 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
plot_count = std::max(0, std::stoi(value));
|
plot_count = std::max(0, std::stoi(value));
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
}
|
}
|
||||||
|
} else if (key == "measure_stats") {
|
||||||
|
st.measure_stats = value == "true";
|
||||||
} else if (key == "multicast") {
|
} else if (key == "multicast") {
|
||||||
st.multicast = strip_quote(value);
|
st.multicast = strip_quote(value);
|
||||||
} else if (key == "interface") {
|
} else if (key == "interface") {
|
||||||
st.interface_addr = strip_quote(value);
|
st.interface_addr = strip_quote(value);
|
||||||
|
} else if (key == "comment") {
|
||||||
|
st.comment = strip_quote(value);
|
||||||
|
} else if (key == "buffer_mb") {
|
||||||
|
try {
|
||||||
|
st.buffer_mb = std::stod(value);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
} else if (key == "relative_time") {
|
||||||
|
st.relative_time = value == "true";
|
||||||
|
} else if (key == "capture_packets") {
|
||||||
|
try {
|
||||||
|
st.capture_packets = static_cast<std::uint32_t>(std::max(0, std::stoi(value)));
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (section == Section::Plot && !st.rows.empty()) {
|
} else if (section == Section::Plot && !st.rows.empty()) {
|
||||||
if (key == "signals") {
|
if (key == "signals") {
|
||||||
@@ -321,6 +522,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
parse_string_array(value.substr(1, value.size() - 2), names);
|
parse_string_array(value.substr(1, value.size() - 2), names);
|
||||||
}
|
}
|
||||||
st.rows.back().signals = names;
|
st.rows.back().signals = names;
|
||||||
|
} else if (key == "weight") {
|
||||||
|
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
|
||||||
}
|
}
|
||||||
} else if (section == Section::Math && !st.math.empty()) {
|
} else if (section == Section::Math && !st.math.empty()) {
|
||||||
MathChannel& m = st.math.back();
|
MathChannel& m = st.math.back();
|
||||||
@@ -363,6 +566,16 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
m.options.threshold_pct = std::stod(value);
|
m.options.threshold_pct = std::stod(value);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
}
|
}
|
||||||
|
} else if (key == "noise") {
|
||||||
|
try {
|
||||||
|
m.options.noise_pct = std::stod(value);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
} else if (key == "hf") {
|
||||||
|
try {
|
||||||
|
m.options.hf_fc = std::stod(value);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
} else if (key == "above") {
|
} else if (key == "above") {
|
||||||
m.options.above = value == "true";
|
m.options.above = value == "true";
|
||||||
} else if (key == "show") {
|
} else if (key == "show") {
|
||||||
@@ -431,8 +644,13 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
}
|
}
|
||||||
o << "]\n";
|
o << "]\n";
|
||||||
}
|
}
|
||||||
|
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
|
||||||
|
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
|
||||||
|
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
|
||||||
|
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||||
|
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
|
||||||
for (const auto& row : state.rows) {
|
for (const auto& row : state.rows) {
|
||||||
o << "\n[[plot]]\nsignals = [";
|
o << "\n[[plot]]\nweight = " << row.weight << "\nsignals = [";
|
||||||
for (std::size_t i = 0; i < row.signals.size(); ++i) {
|
for (std::size_t i = 0; i < row.signals.size(); ++i) {
|
||||||
if (i != 0) o << ", ";
|
if (i != 0) o << ", ";
|
||||||
o << toml_escape(row.signals[i]);
|
o << toml_escape(row.signals[i]);
|
||||||
@@ -470,7 +688,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
|
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
|
||||||
<< "\nthreshold = " << m.options.threshold_pct
|
<< "\nthreshold = " << m.options.threshold_pct
|
||||||
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
|
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
|
||||||
<< (m.options.show_on_plot ? "true" : "false") << "\n";
|
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
|
||||||
|
<< "\nhf = " << m.options.hf_fc << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
|
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
|
||||||
|
|||||||
+68
-4
@@ -20,6 +20,9 @@ struct LayoutState {
|
|||||||
struct Row {
|
struct Row {
|
||||||
/// One entry per slot; an empty string is an empty slot.
|
/// One entry per slot; an empty string is an empty slot.
|
||||||
std::vector<std::string> signals;
|
std::vector<std::string> signals;
|
||||||
|
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
|
||||||
|
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
|
||||||
|
double weight = 1.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
double window_s = 0.0; ///< 0 keeps the default window
|
double window_s = 0.0; ///< 0 keeps the default window
|
||||||
@@ -58,8 +61,27 @@ struct LayoutState {
|
|||||||
/// `math_node_text()` for the field order.
|
/// `math_node_text()` for the field order.
|
||||||
std::vector<MathChannel> math;
|
std::vector<MathChannel> math;
|
||||||
|
|
||||||
|
/// Whether the measurements pane shows its statistics line. The only piece of the
|
||||||
|
/// statistics that is persisted: the accumulated figures themselves describe a session's
|
||||||
|
/// stream and mean nothing restored against another one.
|
||||||
|
bool measure_stats = false;
|
||||||
|
|
||||||
/// The measurements pane, by signal *name* and with the type and options a row was built with.
|
/// The measurements pane, by signal *name* and with the type and options a row was built with.
|
||||||
std::vector<Measure> measures;
|
std::vector<Measure> measures;
|
||||||
|
|
||||||
|
/// Free text the user attached to this configuration: what it is for, what to look at first.
|
||||||
|
/// A configuration is a *named* session, and the name alone rarely says what makes it different
|
||||||
|
/// from its neighbour.
|
||||||
|
std::string comment;
|
||||||
|
|
||||||
|
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
|
||||||
|
/// why they live here and not only on the command line: two sources on one machine want different
|
||||||
|
/// buffers and different clocks, and retyping the flags to look at the second one is how a
|
||||||
|
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored":
|
||||||
|
/// the command line's value stands, so an old file cannot silently shrink a buffer.
|
||||||
|
double buffer_mb = 0.0;
|
||||||
|
bool relative_time = false;
|
||||||
|
std::uint32_t capture_packets = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A data source udpscope has a stored session for: one row of the startup chooser.
|
/// A data source udpscope has a stored session for: one row of the startup chooser.
|
||||||
@@ -71,13 +93,23 @@ struct KnownSource {
|
|||||||
int rows = 0; ///< plots the session had
|
int rows = 0; ///< plots the session had
|
||||||
int signals = 0; ///< named slots across those plots
|
int signals = 0; ///< named slots across those plots
|
||||||
std::int64_t mtime = 0; ///< orders the list; never shown
|
std::int64_t mtime = 0; ///< orders the list; never shown
|
||||||
|
/// Which *configuration* of that source this row is. A source may have several, each a named
|
||||||
|
/// session of its own, so the pair (host, port, config) is what identifies a row.
|
||||||
|
std::string config;
|
||||||
|
std::string comment; ///< the configuration's note, shown beside its name
|
||||||
|
/// The startup settings this configuration carries, so the chooser can apply them.
|
||||||
|
double buffer_mb = 0.0;
|
||||||
|
bool relative_time = false;
|
||||||
|
std::uint32_t capture_packets = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Every session file that names at least one signal, newest first.
|
/// Every configuration that names at least one signal *or* carries a setting, newest first.
|
||||||
///
|
///
|
||||||
/// The filter is what makes the list usable: a development machine accumulates one session file
|
/// The filter is what makes the list usable: a development machine accumulates one session file
|
||||||
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
|
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
|
||||||
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
|
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured — and a
|
||||||
|
/// buffer size, a note or a clock counts as configuring it, because that is how the chooser's form
|
||||||
|
/// sets a host up before it has ever streamed.
|
||||||
std::vector<KnownSource> known_sources();
|
std::vector<KnownSource> known_sources();
|
||||||
|
|
||||||
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
|
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
|
||||||
@@ -93,11 +125,43 @@ bool math_node_from_text(const std::string& text, MathNode& out);
|
|||||||
/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
|
/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
|
||||||
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
|
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
|
||||||
std::string config_dir();
|
std::string config_dir();
|
||||||
|
/// `$XDG_CACHE_HOME/udpscope`, or `~/.cache/udpscope`: where bulky, disposable state
|
||||||
|
/// lives (the packet captures; item 9's event cache). Created on demand by callers.
|
||||||
|
std::string cache_dir();
|
||||||
|
|
||||||
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
|
/// Creates `path` and every missing component, like `mkdir -p`. Public because the preferences are
|
||||||
/// Characters unsafe in a filename are replaced in the host part.
|
/// written under the config directory too.
|
||||||
|
void mkdirs(const std::string& path);
|
||||||
|
|
||||||
|
/// The *legacy* single-file path, `~/.config/udpscope/<host>_<port>.toml`, with the directory
|
||||||
|
/// created. Still the import source for a file written before configurations existed; nothing
|
||||||
|
/// writes here any more.
|
||||||
std::string layout_path(const std::string& host, std::uint16_t port);
|
std::string layout_path(const std::string& host, std::uint16_t port);
|
||||||
|
|
||||||
|
/// The per-source directory a source's configurations live in:
|
||||||
|
/// `~/.config/udpscope/<host>_<port>/`. Not created by this call.
|
||||||
|
std::string config_dir_for(const std::string& host, std::uint16_t port);
|
||||||
|
|
||||||
|
/// One configuration's file, `<config_dir_for(host, port)>/<name>.toml`, with the directory
|
||||||
|
/// created. The name is sanitized the way the host is, so a name the user typed with a slash in it
|
||||||
|
/// cannot escape the directory.
|
||||||
|
std::string configuration_path(const std::string& host, std::uint16_t port,
|
||||||
|
const std::string& name);
|
||||||
|
|
||||||
|
/// The source's configurations, newest first. A legacy single-file session appears here as the
|
||||||
|
/// configuration named `default`, and importing it is the first thing this does.
|
||||||
|
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port);
|
||||||
|
|
||||||
|
/// The configuration the source was last left in: `<dir>/active`, or the newest that exists when
|
||||||
|
/// nothing wrote one. Never empty for a source that has any configuration at all.
|
||||||
|
std::string active_configuration(const std::string& host, std::uint16_t port);
|
||||||
|
|
||||||
|
/// Remembers which configuration this source is on. Called by every save.
|
||||||
|
bool set_active_configuration(const std::string& host, std::uint16_t port, const std::string& name);
|
||||||
|
|
||||||
|
/// Deletes one configuration's file, and `active` if it named it.
|
||||||
|
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name);
|
||||||
|
|
||||||
/// Parses the file's TOML subset. Returns false when the file is missing or
|
/// Parses the file's TOML subset. Returns false when the file is missing or
|
||||||
/// malformed; `out` is left usable either way (a malformed file loads as much as
|
/// malformed; `out` is left usable either way (a malformed file loads as much as
|
||||||
/// parsed, which beats refusing a hand-edited file over one stray character).
|
/// parsed, which beats refusing a hand-edited file over one stray character).
|
||||||
|
|||||||
+54
-1
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include "acquisition.hpp"
|
#include "acquisition.hpp"
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
|
#include "settings.hpp"
|
||||||
|
#include "waveform.hpp"
|
||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
#include "plot_host.hpp"
|
#include "plot_host.hpp"
|
||||||
#include "source_dialog.hpp"
|
#include "source_dialog.hpp"
|
||||||
@@ -15,6 +17,10 @@ int main(int argc, char** argv) {
|
|||||||
udpscope::Config cfg;
|
udpscope::Config cfg;
|
||||||
try {
|
try {
|
||||||
cfg = udpscope::parse_args(argc, argv);
|
cfg = udpscope::parse_args(argc, argv);
|
||||||
|
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit
|
||||||
|
// instruction for this run, so a preference can only turn the mode on, never off -- which is also
|
||||||
|
// why this is read here, before the buffers are planned: the mode changes how they are filled.
|
||||||
|
if (udpscope::load_settings().relative_time) cfg.relative_time = true;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||||
return 2;
|
return 2;
|
||||||
@@ -22,18 +28,65 @@ int main(int argc, char** argv) {
|
|||||||
|
|
||||||
// No `--server` means the user has not said which source to open, so ask. Before the model:
|
// No `--server` means the user has not said which source to open, so ask. Before the model:
|
||||||
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
|
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
|
||||||
if (!cfg.server_specified && !udpscope::choose_source(cfg)) return 0;
|
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
|
||||||
|
// fills `open_waveform` in instead of the connection fields.
|
||||||
|
const bool asked = !cfg.server_specified && cfg.open_waveform.empty();
|
||||||
|
if (asked && !udpscope::choose_source(cfg)) return 0;
|
||||||
|
// A source named on the command line skips the chooser, but its *stored* configuration still
|
||||||
|
// applies: the buffer size, the clock and the capture ring are decided here, before the model --
|
||||||
|
// which is the only moment they can be -- and a saved host whose buffer was ignored because the
|
||||||
|
// address came from the command line is an edit that looks like it never took effect.
|
||||||
|
if (cfg.server_specified) udpscope::apply_stored_settings(cfg);
|
||||||
|
|
||||||
|
// Everything from here to `gtk_window_present()` inside `run_ui()` runs with no main loop: the
|
||||||
|
// buffer is allocated, a recording is read, then the whole UI is built. The chooser's window is
|
||||||
|
// already gone, so without this the screen is empty for all of it -- "the dialog disappeared and
|
||||||
|
// nothing happened", which at a few gigabytes of buffer is ten seconds long. Only on the path
|
||||||
|
// that *asked*: a source named with `--server` had no window to lose, and the status line after
|
||||||
|
// the present is the right place to say what such a run is waiting for.
|
||||||
|
if (asked) udpscope::loading_show(cfg);
|
||||||
|
|
||||||
udpscope::ScopeModel model(cfg);
|
udpscope::ScopeModel model(cfg);
|
||||||
udpscope::RowSet rows;
|
udpscope::RowSet rows;
|
||||||
rows.set_row_count(cfg.plots);
|
rows.set_row_count(cfg.plots);
|
||||||
udpscope::Acquisition acquisition(model, cfg);
|
udpscope::Acquisition acquisition(model, cfg);
|
||||||
|
if (cfg.open_waveform.empty()) {
|
||||||
try {
|
try {
|
||||||
acquisition.start();
|
acquisition.start();
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No socket and no thread: the file is the source, and there is nothing to arrive afterwards.
|
||||||
|
// The acquisition object is passed to the UI all the same -- stopped, which is what every
|
||||||
|
// "paused"/"capturing" question answers as false.
|
||||||
|
udpscope::WaveformLoadReport report;
|
||||||
|
std::string error;
|
||||||
|
if (!udpscope::load_waveform_into_model(model, cfg.open_waveform, &report, &error)) {
|
||||||
|
std::fprintf(stderr, "udpscope: %s\n", error.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// One row per column, so every trace opens where a user can see and rearrange it -- and sized
|
||||||
|
// *here*, before the UI exists, exactly as `--plots` does: resizing the rows from inside the
|
||||||
|
// activation leaves the panels built for the old count.
|
||||||
|
rows.set_row_count(std::min<int>(std::max<int>(cfg.plots, static_cast<int>(report.signals)),
|
||||||
|
udpscope::RowSet::kMaxRows));
|
||||||
|
udpscope::remember_waveform(cfg.open_waveform);
|
||||||
|
// The whole file framed from the start: the window is the recording's own span, and nothing
|
||||||
|
// afterwards restates it -- a zoom is the user's.
|
||||||
|
if (report.span_s > 0.0) rows.set_time_window(report.span_s);
|
||||||
|
// `rows`/`decim` say whether what is on screen is the file sample for sample or a filtered
|
||||||
|
// version of it: `samples` alone below `rows` is how a recording that did not fit its buffer at
|
||||||
|
// its own rate used to read as a truncated one.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] waveform loaded: %s signals=%zu samples=%zu rows=%zu decim=%llu "
|
||||||
|
"span=%.6fs rate=%.6g time=[%.6f..%.6f]\n",
|
||||||
|
cfg.open_waveform.c_str(), report.signals, report.samples, report.rows,
|
||||||
|
static_cast<unsigned long long>(report.decimation), report.span_s, report.rate_hz,
|
||||||
|
report.first_time, report.last_time);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
const int result = udpscope::run_ui(argc, argv, cfg, model, acquisition, rows);
|
const int result = udpscope::run_ui(argc, argv, cfg, model, acquisition, rows);
|
||||||
acquisition.stop();
|
acquisition.stop();
|
||||||
|
|||||||
+95
-2
@@ -59,8 +59,37 @@ void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, d
|
|||||||
|
|
||||||
/// One biquad step, transposed direct form II -- the form whose state words are the two things
|
/// One biquad step, transposed direct form II -- the form whose state words are the two things
|
||||||
/// that actually need carrying between samples.
|
/// that actually need carrying between samples.
|
||||||
|
/// One pole's coefficient for a corner of `fc` Hz at `dt` seconds a sample: `a = 1 - exp(-2*pi*fc*dt)`,
|
||||||
|
/// clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` returns 1, which is a pass-through -- so
|
||||||
|
/// "no pole" costs the step no branch.
|
||||||
|
double pole_a(double fc, double dt) {
|
||||||
|
if (!(fc > 0.0) || !(dt > 0.0)) return 1.0;
|
||||||
|
const double nyquist = 0.5 / dt;
|
||||||
|
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||||||
|
return 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The median window's length: odd, and between 3 and 9.
|
||||||
|
///
|
||||||
|
/// Odd because an even window's median is the average of two samples, which is a value the signal never
|
||||||
|
/// took -- and the whole point of a median here is that its output is a sample that was really there.
|
||||||
|
/// Rounded *up*, so a request for 4 gets 5 rather than 3. The ceiling of 9 is what `math_step`'s fixed
|
||||||
|
/// `double sorted[9]` is sized for.
|
||||||
|
int median_n(int want) {
|
||||||
|
int n = want < 3 ? 3 : (want > 9 ? 9 : want);
|
||||||
|
if ((n & 1) == 0) ++n;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
|
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
|
||||||
double& z2) {
|
double& z2) {
|
||||||
|
// A reserved hole is NaN in the source, and a NaN integrated into the state words is
|
||||||
|
// permanent: every later sample would come out NaN, so a filtered trace died at the first
|
||||||
|
// gap and never came back -- which is what a dropped packet did to every low pass in
|
||||||
|
// relative mode, where a counter gap reserves NaN slots by construction. A gap in the input
|
||||||
|
// is a gap in the output (the hole stays visible), and the filter's history is the last
|
||||||
|
// *real* samples it saw, so the state is left alone and the trace resumes after the hole.
|
||||||
|
if (!std::isfinite(x)) return kNaN;
|
||||||
const double y = b0 * x + z1;
|
const double y = b0 * x + z1;
|
||||||
z1 = b1 * x - a1 * y + z2;
|
z1 = b1 * x - a1 * y + z2;
|
||||||
z2 = b2 * x - a2 * y;
|
z2 = b2 * x - a2 * y;
|
||||||
@@ -83,11 +112,12 @@ const char* math_op_name(MathOp op) {
|
|||||||
case MathOp::Sub: return "difference";
|
case MathOp::Sub: return "difference";
|
||||||
case MathOp::Mul: return "product";
|
case MathOp::Mul: return "product";
|
||||||
case MathOp::Div: return "ratio";
|
case MathOp::Div: return "ratio";
|
||||||
|
case MathOp::DeNoise: return "de-noise";
|
||||||
}
|
}
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
int math_op_count() { return 12; }
|
int math_op_count() { return 13; }
|
||||||
|
|
||||||
MathOp math_op_at(int index) {
|
MathOp math_op_at(int index) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
@@ -102,7 +132,8 @@ MathOp math_op_at(int index) {
|
|||||||
case 8: return MathOp::Sum;
|
case 8: return MathOp::Sum;
|
||||||
case 9: return MathOp::Sub;
|
case 9: return MathOp::Sub;
|
||||||
case 10: return MathOp::Mul;
|
case 10: return MathOp::Mul;
|
||||||
default: return MathOp::Div;
|
case 11: return MathOp::Div;
|
||||||
|
default: return MathOp::DeNoise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +160,8 @@ bool math_op_uses_filter(MathOp op) {
|
|||||||
return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass;
|
return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass;
|
||||||
}
|
}
|
||||||
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
|
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
|
||||||
|
bool math_op_uses_fc(MathOp op) { return math_op_uses_filter(op) || op == MathOp::DeNoise; }
|
||||||
|
bool math_op_uses_median(MathOp op) { return op == MathOp::DeNoise; }
|
||||||
|
|
||||||
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
||||||
const std::function<int(const std::string&)>& index_of) {
|
const std::function<int(const std::string&)>& index_of) {
|
||||||
@@ -145,6 +178,17 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
|||||||
}
|
}
|
||||||
rt.dt = dt;
|
rt.dt = dt;
|
||||||
rt.ready = true;
|
rt.ready = true;
|
||||||
|
// The median windows, pooled: one pass to size the pool, then the node loop hands out the slices. A
|
||||||
|
// window starts from rest after a re-prepare -- the same choice the biquads' `z2` makes -- while the
|
||||||
|
// pole's `z1` is carried, so a decimation change does not make the trace jump.
|
||||||
|
std::size_t med_total = 0;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
if (channel.nodes[i].op == MathOp::DeNoise) {
|
||||||
|
med_total += static_cast<std::size_t>(median_n(channel.nodes[i].med_n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rt.median.assign(med_total, 0.0);
|
||||||
|
std::size_t med_next = 0;
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
const MathNode& node = channel.nodes[i];
|
const MathNode& node = channel.nodes[i];
|
||||||
MathNodeRt& out = rt.node[i];
|
MathNodeRt& out = rt.node[i];
|
||||||
@@ -152,6 +196,15 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
|||||||
out.src = index_of ? index_of(node.source) : -1;
|
out.src = index_of ? index_of(node.source) : -1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (node.op == MathOp::DeNoise) {
|
||||||
|
out.med_n = median_n(node.med_n);
|
||||||
|
out.med_at = med_next;
|
||||||
|
out.med_pos = 0;
|
||||||
|
out.med_have = 0;
|
||||||
|
med_next += static_cast<std::size_t>(out.med_n);
|
||||||
|
out.b0 = pole_a(node.fc, dt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!math_op_uses_filter(node.op)) continue;
|
if (!math_op_uses_filter(node.op)) continue;
|
||||||
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
|
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
|
||||||
}
|
}
|
||||||
@@ -214,6 +267,46 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
|||||||
x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0;
|
x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case MathOp::DeNoise: {
|
||||||
|
const double in = at(node.in_a);
|
||||||
|
// A hole stays a hole, and the window is left alone: folding a NaN into the median would smear one
|
||||||
|
// gap across `med_n` samples, and the whole trace after it in the pole. `med_n <= 0` is a node the
|
||||||
|
// runtime has not prepared -- the window pointer below would be a read past the pool, so it is
|
||||||
|
// checked rather than assumed.
|
||||||
|
if (!std::isfinite(in) || self.med_n <= 0) {
|
||||||
|
x = kNaN;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const std::size_t len = static_cast<std::size_t>(self.med_n);
|
||||||
|
double* win = rt.median.data() + self.med_at;
|
||||||
|
win[self.med_pos] = in;
|
||||||
|
self.med_pos = self.med_pos + 1 == len ? 0 : self.med_pos + 1;
|
||||||
|
if (self.med_have < len) ++self.med_have;
|
||||||
|
// An insertion sort of at most nine doubles into a local. The window is tiny and fixed, so this
|
||||||
|
// beats anything that allocates, and it leaves the ring itself in arrival order -- which is what
|
||||||
|
// lets the oldest sample be overwritten in place. Before the window has filled, the median is taken
|
||||||
|
// over what there is: a shorter window, deliberately, rather than NaN. A derived trace that reads
|
||||||
|
// "no data" for its first few samples after every rebuild looks like a broken graph.
|
||||||
|
double sorted[9];
|
||||||
|
const std::size_t m = self.med_have;
|
||||||
|
for (std::size_t j = 0; j < m; ++j) {
|
||||||
|
const double v = win[j];
|
||||||
|
std::size_t p = j;
|
||||||
|
while (p > 0 && sorted[p - 1] > v) {
|
||||||
|
sorted[p] = sorted[p - 1];
|
||||||
|
--p;
|
||||||
|
}
|
||||||
|
sorted[p] = v;
|
||||||
|
}
|
||||||
|
const double med = sorted[m / 2];
|
||||||
|
// Median *then* pole, and the order is the design. The median kills an impulse outright; the pole
|
||||||
|
// then takes the broadband hash it leaves behind. The other way round feeds the pole the impulse,
|
||||||
|
// which smears it over the pole's whole settling time and leaves the median a wide bump it cannot
|
||||||
|
// reject.
|
||||||
|
self.z1 += self.b0 * (med - self.z1);
|
||||||
|
x = self.z1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.value = x;
|
self.value = x;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ enum class MathOp {
|
|||||||
Sub, ///< a - b
|
Sub, ///< a - b
|
||||||
Mul, ///< a * b
|
Mul, ///< a * b
|
||||||
Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN)
|
Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN)
|
||||||
|
/// A running median of `med_n` samples, then a one-pole at `fc` Hz. For impulsive interference -- an EM
|
||||||
|
/// peak a few samples wide -- which a linear filter can only smear over its own settling time. `fc = 0`
|
||||||
|
/// is the median alone.
|
||||||
|
DeNoise,
|
||||||
};
|
};
|
||||||
|
|
||||||
const char* math_op_name(MathOp op);
|
const char* math_op_name(MathOp op);
|
||||||
@@ -41,6 +45,11 @@ int math_op_arity(MathOp op);
|
|||||||
bool math_op_uses_gain(MathOp op);
|
bool math_op_uses_gain(MathOp op);
|
||||||
bool math_op_uses_filter(MathOp op);
|
bool math_op_uses_filter(MathOp op);
|
||||||
bool math_op_uses_clamp(MathOp op);
|
bool math_op_uses_clamp(MathOp op);
|
||||||
|
/// Whether an op reads `fc`. Wider than `math_op_uses_filter()`, which keeps its present meaning of *a
|
||||||
|
/// biquad*: `DeNoise` reads `fc` for a one-pole and has no `q`.
|
||||||
|
bool math_op_uses_fc(MathOp op);
|
||||||
|
/// Whether an op reads `med_n`.
|
||||||
|
bool math_op_uses_median(MathOp op);
|
||||||
|
|
||||||
/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a
|
/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a
|
||||||
/// node may only read ones before it: the evaluator walks the list in order, which is what makes a
|
/// node may only read ones before it: the evaluator walks the list in order, which is what makes a
|
||||||
@@ -56,8 +65,23 @@ struct MathNode {
|
|||||||
double q = 0.707; ///< BP quality factor.
|
double q = 0.707; ///< BP quality factor.
|
||||||
double lo = -1.0; ///< `Clamp` bounds.
|
double lo = -1.0; ///< `Clamp` bounds.
|
||||||
double hi = 1.0;
|
double hi = 1.0;
|
||||||
|
/// `DeNoise`: the median window, in samples. Odd and between 3 and 9 -- `median_n()` in the
|
||||||
|
/// implementation is what enforces that, so a session file holding anything else still loads. An impulse
|
||||||
|
/// up to `med_n / 2` samples wide is rejected outright.
|
||||||
|
int med_n = 5;
|
||||||
double x = 0.0; ///< the editor's box position, logical pixels.
|
double x = 0.0; ///< the editor's box position, logical pixels.
|
||||||
double y = 0.0;
|
double y = 0.0;
|
||||||
|
|
||||||
|
/// Whether two nodes are the same node for the *engine's* purposes.
|
||||||
|
///
|
||||||
|
/// `x`/`y` are the canvas's own and no op reads them, so a graph that only moved is not a change:
|
||||||
|
/// rebuilding for it replaces the buffer and drops its history -- and in a file view that means
|
||||||
|
/// re-reading the file -- to store two numbers nothing but the editor draws with.
|
||||||
|
bool operator==(const MathNode& other) const {
|
||||||
|
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
|
||||||
|
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
|
||||||
|
lo == other.lo && hi == other.hi && med_n == other.med_n;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A derived channel: a graph of nodes and which node is the answer.
|
/// A derived channel: a graph of nodes and which node is the answer.
|
||||||
@@ -67,6 +91,11 @@ struct MathChannel {
|
|||||||
bool enabled = false;
|
bool enabled = false;
|
||||||
std::vector<MathNode> nodes;
|
std::vector<MathNode> nodes;
|
||||||
int output = -1; ///< the node whose value is the channel's.
|
int output = -1; ///< the node whose value is the channel's.
|
||||||
|
|
||||||
|
bool operator==(const MathChannel& other) const {
|
||||||
|
return name == other.name && unit == other.unit && enabled == other.enabled &&
|
||||||
|
output == other.output && nodes == other.nodes;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
|
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
|
||||||
@@ -94,10 +123,25 @@ struct MathNodeRt {
|
|||||||
/// per source node, per sample, behind a type-erased call. Names cannot change without a
|
/// per source node, per sample, behind a type-erased call. Names cannot change without a
|
||||||
/// rebuild, and every rebuild re-prepares.
|
/// rebuild, and every rebuild re-prepares.
|
||||||
int src = -1;
|
int src = -1;
|
||||||
|
/// `DeNoise`: where this node's median window starts in `MathRuntime::median`, how long it is, the ring's
|
||||||
|
/// write position, and how many samples it holds. The window is *pooled* rather than held here because a
|
||||||
|
/// node's state belongs on one cache line, and nine doubles would push this struct across two for every
|
||||||
|
/// node in the graph, filter or not.
|
||||||
|
///
|
||||||
|
/// The one-pole reuses `b0` for its coefficient and `z1` for its value, which is not a shortcut:
|
||||||
|
/// `math_prepare()` deliberately carries `z1` across a re-prepare, so the pole keeps its history through
|
||||||
|
/// a decimation change exactly as the biquads keep theirs.
|
||||||
|
std::size_t med_at = 0;
|
||||||
|
int med_n = 0;
|
||||||
|
std::size_t med_pos = 0;
|
||||||
|
std::size_t med_have = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct MathRuntime {
|
struct MathRuntime {
|
||||||
std::vector<MathNodeRt> node;
|
std::vector<MathNodeRt> node;
|
||||||
|
/// Every `DeNoise` node's median window, end to end: one allocation for the channel, sized by
|
||||||
|
/// `math_prepare()`, a node's slice starting at its own `med_at`.
|
||||||
|
std::vector<double> median;
|
||||||
/// The rate the coefficients were built for; a change rebuilds them.
|
/// The rate the coefficients were built for; a change rebuilds them.
|
||||||
double dt = 0.0;
|
double dt = 0.0;
|
||||||
bool ready = false;
|
bool ready = false;
|
||||||
|
|||||||
+31
-14
@@ -28,16 +28,13 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
|||||||
acc.open[e] = true;
|
acc.open[e] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
|
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
|
||||||
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
|
// setting now, and a second copy of the number here would be a second thing to keep in step.
|
||||||
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
|
|
||||||
/// on anything less square than a sine.
|
|
||||||
constexpr double kHysteresisFrac = 0.10;
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
|
||||||
const double span = s.hi - s.lo;
|
const double span = s.hi - s.lo;
|
||||||
acc.level = s.lo + pct * span;
|
acc.level = s.lo + pct * span;
|
||||||
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||||||
@@ -46,10 +43,21 @@ void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
|||||||
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||||||
// fixed band, `enters=4 period=0.200000` with the clamp.
|
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||||||
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||||||
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
|
const double want = noise_frac > 0.0 ? noise_frac * span : 0.0;
|
||||||
|
acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room));
|
||||||
}
|
}
|
||||||
|
|
||||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
void measure_filter_set(MeasureFilter& f, double fc, double dt) {
|
||||||
|
f.reset();
|
||||||
|
f.a = 1.0;
|
||||||
|
if (fc > 0.0 && dt > 0.0) {
|
||||||
|
const double nyquist = 0.5 / dt;
|
||||||
|
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||||||
|
f.a = 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) {
|
||||||
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
||||||
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
||||||
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
|
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
|
||||||
@@ -62,7 +70,7 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
|||||||
std::size_t at_hi = 0;
|
std::size_t at_hi = 0;
|
||||||
bool any = false;
|
bool any = false;
|
||||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||||
const double v = static_cast<double>(chunk.value[i]);
|
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||||||
if (!std::isfinite(v)) continue;
|
if (!std::isfinite(v)) continue;
|
||||||
if (!any) {
|
if (!any) {
|
||||||
lo = hi = v;
|
lo = hi = v;
|
||||||
@@ -143,12 +151,16 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
|
|||||||
|
|
||||||
void measure_accumulate(const MeasureChunk& chunk,
|
void measure_accumulate(const MeasureChunk& chunk,
|
||||||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||||||
std::size_t level_count) {
|
std::size_t level_count, MeasureFilter& filter) {
|
||||||
|
// Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. The filter
|
||||||
|
// holds one state for the whole walk, so a levels-outer loop would step it once per level per sample and
|
||||||
|
// every level after the first would read a signal filtered `k` times over. Each level's own state is
|
||||||
|
// independent of the others, so the swap costs nothing else.
|
||||||
|
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||||
|
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||||||
|
if (!std::isfinite(v)) continue;
|
||||||
for (std::size_t k = 0; k < level_count; ++k) {
|
for (std::size_t k = 0; k < level_count; ++k) {
|
||||||
MeasureLevel& acc = levels[k];
|
MeasureLevel& acc = levels[k];
|
||||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
|
||||||
const double v = static_cast<double>(chunk.value[i]);
|
|
||||||
if (!std::isfinite(v)) continue;
|
|
||||||
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||||||
// below the threshold, and a band here would move a documented exact reading.
|
// below the threshold, and a band here would move a documented exact reading.
|
||||||
const int s = v > acc.level ? 1 : 0;
|
const int s = v > acc.level ? 1 : 0;
|
||||||
@@ -278,6 +290,11 @@ bool measure_uses_threshold(MeasureType type) {
|
|||||||
|
|
||||||
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
|
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
|
||||||
|
|
||||||
|
bool measure_uses_noise(MeasureType type) {
|
||||||
|
return type == MeasureType::PulseDuration || type == MeasureType::Period ||
|
||||||
|
type == MeasureType::Frequency;
|
||||||
|
}
|
||||||
|
|
||||||
bool measure_can_show_on_plot(MeasureType) { return true; }
|
bool measure_can_show_on_plot(MeasureType) { return true; }
|
||||||
|
|
||||||
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
|
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
|
||||||
|
|||||||
+104
-3
@@ -2,7 +2,9 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -44,6 +46,23 @@ struct MeasureOptions {
|
|||||||
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The
|
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The
|
||||||
/// markers are readouts -- they follow the window, and the user cannot drag them.
|
/// markers are readouts -- they follow the window, and the user cannot drag them.
|
||||||
bool show_on_plot = false;
|
bool show_on_plot = false;
|
||||||
|
/// `PulseDuration`/`Period`/`Frequency`: the half-width of the hysteresis band about the threshold, as
|
||||||
|
/// a percentage of the window's own peak-to-peak. A crossing counts only once the signal has passed the
|
||||||
|
/// band, so an edge count is a count of the *waveform's* cycles rather than of its noise.
|
||||||
|
///
|
||||||
|
/// The default is measured rather than chosen: 5 % reads the mock's 0.1 interferer correctly and fails
|
||||||
|
/// at 0.2 (`period=0.075` for a true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third
|
||||||
|
/// of the waveform -- and to white noise at sigma=0.05. Above that the band starts eating a real
|
||||||
|
/// waveform's own extremes on anything less square than a sine. 0 reduces the walk exactly to a single
|
||||||
|
/// level.
|
||||||
|
double noise_pct = 10.0;
|
||||||
|
/// Every type: the corner of a one-pole low pass applied to each sample before *either* walk reads it,
|
||||||
|
/// so the extremes, the mean and the crossings all describe the filtered signal. 0 is off. In Hz.
|
||||||
|
///
|
||||||
|
/// It rejects what a band cannot: an impulse large enough to cross the band on its own. It is not free
|
||||||
|
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
|
||||||
|
/// so a non-zero corner costs a full raw walk.
|
||||||
|
double hf_fc = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
|
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
|
||||||
@@ -103,6 +122,37 @@ struct MeasureStats {
|
|||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The HF reject, as a walk carries it: one pole, one state word.
|
||||||
|
///
|
||||||
|
/// `a == 1` is a pass-through and is the default, so an unfiltered walk pays one multiply and no branch,
|
||||||
|
/// and every caller can step it unconditionally. A non-finite sample passes through unchanged and leaves
|
||||||
|
/// the state alone -- a dropout is a real reading, and letting it poison `y` would make every sample
|
||||||
|
/// after it non-finite too.
|
||||||
|
struct MeasureFilter {
|
||||||
|
double a = 1.0;
|
||||||
|
double y = 0.0;
|
||||||
|
bool have = false;
|
||||||
|
|
||||||
|
double step(double v) noexcept {
|
||||||
|
if (!std::isfinite(v)) return v;
|
||||||
|
if (!have) {
|
||||||
|
y = v;
|
||||||
|
have = true;
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
y += a * (v - y);
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
void reset() noexcept {
|
||||||
|
y = 0.0;
|
||||||
|
have = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` or `dt == 0`
|
||||||
|
/// leaves it at 1, which is the pass-through. Resets the state.
|
||||||
|
void measure_filter_set(MeasureFilter& f, double fc, double dt);
|
||||||
|
|
||||||
/// One chunk of a window's values, in time order, exactly as the rings hold them.
|
/// One chunk of a window's values, in time order, exactly as the rings hold them.
|
||||||
///
|
///
|
||||||
/// The engine is *fed* the window this way rather than handed a materialized copy. A visible
|
/// The engine is *fed* the window this way rather than handed a materialized copy. A visible
|
||||||
@@ -124,6 +174,9 @@ struct MeasureChunk {
|
|||||||
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
|
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
|
||||||
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
|
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
|
||||||
/// are opened.
|
/// are opened.
|
||||||
|
///
|
||||||
|
/// A bucket aggregates **unfiltered** samples, so a walk with an HF reject must not use these at all --
|
||||||
|
/// that is what makes a non-zero `hf_fc` cost a full raw walk.
|
||||||
struct MeasureBucket {
|
struct MeasureBucket {
|
||||||
double lo = 0.0;
|
double lo = 0.0;
|
||||||
double hi = 0.0;
|
double hi = 0.0;
|
||||||
@@ -137,7 +190,7 @@ struct MeasureBucket {
|
|||||||
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
|
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
|
||||||
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
|
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
|
||||||
/// that merely ties it must not move it.
|
/// that merely ties it must not move it.
|
||||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats);
|
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter);
|
||||||
|
|
||||||
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
|
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
|
||||||
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
|
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
|
||||||
@@ -190,14 +243,17 @@ struct MeasureLevel {
|
|||||||
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
||||||
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
||||||
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
||||||
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
|
/// `noise_frac` is `MeasureOptions::noise_pct / 100`. It is the caller's because the band is a
|
||||||
|
/// per-measurement setting and this is called once per *level*, which several measurements may share; the
|
||||||
|
/// clamp to half the room to the nearer extreme stays here, so every caller gets it.
|
||||||
|
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct, double noise_frac);
|
||||||
|
|
||||||
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
||||||
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
||||||
/// measurements on one signal costs two walks, not two per measurement.
|
/// measurements on one signal costs two walks, not two per measurement.
|
||||||
void measure_accumulate(const MeasureChunk& chunk,
|
void measure_accumulate(const MeasureChunk& chunk,
|
||||||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||||||
std::size_t level_count);
|
std::size_t level_count, MeasureFilter& filter);
|
||||||
|
|
||||||
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
|
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
|
||||||
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
|
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
|
||||||
@@ -241,7 +297,52 @@ bool measure_uses_threshold(MeasureType type);
|
|||||||
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
|
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
|
||||||
/// the waveform is the pulse).
|
/// the waveform is the pulse).
|
||||||
bool measure_uses_side(MeasureType type);
|
bool measure_uses_side(MeasureType type);
|
||||||
|
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves
|
||||||
|
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their
|
||||||
|
/// sums at the bare level and read the same answer at any band.
|
||||||
|
bool measure_uses_noise(MeasureType type);
|
||||||
/// Whether a type draws its result on the plot when asked.
|
/// Whether a type draws its result on the plot when asked.
|
||||||
bool measure_can_show_on_plot(MeasureType type);
|
bool measure_can_show_on_plot(MeasureType type);
|
||||||
|
|
||||||
|
/// Running statistics over a measurement's successive results.
|
||||||
|
///
|
||||||
|
/// One observation per *recompute* of the measurements pane, never one per sample: a
|
||||||
|
/// measurement is a figure derived from a whole window, and counting its samples would
|
||||||
|
/// describe the window rather than the measurement's stability. `n` is therefore a count of
|
||||||
|
/// passes, which is what makes the readout comparable with a bench scope's.
|
||||||
|
///
|
||||||
|
/// The mean and spread are Welford's recurrence rather than running sums of `v` and `v*v`:
|
||||||
|
/// the results are absolute signal values, so a channel sitting at a large offset with a
|
||||||
|
/// small ripple -- the case the readout is *for* -- cancels to nothing in a naive
|
||||||
|
/// sum-of-squares long before the ripple is visible.
|
||||||
|
struct MeasureStatsAccum {
|
||||||
|
std::uint64_t n = 0;
|
||||||
|
double mean = 0.0; ///< Welford's running mean.
|
||||||
|
double m2 = 0.0; ///< Welford's sum of squared deviations from the running mean.
|
||||||
|
double lo = 0.0;
|
||||||
|
double hi = 0.0;
|
||||||
|
|
||||||
|
void add(double v) noexcept {
|
||||||
|
if (n == 0) {
|
||||||
|
lo = v;
|
||||||
|
hi = v;
|
||||||
|
} else {
|
||||||
|
if (v < lo) lo = v;
|
||||||
|
if (v > hi) hi = v;
|
||||||
|
}
|
||||||
|
++n;
|
||||||
|
const double delta = v - mean;
|
||||||
|
mean += delta / static_cast<double>(n);
|
||||||
|
m2 += delta * (v - mean);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() noexcept { *this = MeasureStatsAccum{}; }
|
||||||
|
|
||||||
|
/// The sample standard deviation (n-1), and 0 rather than NaN for fewer than two
|
||||||
|
/// observations: the readout is a figure in a label, and a single pass has no spread.
|
||||||
|
double sd() const noexcept {
|
||||||
|
return n < 2 ? 0.0 : std::sqrt(m2 / static_cast<double>(n - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
+315
-29
@@ -43,7 +43,7 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||||
double target_span_s) {
|
double target_span_s, bool with_abs_time) {
|
||||||
Partition p;
|
Partition p;
|
||||||
const std::size_t n = channel_dt.size();
|
const std::size_t n = channel_dt.size();
|
||||||
p.stride.assign(n, 1);
|
p.stride.assign(n, 1);
|
||||||
@@ -69,12 +69,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
// affects every channel equally and goes through the ingest filter.
|
// affects every channel equally and goes through the ingest filter.
|
||||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||||
|
|
||||||
|
p.abs_time = with_abs_time;
|
||||||
|
|
||||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||||
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
||||||
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
||||||
// slices are not interchangeable. Counting both as one f32 is what would make
|
// slices are not interchangeable. Counting both as one f32 is what would make
|
||||||
// `--buffer-mb` quietly understate the time ring by its own size.
|
// `--buffer-mb` quietly understate the time ring by its own size.
|
||||||
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
|
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s)
|
||||||
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
||||||
|
|
||||||
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
||||||
@@ -99,7 +101,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||||
};
|
};
|
||||||
const auto bytes_for = [&](u64 ticks) {
|
const auto bytes_for = [&](u64 ticks) {
|
||||||
u64 total = ticks * sizeof(f64);
|
u64 total = ticks * sizeof(f64) * (with_abs_time ? 2u : 1u);
|
||||||
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
@@ -116,8 +118,11 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
// Only now, with the ring as long as the budget can buy, decimate the shared clock
|
// Only now, with the ring as long as the budget can buy, decimate the shared clock
|
||||||
// enough for the requested window to fit in it: deciding this earlier would over-
|
// enough for the requested window to fit in it: deciding this earlier would over-
|
||||||
// decimate by whatever factor the growth loop was about to add. A power of two keeps
|
// decimate by whatever factor the growth loop was about to add. A power of two keeps
|
||||||
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
|
// the ring capacities (themselves powers of two) in step and lets the producer's group
|
||||||
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
|
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
|
||||||
|
// this function chose at runtime is a real 64-bit division, which the test *was* paying
|
||||||
|
// once per channel per wire sample.
|
||||||
|
// The cap stops a nonsense request -- a window far past `--max-window` -- from
|
||||||
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
|
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
|
||||||
// picture is averages of averages anyway.
|
// picture is averages of averages anyway.
|
||||||
u64 m = 1;
|
u64 m = 1;
|
||||||
@@ -139,6 +144,11 @@ BufferStore::BufferStore(const Partition& part, f64 span_s)
|
|||||||
: part_(part),
|
: part_(part),
|
||||||
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
||||||
(void)span_s;
|
(void)span_s;
|
||||||
|
// Same capacity as the clock it parallels: the two are written on the same slots, so a
|
||||||
|
// smaller ring would make an export's stamps run out before its values do.
|
||||||
|
if (part.abs_time) {
|
||||||
|
abs_time_ = std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity);
|
||||||
|
}
|
||||||
const std::size_t n = part.value_capacity.size();
|
const std::size_t n = part.value_capacity.size();
|
||||||
values_.reserve(n);
|
values_.reserve(n);
|
||||||
channels_.reserve(n);
|
channels_.reserve(n);
|
||||||
@@ -153,6 +163,16 @@ BufferStore::BufferStore(const Partition& part, f64 span_s)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BufferStore::reset() noexcept {
|
||||||
|
time_->clear();
|
||||||
|
if (abs_time_) abs_time_->clear();
|
||||||
|
for (auto& v : values_) v->clear();
|
||||||
|
for (auto& c : channels_) {
|
||||||
|
c->invalidate();
|
||||||
|
c->set_epoch(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void BufferStore::acquire_all() {
|
void BufferStore::acquire_all() {
|
||||||
for (auto& c : channels_) c->acquire();
|
for (auto& c : channels_) c->acquire();
|
||||||
}
|
}
|
||||||
@@ -163,6 +183,35 @@ ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
|
|||||||
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
/// True when the incoming CONFIG names exactly the stream the model already has: same
|
||||||
|
/// signals, same shapes, rates, ranges and time modes. A reconnect re-sends the CONFIG,
|
||||||
|
/// and EMI makes that happen at the worst possible moment; the buffer, its history, the
|
||||||
|
/// holes, the trigger's held event and every measurement are all still valid and must
|
||||||
|
/// survive the reconnect untouched. Only a *different* stream is worth a re-plan, and a
|
||||||
|
/// re-plan is what drops history (see `rebuild_locked`).
|
||||||
|
bool same_stream_signals(const std::vector<SignalInfo>& incoming,
|
||||||
|
const std::vector<SignalInfo>& current) {
|
||||||
|
// `current` is the stream's signals with the math channels' synthetic entries appended,
|
||||||
|
// so compare the incoming list against current's prefix, skipping nothing on the left.
|
||||||
|
std::size_t n_stream = 0;
|
||||||
|
while (n_stream < current.size() && !current[n_stream].math) ++n_stream;
|
||||||
|
if (incoming.size() != n_stream) return false;
|
||||||
|
for (std::size_t i = 0; i < incoming.size(); ++i) {
|
||||||
|
const SignalInfo& a = incoming[i];
|
||||||
|
const SignalInfo& b = current[i];
|
||||||
|
if (a.name != b.name || a.unit != b.unit || a.type_code != b.type_code ||
|
||||||
|
a.quant_type != b.quant_type || a.rows != b.rows || a.cols != b.cols ||
|
||||||
|
a.range_min != b.range_min || a.range_max != b.range_max ||
|
||||||
|
a.time_mode != b.time_mode || a.sampling_rate != b.sampling_rate ||
|
||||||
|
a.time_signal_idx != b.time_signal_idx) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
// Keep the enable state across CONFIG updates for signals that kept their name. The math
|
// Keep the enable state across CONFIG updates for signals that kept their name. The math
|
||||||
@@ -177,6 +226,14 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
|||||||
signals.erase(std::remove_if(signals.begin(), signals.end(),
|
signals.erase(std::remove_if(signals.begin(), signals.end(),
|
||||||
[](const SignalInfo& s) { return s.math; }),
|
[](const SignalInfo& s) { return s.math; }),
|
||||||
signals.end());
|
signals.end());
|
||||||
|
if (!signals_.empty() && same_stream_signals(signals, signals_)) {
|
||||||
|
// A reconnect's identical CONFIG: the honest recovery is *nothing* -- the ring keeps
|
||||||
|
// its history, the trigger keeps its state and its held event, and the outage shows
|
||||||
|
// as the gap in the data that it was.
|
||||||
|
std::fprintf(stderr, "[udpscope] CONFIG unchanged: keeping the buffer and its history\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
signals_ = std::move(signals);
|
signals_ = std::move(signals);
|
||||||
enabled_.assign(signals_.size(), false);
|
enabled_.assign(signals_.size(), false);
|
||||||
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
||||||
@@ -213,6 +270,65 @@ void ScopeModel::append_math_signals_locked() {
|
|||||||
|
|
||||||
void ScopeModel::set_math_channels(std::vector<MathChannel> channels) {
|
void ScopeModel::set_math_channels(std::vector<MathChannel> channels) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
// An identical list is not a change. The rebuild below replaces the buffer and drops its history --
|
||||||
|
// which a file view then has to re-read from disk -- and `apply_math_channels()` runs this on every
|
||||||
|
// session start, where a session with no graphs hands over an empty list against a model that has
|
||||||
|
// none either. Comparing the graphs is what makes that call free.
|
||||||
|
if (channels == math_) return;
|
||||||
|
// An edit of an existing graph -- the same channels, names and enables -- does not move the *plan*, which
|
||||||
|
// is a partition over the channels. Nothing has to be rebuilt, and every other channel keeps its history:
|
||||||
|
// only the edited graphs, and the channels that read them, are recomputed.
|
||||||
|
bool same_set = channels.size() == math_.size();
|
||||||
|
for (std::size_t i = 0; same_set && i < channels.size(); ++i) {
|
||||||
|
same_set = channels[i].name == math_[i].name && channels[i].enabled == math_[i].enabled;
|
||||||
|
}
|
||||||
|
if (same_set) {
|
||||||
|
const auto math_index_of = [this](const std::string& name) -> int {
|
||||||
|
for (std::size_t k = 0; k < math_.size(); ++k) {
|
||||||
|
if (math_[k].name == name) return static_cast<int>(k);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
std::vector<bool> affected(math_.size(), false);
|
||||||
|
std::vector<bool> edited(math_.size(), false);
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
if (!(channels[i] == math_[i])) {
|
||||||
|
affected[i] = true;
|
||||||
|
edited[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A channel's output is another's *input*, so whatever names a changed channel is recomputed too -- and
|
||||||
|
// nothing else is touched at all.
|
||||||
|
for (bool moved = true; moved;) {
|
||||||
|
moved = false;
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
if (!affected[i]) continue;
|
||||||
|
for (const MathNode& node : channels[i].nodes) {
|
||||||
|
if (node.op != MathOp::Source) continue;
|
||||||
|
const int k = math_index_of(node.source);
|
||||||
|
if (k >= 0 && !affected[static_cast<std::size_t>(k)]) {
|
||||||
|
affected[static_cast<std::size_t>(k)] = true;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
math_ = std::move(channels);
|
||||||
|
const auto index_of = [this](const std::string& name) -> int {
|
||||||
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
|
if (channels_[c].name == name) return static_cast<int>(c);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
// Every affected channel is prepared again: a runtime carried over from the old graph is history for a
|
||||||
|
// filter nobody asked for, and one whose *inputs* changed is history for inputs it no longer has.
|
||||||
|
for (std::size_t i = 0; i < math_.size(); ++i) {
|
||||||
|
if (affected[i]) math_prepare(math_[i], math_rt_[i], part_.slot_dt(), index_of);
|
||||||
|
}
|
||||||
|
recompute_math_locked(affected);
|
||||||
|
generation_.fetch_add(1, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
math_ = std::move(channels);
|
math_ = std::move(channels);
|
||||||
// Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()`
|
// Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()`
|
||||||
// puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG.
|
// puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG.
|
||||||
@@ -250,7 +366,38 @@ void ScopeModel::prepare_math_locked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScopeModel::push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs_t) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (!store_) return;
|
||||||
|
BufferStore& store = *store_;
|
||||||
|
const std::size_t channels = channels_.size();
|
||||||
|
std::vector<f32> slot(channels, 0.0f);
|
||||||
|
for (std::size_t i = 0; i < channels; ++i) {
|
||||||
|
// A derived channel has no column of its own: `push_math_sample()` below fills it, and pushing the
|
||||||
|
// file's value here as well would put *two* samples in its ring per slot -- a trace at double rate.
|
||||||
|
// The same rule the packet path states in `ChannelInfo::math`: the ingest must skip it.
|
||||||
|
if (channels_[i].math) continue;
|
||||||
|
const f32 v = i < values.size() ? values[i] : 0.0f;
|
||||||
|
store.values(i).push(v);
|
||||||
|
slot[i] = v;
|
||||||
|
}
|
||||||
|
store.time().push(t);
|
||||||
|
if (auto* abs = store.abs_time()) abs->push(abs_t);
|
||||||
|
push_math_sample(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t ScopeModel::stored_slots() {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
return store_ ? store_->time().size() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||||
|
// The producer's entry: every derived channel, and timed (see the cost sampling below).
|
||||||
|
math_apply_locked(sample, nullptr, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::math_apply_locked(const std::vector<f32>& sample, const std::vector<bool>* write_mask,
|
||||||
|
bool timed) {
|
||||||
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
|
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
|
||||||
// early-out is what keeps a stream with no math from paying for the feature.
|
// early-out is what keeps a stream with no math from paying for the feature.
|
||||||
if (math_.empty()) return;
|
if (math_.empty()) return;
|
||||||
@@ -259,9 +406,14 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
|||||||
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
|
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
|
||||||
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
|
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
|
||||||
// overall, and the figure converges over the millions this counter sees.
|
// overall, and the figure converges over the millions this counter sees.
|
||||||
const std::uint64_t slot = math_slot_count_++;
|
// The sampling counts *producer* slots: a recompute must not inflate `math_slots_`, which the dump
|
||||||
|
// reports as the run's own cadence.
|
||||||
|
std::uint64_t slot = 0;
|
||||||
|
if (timed) {
|
||||||
|
slot = math_slot_count_++;
|
||||||
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
|
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
|
||||||
const bool timed = slot % kMathCostSample == 0;
|
timed = slot % kMathCostSample == 0;
|
||||||
|
}
|
||||||
if (timed) math_cost_t0_ = now_ns();
|
if (timed) math_cost_t0_ = now_ns();
|
||||||
// Values for this slot, extended as the derived ones are computed: a graph may name another math
|
// Values for this slot, extended as the derived ones are computed: a graph may name another math
|
||||||
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
|
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
|
||||||
@@ -274,7 +426,11 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
|||||||
if (channel < 0 || m >= math_rt_.size()) continue;
|
if (channel < 0 || m >= math_rt_.size()) continue;
|
||||||
const double out =
|
const double out =
|
||||||
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
|
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
|
||||||
|
// A recompute computes every channel -- the graphs are a list walked in order, so a channel's inputs
|
||||||
|
// have to be there -- but only the affected ones take the sample.
|
||||||
|
if (write_mask == nullptr || (*write_mask)[m]) {
|
||||||
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
|
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
|
||||||
|
}
|
||||||
if (static_cast<std::size_t>(channel) < math_values_.size()) {
|
if (static_cast<std::size_t>(channel) < math_values_.size()) {
|
||||||
math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
|
math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
|
||||||
}
|
}
|
||||||
@@ -287,6 +443,48 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
|
||||||
|
if (!store_ || math_.empty()) return;
|
||||||
|
BufferStore& store = *store_;
|
||||||
|
const std::size_t slots = static_cast<std::size_t>(store.time().size());
|
||||||
|
if (slots == 0) return;
|
||||||
|
|
||||||
|
std::vector<bool> derived(channels_.size(), false);
|
||||||
|
for (std::size_t m = 0; m < math_.size(); ++m) {
|
||||||
|
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
|
||||||
|
if (channel < 0) continue;
|
||||||
|
derived[static_cast<std::size_t>(channel)] = true;
|
||||||
|
// Only the affected channels start again. The rest keep their samples *and* their runtimes: their graphs
|
||||||
|
// are untouched, so the state they hold is the answer to the inputs they have always had.
|
||||||
|
if (affected[m]) store.values(static_cast<std::size_t>(channel)).clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One bulk copy per channel per chunk, through the ring's own reader. A derived channel is fed a zero:
|
||||||
|
// `math_apply_locked()` overwrites its value as it walks the list in order, and reading its half-rebuilt
|
||||||
|
// ring here would read out of range.
|
||||||
|
constexpr std::size_t kChunk = 1u << 14;
|
||||||
|
const std::size_t base = static_cast<std::size_t>(store.time().base());
|
||||||
|
std::vector<std::vector<f32>> inputs(channels_.size());
|
||||||
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
|
if (!derived[c]) inputs[c].resize(kChunk);
|
||||||
|
}
|
||||||
|
std::vector<f32> slot(channels_.size(), 0.0f);
|
||||||
|
for (std::size_t start = 0; start < slots; start += kChunk) {
|
||||||
|
const std::size_t n = std::min(kChunk, slots - start);
|
||||||
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
|
if (derived[c]) continue;
|
||||||
|
store.values(c).copy_from(base + start, n, inputs[c].data());
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
|
slot[c] = derived[c] ? 0.0f : inputs[c][i];
|
||||||
|
}
|
||||||
|
math_apply_locked(slot, &affected, /*timed=*/false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The pyramid is the consumer's and the tick publishes it; the samples are written here.
|
||||||
|
}
|
||||||
|
|
||||||
void ScopeModel::rebuild_channels() {
|
void ScopeModel::rebuild_channels() {
|
||||||
channels_.clear();
|
channels_.clear();
|
||||||
// Which channel each math channel ended up with, by name: a disabled one has none, and the
|
// Which channel each math channel ended up with, by name: a disabled one has none, and the
|
||||||
@@ -329,6 +527,15 @@ void ScopeModel::rebuild_channels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::rebuild_locked() {
|
void ScopeModel::rebuild_locked() {
|
||||||
|
// The store is replaced below, so every hole's `slot_begin`/`tick_begin`/`x_begin` describes a
|
||||||
|
// buffer that no longer exists: the new store's base is 0, so `forget_holes_before()` cannot
|
||||||
|
// retire them and they would shade a band over the wrong x until the table pushed them out.
|
||||||
|
// Dropping them is the honest answer -- the samples they reported absent are gone with the
|
||||||
|
// history. `++plan_generation_` is what tells the producer the same thing about `tick_`, whose
|
||||||
|
// relation to `produced()` is what addresses a backfill (see `Acquisition::on_data`).
|
||||||
|
holes_.clear();
|
||||||
|
++plan_generation_;
|
||||||
|
|
||||||
std::vector<double> dts;
|
std::vector<double> dts;
|
||||||
dts.reserve(channels_.size());
|
dts.reserve(channels_.size());
|
||||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||||
@@ -339,9 +546,32 @@ void ScopeModel::rebuild_locked() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
|
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
|
||||||
target_span_.load(std::memory_order_relaxed));
|
target_span_.load(std::memory_order_relaxed),
|
||||||
|
cfg_.relative_time);
|
||||||
|
// A plan that describes the storage already allocated is cleared, not rebuilt. Allocating
|
||||||
|
// a store is what costs -- its rings are value-initialised, so it touches the whole
|
||||||
|
// `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration
|
||||||
|
// keeps the channel set, and so does leaving an event for the live view. Startup asked for
|
||||||
|
// sixteen of these and eight of them planned the same buffer twice. Clearing has the same
|
||||||
|
// meaning (`BufferStore::reset()`): the history is gone either way, which is why
|
||||||
|
// `plan_generation_` bumps above regardless.
|
||||||
|
if (store_ && part_.same_storage_as(want)) {
|
||||||
|
part_ = want;
|
||||||
|
store_->reset();
|
||||||
|
} else {
|
||||||
|
part_ = want;
|
||||||
|
const std::uint64_t t0 = now_ns();
|
||||||
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
|
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
|
||||||
|
// Named, with what it cost: this is the one operation in the application whose duration a
|
||||||
|
// user can see with no instrument, because it blocks the main thread for the time it takes
|
||||||
|
// to touch the whole buffer -- about 300 ms per GB. A startup or a mode change that reads
|
||||||
|
// more than one of these lines is a re-plan that should have been coalesced, and that is
|
||||||
|
// the defect the line exists to name (it was fifteen of them at startup).
|
||||||
|
std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n",
|
||||||
|
channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
|
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
|
||||||
prepare_math_locked();
|
prepare_math_locked();
|
||||||
@@ -358,7 +588,7 @@ bool ScopeModel::set_target_span(double seconds) {
|
|||||||
std::vector<double> dts;
|
std::vector<double> dts;
|
||||||
dts.reserve(channels_.size());
|
dts.reserve(channels_.size());
|
||||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds);
|
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time);
|
||||||
if (want.time_stride == part_.time_stride) return false;
|
if (want.time_stride == part_.time_stride) return false;
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
|
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
|
||||||
@@ -393,9 +623,8 @@ bool ScopeModel::signal_enabled(std::size_t signal) const {
|
|||||||
return signal < enabled_.size() && enabled_[signal];
|
return signal < enabled_.size() && enabled_[signal];
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
bool ScopeModel::enable_locked(std::size_t signal, bool on) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
if (signal >= enabled_.size() || enabled_[signal] == on) return false;
|
||||||
if (signal >= enabled_.size() || enabled_[signal] == on) return;
|
|
||||||
enabled_[signal] = on;
|
enabled_[signal] = on;
|
||||||
// A math channel's synthetic signal is enabled *here* -- it is the panel's switch -- while
|
// A math channel's synthetic signal is enabled *here* -- it is the panel's switch -- while
|
||||||
// `append_math_signals_locked()` re-states `enabled_` from `math_` on the next edit. The two have
|
// `append_math_signals_locked()` re-states `enabled_` from `math_` on the next edit. The two have
|
||||||
@@ -406,9 +635,14 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
|||||||
if (m.name == signals_[signal].name) m.enabled = on;
|
if (m.name == signals_[signal].name) m.enabled = on;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::rebuild_enabled_locked() {
|
||||||
// The trigger names a channel by its display name, which is stable across
|
// The trigger names a channel by its display name, which is stable across
|
||||||
// renumbering: when its own signal is disabled the channel vanishes (the trigger
|
// renumbering: when its own signal is disabled the channel vanishes (the trigger
|
||||||
// then waits, and comes back when the signal is re-enabled).
|
// then waits, and comes back when the signal is re-enabled). Read before
|
||||||
|
// `rebuild_channels()`, which is what renumbers.
|
||||||
const int old_trigger = trigger_.channel;
|
const int old_trigger = trigger_.channel;
|
||||||
const std::string trigger_name =
|
const std::string trigger_name =
|
||||||
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
|
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
|
||||||
@@ -425,6 +659,31 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
|||||||
generation_.fetch_add(1, std::memory_order_release);
|
generation_.fetch_add(1, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (!enable_locked(signal, on)) return;
|
||||||
|
rebuild_enabled_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_signals_enabled(const std::vector<std::size_t>& signals, bool on) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
bool any = false;
|
||||||
|
for (const std::size_t s : signals) {
|
||||||
|
if (enable_locked(s, on)) any = true;
|
||||||
|
}
|
||||||
|
// Nothing moved: no re-plan, and in particular no `generation_` bump -- a restored session
|
||||||
|
// re-enables what is already on, and a bump there would make the UI rebind for nothing.
|
||||||
|
if (!any) return;
|
||||||
|
rebuild_enabled_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::pair<double, double> ScopeModel::calibration(std::size_t signal) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (signal >= signals_.size()) return {1.0, 0.0};
|
||||||
|
const auto it = calibration_.find(signals_[signal].name);
|
||||||
|
return it != calibration_.end() ? it->second : std::pair<double, double>{1.0, 0.0};
|
||||||
|
}
|
||||||
|
|
||||||
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
|
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (signal < signals_.size()) calibration_[signals_[signal].name] = {gain, offset};
|
if (signal < signals_.size()) calibration_[signals_[signal].name] = {gain, offset};
|
||||||
@@ -450,6 +709,13 @@ double ScopeModel::channel_dt(std::size_t channel) const {
|
|||||||
BufferStore& ScopeModel::live() { return *store_; }
|
BufferStore& ScopeModel::live() { return *store_; }
|
||||||
|
|
||||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||||
|
// There may be **no store at all**: `rebuild_locked()` resets it for a stream with no channels, which
|
||||||
|
// is what the model is in until a CONFIG arrives. `live()` dereferences the pointer, so every path
|
||||||
|
// that can be reached without a stream has to ask first -- pausing a scope that is not connected did
|
||||||
|
// not, and walked into this function through `publish_pause_view()`, a null dereference.
|
||||||
|
first = 0;
|
||||||
|
last = 0;
|
||||||
|
if (store_ == nullptr) return;
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||||
|
|
||||||
@@ -501,21 +767,11 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
|||||||
if (last < first) last = first;
|
if (last < first) last = first;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::reset_live_history() {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
reset_live_history_locked();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScopeModel::reset_live_history_locked() {
|
|
||||||
BufferStore& src = live();
|
|
||||||
src.time().clear();
|
|
||||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
|
||||||
src.values(c).clear();
|
|
||||||
src.channel(c).invalidate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||||
|
// Nothing has arrived yet, so there is nothing to freeze: pausing a scope that is not connected is a
|
||||||
|
// legitimate gesture (the button works), and it must not take a window from a store that does not
|
||||||
|
// exist.
|
||||||
|
if (store_ == nullptr) return;
|
||||||
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
|
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
|
||||||
// doing it, and this one is the UI's.
|
// doing it, and this one is the UI's.
|
||||||
const std::uint64_t t0 = now_ns();
|
const std::uint64_t t0 = now_ns();
|
||||||
@@ -538,6 +794,12 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
|||||||
src.time().copy_from(first, n, snap->stamps.data());
|
src.time().copy_from(first, n, snap->stamps.data());
|
||||||
for (f64& t : snap->stamps) t -= x_offset;
|
for (f64& t : snap->stamps) t -= x_offset;
|
||||||
}
|
}
|
||||||
|
if (const plot::data::RingBuffer<f64>* abs = src.abs_time(); abs != nullptr) {
|
||||||
|
snap->abs_stamps.resize(static_cast<std::size_t>(n));
|
||||||
|
abs->copy_from(first, n, snap->abs_stamps.data());
|
||||||
|
// Absolute seconds outright -- deliberately not rebased like `stamps`.
|
||||||
|
for (f64& t : snap->abs_stamps) t += epoch_;
|
||||||
|
}
|
||||||
snap->values.resize(channels_.size());
|
snap->values.resize(channels_.size());
|
||||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||||
snap->pyramids.resize(channels_.size());
|
snap->pyramids.resize(channels_.size());
|
||||||
@@ -602,6 +864,12 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
|||||||
// disagree with.
|
// disagree with.
|
||||||
for (auto& t : snap->stamps) t -= edge;
|
for (auto& t : snap->stamps) t -= edge;
|
||||||
}
|
}
|
||||||
|
if (const plot::data::RingBuffer<f64>* abs = src.abs_time(); abs != nullptr) {
|
||||||
|
snap->abs_stamps.resize(static_cast<std::size_t>(n));
|
||||||
|
abs->copy_from(first, n, snap->abs_stamps.data());
|
||||||
|
// Absolute seconds outright -- deliberately not rebased like `stamps`.
|
||||||
|
for (f64& t : snap->abs_stamps) t += epoch_;
|
||||||
|
}
|
||||||
|
|
||||||
snap->values.resize(channels_.size());
|
snap->values.resize(channels_.size());
|
||||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||||
@@ -649,7 +917,25 @@ void ScopeModel::set_display_epoch(f64 e) noexcept {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::acquire_all() {
|
void ScopeModel::acquire_all() {
|
||||||
if (store_) store_->acquire_all();
|
if (store_) {
|
||||||
|
store_->acquire_all();
|
||||||
|
// Drop holes whose slots the time ring has already overwritten: their slot addresses
|
||||||
|
// would alias live slots once the ring wraps past them, and they can no longer be
|
||||||
|
// backfilled or drawn. `base()` is the absolute index of the oldest slot the ring
|
||||||
|
// still retains -- anything earlier has already been discarded.
|
||||||
|
forget_holes_before(store_->time().base());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::note_hole(const Hole& h) {
|
||||||
|
if (holes_.size() >= kMaxHoles) holes_.erase(holes_.begin());
|
||||||
|
holes_.push_back(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::forget_holes_before(u64 slot) {
|
||||||
|
// A hole whose last slot the ring has dropped can no longer be backfilled or drawn, and
|
||||||
|
// its address would alias a live slot once the ring wraps past it.
|
||||||
|
std::erase_if(holes_, [slot](const Hole& h) { return h.slot_begin + h.slot_count <= slot; });
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::set_trigger_enabled(bool on) {
|
void ScopeModel::set_trigger_enabled(bool on) {
|
||||||
|
|||||||
+180
-15
@@ -93,6 +93,11 @@ struct Partition {
|
|||||||
double base_dt = 1.0; ///< seconds per base tick
|
double base_dt = 1.0; ///< seconds per base tick
|
||||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||||
|
|
||||||
|
/// True when a second f64 ring parallel to the clock was planned and paid for in the
|
||||||
|
/// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute
|
||||||
|
/// clock and a copy would cost ~22% of the user's `--buffer-mb` for nothing.
|
||||||
|
bool abs_time = false;
|
||||||
|
|
||||||
/// Seconds between two time-base slots (the sample interval of every channel).
|
/// Seconds between two time-base slots (the sample interval of every channel).
|
||||||
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||||
/// The rate samples are actually stored at, in samples per second.
|
/// The rate samples are actually stored at, in samples per second.
|
||||||
@@ -104,14 +109,32 @@ struct Partition {
|
|||||||
/// Nyquist. The boxcar the ingest path averages with has its first null at the stored
|
/// Nyquist. The boxcar the ingest path averages with has its first null at the stored
|
||||||
/// rate itself, so everything that would fold back is attenuated.
|
/// rate itself, so everything that would fold back is attenuated.
|
||||||
double antialias_hz() const noexcept { return 0.5 * stored_rate(); }
|
double antialias_hz() const noexcept { return 0.5 * stored_rate(); }
|
||||||
|
|
||||||
|
/// Two plans that describe the **same storage**: the same rings, of the same lengths, at
|
||||||
|
/// the same decimation, over the same clock. This is the test `rebuild_locked()` uses to
|
||||||
|
/// decide whether it can clear the buffer it has instead of allocating another one.
|
||||||
|
///
|
||||||
|
/// `base_dt` is compared although it buys no byte, because a reused store keeps the
|
||||||
|
/// `Partition` *it* was built with and hands `slot_dt()` to every `ChannelBuffer` as the
|
||||||
|
/// nominal grid `dx()` reports -- and `dx()` is what picks the LOD level. Two channel sets
|
||||||
|
/// can want the same capacities off a different base tick, and reusing across that would
|
||||||
|
/// leave every channel describing a grid the samples are not on. It is an exact compare of
|
||||||
|
/// a value that is copied, never recomputed, from the fastest channel's `dt`.
|
||||||
|
/// `span_s` needs no compare of its own: it is the product of the three fields above.
|
||||||
|
bool same_storage_as(const Partition& o) const noexcept {
|
||||||
|
return time_capacity == o.time_capacity && time_stride == o.time_stride &&
|
||||||
|
abs_time == o.abs_time && base_dt == o.base_dt && stride == o.stride &&
|
||||||
|
value_capacity == o.value_capacity;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
|
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
|
||||||
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
|
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
|
||||||
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
|
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
|
||||||
/// one sample per channel.
|
/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget
|
||||||
|
/// and sets `Partition::abs_time`.
|
||||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||||
double target_span_s);
|
double target_span_s, bool with_abs_time = false);
|
||||||
|
|
||||||
/// One of the two identical sample buffers: a shared time-base ring plus one
|
/// One of the two identical sample buffers: a shared time-base ring plus one
|
||||||
/// value ring and one `ChannelBuffer` per channel.
|
/// value ring and one `ChannelBuffer` per channel.
|
||||||
@@ -132,6 +155,15 @@ class BufferStore {
|
|||||||
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
|
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
|
||||||
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
|
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
|
||||||
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
|
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
|
||||||
|
/// The stream's **absolute** clock, parallel to `time()` and on the same slots, or
|
||||||
|
/// `nullptr` when the store was planned without it.
|
||||||
|
///
|
||||||
|
/// There is exactly one time ring per store, shared by every channel, and relative mode
|
||||||
|
/// fills it with counter-derived stamps -- so without this ring, selecting relative mode
|
||||||
|
/// would silently drop absolute time from an export, the one output `todo.md` item 2 says
|
||||||
|
/// must keep it. Allocated in relative mode only; read only by the export path.
|
||||||
|
plot::data::RingBuffer<f64>* abs_time() noexcept { return abs_time_.get(); }
|
||||||
|
const plot::data::RingBuffer<f64>* abs_time() const noexcept { return abs_time_.get(); }
|
||||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||||
@@ -141,16 +173,72 @@ class BufferStore {
|
|||||||
}
|
}
|
||||||
const Partition& partition() const noexcept { return part_; }
|
const Partition& partition() const noexcept { return part_; }
|
||||||
|
|
||||||
|
/// Drops every sample and leaves a store indistinguishable from a freshly built one with
|
||||||
|
/// the same `Partition` -- the state `rebuild_locked()` wants when the plan did not change.
|
||||||
|
///
|
||||||
|
/// Why it exists: constructing a store *is* its cost. A ring is a
|
||||||
|
/// `vector<std::atomic<T>>`, which is value-initialised, so building one touches every
|
||||||
|
/// byte of the user's `--buffer-mb` -- about 300 ms per GB, nearly all of it first-touch
|
||||||
|
/// page faults. Clearing is a count store per ring. Most structural changes (a
|
||||||
|
/// calibration, a re-enable of what was just disabled, going back to live) re-plan to the
|
||||||
|
/// *same* partition, and paying a second full allocation for them is what made a large
|
||||||
|
/// buffer take ten seconds to start and an event take seconds to leave.
|
||||||
|
///
|
||||||
|
/// It must leave **nothing** of the old history behind, in either copy: the rings are
|
||||||
|
/// emptied and every `ChannelBuffer` is `invalidate()`d, which drops the published window
|
||||||
|
/// and the pyramid built over it. The epoch goes back to 0 for the same reason -- a fresh
|
||||||
|
/// store has none, and a reused one keeping the old event's epoch would re-base the next
|
||||||
|
/// axis against a trigger that no longer exists.
|
||||||
|
///
|
||||||
|
/// Caller's thread: the consumer, under `ScopeModel::mutex()` -- the same lock the
|
||||||
|
/// producer's pushes hold. `RingBuffer::clear()` is not safe against a concurrent push.
|
||||||
|
void reset() noexcept;
|
||||||
|
|
||||||
/// Consumer: publishes every channel's new samples.
|
/// Consumer: publishes every channel's new samples.
|
||||||
void acquire_all();
|
void acquire_all();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Partition part_;
|
Partition part_;
|
||||||
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
|
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
|
||||||
|
std::shared_ptr<plot::data::RingBuffer<f64>> abs_time_;
|
||||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// One reserved, invalidated span: samples the counter says exist and the stream has not
|
||||||
|
/// delivered. Its stored slots hold NaN, so a reader needs no special case -- the table is
|
||||||
|
/// what lets a *late* packet find the slots it belongs to, and what the band draws.
|
||||||
|
struct Hole {
|
||||||
|
f64 x_begin = 0.0; ///< stream seconds of the hole's first slot
|
||||||
|
f64 x_end = 0.0; ///< stream seconds of the hole's last slot
|
||||||
|
/// Absolute **time-ring** slot of the first reserved slot, and the count of them. The
|
||||||
|
/// time ring is the one clock every channel shares, so this is the only slot address
|
||||||
|
/// that is unambiguous: per-channel value rings run at `time_stride * stride[i]`, which
|
||||||
|
/// differs between channels. A consumer that needs a *channel's* slots derives them from
|
||||||
|
/// `tick_begin` and that channel's stride rather than reusing these.
|
||||||
|
u64 slot_begin = 0;
|
||||||
|
u64 slot_count = 0;
|
||||||
|
u64 tick_begin = 0; ///< absolute base tick of the hole's first raw sample
|
||||||
|
std::uint32_t counter_first = 0; ///< counter of the first missing packet
|
||||||
|
std::uint32_t packets = 0; ///< packets the hole is worth
|
||||||
|
/// How many of those packets have since arrived late and been written in. The band's two tints
|
||||||
|
/// are this field against `packets`: a hole still missing data is shaded solidly and a fully
|
||||||
|
/// repaired one faintly, so a reader can tell "there is no data here" from "there was none when
|
||||||
|
/// this window was first drawn".
|
||||||
|
///
|
||||||
|
/// Counted in *packets* and not in slots, because a packet is the unit the reservation was made
|
||||||
|
/// in: the hole is worth exactly `packets` of them, each late arrival delivers exactly one, and
|
||||||
|
/// the client refuses a duplicate -- so this count is exact. A slot count would not be: the
|
||||||
|
/// channels have different strides, so "how many slots did this hole want" has one answer per
|
||||||
|
/// channel and the repaired test would have to pick one.
|
||||||
|
std::uint32_t filled_packets = 0;
|
||||||
|
|
||||||
|
/// Every packet this hole was reserved for has arrived. A hole worth no packets is never
|
||||||
|
/// repaired -- there is nothing for a late packet to deliver -- which keeps a degenerate
|
||||||
|
/// reservation shaded rather than silently downgraded.
|
||||||
|
bool repaired() const noexcept { return packets > 0 && filled_packets >= packets; }
|
||||||
|
};
|
||||||
|
|
||||||
/// Live connection and counter state, readable from the UI thread.
|
/// Live connection and counter state, readable from the UI thread.
|
||||||
struct StreamStats {
|
struct StreamStats {
|
||||||
std::atomic<std::uint64_t> packets{0};
|
std::atomic<std::uint64_t> packets{0};
|
||||||
@@ -159,6 +247,27 @@ struct StreamStats {
|
|||||||
std::atomic<std::uint64_t> reconnects{0};
|
std::atomic<std::uint64_t> reconnects{0};
|
||||||
std::atomic<bool> connected{false};
|
std::atomic<bool> connected{false};
|
||||||
std::atomic<bool> have_config{false};
|
std::atomic<bool> have_config{false};
|
||||||
|
std::atomic<std::uint64_t> holes{0}; ///< reserved invalidated spans
|
||||||
|
std::atomic<std::uint64_t> hole_slots{0}; ///< stored slots those spans cover
|
||||||
|
/// Out-of-order packets put back in order inside the reorder window: no hole was ever
|
||||||
|
/// seen for these.
|
||||||
|
std::atomic<std::uint64_t> resequenced{0};
|
||||||
|
/// Out-of-order packets delivered behind the sequence, past the window.
|
||||||
|
std::atomic<std::uint64_t> late{0};
|
||||||
|
/// Times the client's reorder ring was full when a packet needed a slot, so the gap was
|
||||||
|
/// committed early. Nothing is dropped; the window was just shorter than asked for, and a
|
||||||
|
/// non-zero reading is what says the ring is undersized for the stream.
|
||||||
|
std::atomic<std::uint64_t> overflows{0};
|
||||||
|
/// Packets the client still refused outright (a duplicate, or any late packet while
|
||||||
|
/// resequencing is disabled). Counted since the beginning and displayed nowhere until
|
||||||
|
/// now, which is why an inversion had to be inferred from `packets` and `frames`
|
||||||
|
/// diverging.
|
||||||
|
std::atomic<std::uint64_t> stale{0};
|
||||||
|
/// Stored slots a late packet repaired. Declared here rather than with the backfill that
|
||||||
|
/// writes it, because the display segment below is built once and reads it from the start:
|
||||||
|
/// a reading of 0 beside a non-zero `holes` is exactly the honest answer until the
|
||||||
|
/// backfill lands.
|
||||||
|
std::atomic<std::uint64_t> backfilled{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What the plot is showing, which is one decision made once per frame: the bind, the
|
/// What the plot is showing, which is one decision made once per frame: the bind, the
|
||||||
@@ -209,6 +318,26 @@ class ScopeModel {
|
|||||||
/// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are
|
/// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are
|
||||||
/// evaluated from those, in order, so a math channel may use another one.
|
/// evaluated from those, in order, so a math channel may use another one.
|
||||||
void push_math_sample(const std::vector<f32>& sample);
|
void push_math_sample(const std::vector<f32>& sample);
|
||||||
|
|
||||||
|
/// Applies the graphs to one stored slot. `write_mask`, when given, names the derived channels whose
|
||||||
|
/// rings may be written -- `nullptr` writes all of them, which is what the producer does; a recompute
|
||||||
|
/// passes the channels a graph change actually affects, so the others keep the samples they have.
|
||||||
|
void math_apply_locked(const std::vector<f32>& sample, const std::vector<bool>* write_mask, bool timed);
|
||||||
|
|
||||||
|
/// Recomputes the derived channels in `affected` from the samples the store already holds: their rings are
|
||||||
|
/// rebuilt from scratch, and the channels a chart change does *not* touch are left exactly as they were.
|
||||||
|
void recompute_math_locked(const std::vector<bool>& affected);
|
||||||
|
|
||||||
|
/// How many slots the store currently holds. A file view reads it to tell a structural change that
|
||||||
|
/// dropped the buffer (0) from one that could not have (see `reload_file_view()` in ui.cpp).
|
||||||
|
std::uint64_t stored_slots();
|
||||||
|
|
||||||
|
/// Pushes one stored slot of a **file** load -- the values, the shared clock and the absolute
|
||||||
|
/// clock -- and then the derived channels, in the producer's own order and under the model's own
|
||||||
|
/// lock. The loader cannot simply hold the lock and push into `live()` itself: every setter it
|
||||||
|
/// needs (`set_signals`, `set_signal_enabled`, ...) takes that same non-recursive lock, so the
|
||||||
|
/// outer one deadlocks the first call.
|
||||||
|
void push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs_t);
|
||||||
/// What the derived channels cost the *producer*: every stored slot that went through
|
/// What the derived channels cost the *producer*: every stored slot that went through
|
||||||
/// `push_math_sample()`, and the nanoseconds taken by the `math_timed()` of them that were
|
/// `push_math_sample()`, and the nanoseconds taken by the `math_timed()` of them that were
|
||||||
/// measured. The producer owns the budget between two DATA packets, so this cost is invisible in
|
/// measured. The producer owns the budget between two DATA packets, so this cost is invisible in
|
||||||
@@ -265,6 +394,17 @@ class ScopeModel {
|
|||||||
static bool is_time_signal(const std::vector<SignalInfo>& signals,
|
static bool is_time_signal(const std::vector<SignalInfo>& signals,
|
||||||
std::size_t signal);
|
std::size_t signal);
|
||||||
void set_signal_enabled(std::size_t signal, bool on);
|
void set_signal_enabled(std::size_t signal, bool on);
|
||||||
|
/// The same switch thrown for several signals as **one** structural change.
|
||||||
|
///
|
||||||
|
/// Enabling a signal changes the channel count, so it re-plans the partition and the new
|
||||||
|
/// plan really is a different buffer -- there is no reusing the old store for it (see
|
||||||
|
/// `BufferStore::reset()`). A caller that enables n signals one at a time therefore pays n
|
||||||
|
/// full allocations to reach a state one allocation describes, and every one of them drops
|
||||||
|
/// the history the one before it started collecting. That is what a restored session, a
|
||||||
|
/// loaded waveform and an event group all do, and at a 1 GB buffer it is seconds of a
|
||||||
|
/// blank window: the plots are bound to a store that is replaced again before it holds a
|
||||||
|
/// screenful. Indices the model has no signal for are ignored.
|
||||||
|
void set_signals_enabled(const std::vector<std::size_t>& signals, bool on);
|
||||||
/// Stores the calibration for a signal's *name* and applies it to that signal's
|
/// Stores the calibration for a signal's *name* and applies it to that signal's
|
||||||
/// live channels. Keyed by name, not index: a CONFIG renumbers and an
|
/// live channels. Keyed by name, not index: a CONFIG renumbers and an
|
||||||
/// enable/disable rebuilds the channel list, and `rebuild_channels()` re-applies
|
/// enable/disable rebuilds the channel list, and `rebuild_channels()` re-applies
|
||||||
@@ -272,6 +412,12 @@ class ScopeModel {
|
|||||||
/// CONFIG, is not silently dropped.
|
/// CONFIG, is not silently dropped.
|
||||||
void set_calibration(std::size_t signal, double gain, double offset);
|
void set_calibration(std::size_t signal, double gain, double offset);
|
||||||
|
|
||||||
|
/// The calibration that *will* be applied to that signal, as `{gain, offset}` -- the identity
|
||||||
|
/// `{1, 0}` for a signal nobody calibrated. The read counterpart of `set_calibration()`, and read
|
||||||
|
/// from the model's own record rather than from the channels: a signal that is not acquired has
|
||||||
|
/// no channel to read it from, and the record is what a dialog must show.
|
||||||
|
std::pair<double, double> calibration(std::size_t signal);
|
||||||
|
|
||||||
std::size_t channel_count() const noexcept { return channels_.size(); }
|
std::size_t channel_count() const noexcept { return channels_.size(); }
|
||||||
const Partition& partition() const noexcept { return part_; }
|
const Partition& partition() const noexcept { return part_; }
|
||||||
|
|
||||||
@@ -285,19 +431,18 @@ class ScopeModel {
|
|||||||
/// is armed.
|
/// is armed.
|
||||||
BufferStore& live();
|
BufferStore& live();
|
||||||
|
|
||||||
/// Forgets the live buffer's history: every ring is emptied and every channel's
|
/// Records a reserved hole. Caller holds `mutex()` (the producer already does). The table
|
||||||
/// published window is dropped, so the two stay index-aligned. Used when the
|
/// is capped at `kMaxHoles`; past that the oldest is dropped, because an unbounded list of
|
||||||
/// acquisition has to change time source (see `Acquisition::build_stamps`): the
|
/// gaps is a leak and the oldest gap is the one furthest from being backfilled.
|
||||||
/// stamps already recorded are on the old timeline, and a ring holding two
|
static constexpr std::size_t kMaxHoles = 64;
|
||||||
/// timelines is not a time base at all -- the x axis, the crosshair and an export
|
void note_hole(const Hole& h);
|
||||||
/// all read it by index.
|
const std::vector<Hole>& holes() const noexcept { return holes_; }
|
||||||
///
|
/// The producer's view of the same table. `Acquisition::backfill_from()` marks a hole's packet
|
||||||
/// Takes `mutex()` itself: call it from the GTK thread. The acquisition thread
|
/// filled as it repairs it, and that mark is what the band's fainter tint reads. Caller holds
|
||||||
/// calls `reset_live_history_locked()` instead, already holding the lock (the
|
/// `mutex()` -- the same requirement `note_hole()` above states, and the producer already does.
|
||||||
/// same split `publish_event()` follows).
|
std::vector<Hole>& holes() noexcept { return holes_; }
|
||||||
void reset_live_history();
|
/// Drops holes whose time-ring slots the ring has already discarded.
|
||||||
/// The body of the above for a caller that already holds `mutex()`.
|
void forget_holes_before(u64 slot);
|
||||||
void reset_live_history_locked();
|
|
||||||
|
|
||||||
/// Builds the captured event from the slice of the live buffer covering
|
/// Builds the captured event from the slice of the live buffer covering
|
||||||
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
|
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
|
||||||
@@ -481,8 +626,25 @@ class ScopeModel {
|
|||||||
/// Rebuilds buffers from the current channel list. Caller holds `mutex()`.
|
/// Rebuilds buffers from the current channel list. Caller holds `mutex()`.
|
||||||
void rebuild_locked();
|
void rebuild_locked();
|
||||||
|
|
||||||
|
/// Counts buffer re-plans: every `rebuild_locked()` bumps it. The producer caches the value
|
||||||
|
/// it last saw and resets the state tied to the old store when it moves (see
|
||||||
|
/// `Acquisition::on_data`). Not an atomic on purpose: `rebuild_locked()` only runs under
|
||||||
|
/// `mutex()` and the producer holds that lock for all of `on_data`, so a plain read under the
|
||||||
|
/// lock is correct, and a reconnect with an identical CONFIG is a re-plan this sees and the
|
||||||
|
/// stride/channel-count comparison does not.
|
||||||
|
unsigned plan_generation() const noexcept { return plan_generation_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void rebuild_channels();
|
void rebuild_channels();
|
||||||
|
/// One signal's enable bookkeeping -- `enabled_` and the math channel's own flag -- with no
|
||||||
|
/// re-plan. Returns whether the state actually moved. The two halves of
|
||||||
|
/// `set_signal_enabled()` are split so that a batch can do n of these and one of the
|
||||||
|
/// rebuild below.
|
||||||
|
bool enable_locked(std::size_t signal, bool on);
|
||||||
|
/// The re-plan an enable needs: carry the trigger's channel across the renumbering,
|
||||||
|
/// rebuild the channels and the buffers, and publish the new generation. Caller holds
|
||||||
|
/// `mutex()` and has already moved `enabled_`.
|
||||||
|
void rebuild_enabled_locked();
|
||||||
/// Re-appends one synthetic signal per math channel, after the stream's own. Called under the
|
/// Re-appends one synthetic signal per math channel, after the stream's own. Called under the
|
||||||
/// lock by both `set_signals()` (a CONFIG) and `set_math_channels()` (an edit).
|
/// lock by both `set_signals()` (a CONFIG) and `set_math_channels()` (an edit).
|
||||||
void append_math_signals_locked();
|
void append_math_signals_locked();
|
||||||
@@ -542,6 +704,9 @@ class ScopeModel {
|
|||||||
|
|
||||||
Partition part_;
|
Partition part_;
|
||||||
std::unique_ptr<BufferStore> store_;
|
std::unique_ptr<BufferStore> store_;
|
||||||
|
std::vector<Hole> holes_;
|
||||||
|
/// See `plan_generation()`. Written and read under `mutex()` only.
|
||||||
|
unsigned plan_generation_ = 0;
|
||||||
TriggerConfig trigger_{};
|
TriggerConfig trigger_{};
|
||||||
StreamStats stats_{};
|
StreamStats stats_{};
|
||||||
f64 epoch_ = 0.0;
|
f64 epoch_ = 0.0;
|
||||||
|
|||||||
+113
-2
@@ -31,6 +31,14 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
|||||||
/// mistaken for something the user placed.
|
/// mistaken for something the user placed.
|
||||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
||||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||||
|
/// A found hit's colour: the trigger marker's claim ("the scope fired here") in a cooler hue,
|
||||||
|
/// because a hit is the same condition found in *history* and the two are on screen together --
|
||||||
|
/// the live marker's red must stay the one that means "this is the event the capture is about".
|
||||||
|
constexpr plot::u32 kSearchColor = 0x7FB2FFu;
|
||||||
|
/// The *selected* hit's colour: the one the arrows are standing on, and the one a pause was taken
|
||||||
|
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
|
||||||
|
/// picture that cannot be read, so the current hit is brighter than its neighbours.
|
||||||
|
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
|
||||||
|
|
||||||
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
|
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
|
||||||
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
|
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
|
||||||
@@ -62,6 +70,23 @@ constexpr plot::f32 kRowGap = 6.0f;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
std::string layer_base_name(const std::string& name) {
|
||||||
|
const std::size_t a = name.rfind(" (");
|
||||||
|
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
|
||||||
|
name[a + 2] >= 'A' && name[a + 2] <= 'B') {
|
||||||
|
return name.substr(0, a);
|
||||||
|
}
|
||||||
|
const std::size_t h = name.rfind(" #");
|
||||||
|
if (h != std::string::npos && h + 2 < name.size()) {
|
||||||
|
bool digits = true;
|
||||||
|
for (std::size_t i = h + 2; i < name.size(); ++i) {
|
||||||
|
if (name[i] < '0' || name[i] > '9') digits = false;
|
||||||
|
}
|
||||||
|
if (digits) return name.substr(0, h);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
plot::Color RowSet::palette_color(int index) {
|
plot::Color RowSet::palette_color(int index) {
|
||||||
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
|
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
|
||||||
return plot::Color::hex(kPalette[i]);
|
return plot::Color::hex(kPalette[i]);
|
||||||
@@ -70,7 +95,10 @@ plot::Color RowSet::palette_color(int index) {
|
|||||||
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
|
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
|
||||||
|
|
||||||
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
|
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
|
||||||
const auto it = signal_colors_.find(signal);
|
// The exact name first, then the signal's: a colour the user picked for `sine` is the colour of
|
||||||
|
// every layer of it, so a comparison's `sine (B)` is not a signal of its own colour.
|
||||||
|
auto it = signal_colors_.find(signal);
|
||||||
|
if (it == signal_colors_.end()) it = signal_colors_.find(layer_base_name(signal));
|
||||||
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
|
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
|
||||||
return palette_color(palette_index);
|
return palette_color(palette_index);
|
||||||
}
|
}
|
||||||
@@ -202,6 +230,15 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
|||||||
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
||||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||||
}
|
}
|
||||||
|
// The search hits' pool, parked, created for the same reason the measurement pool above is:
|
||||||
|
// `add_cursor()` only appends, so every mark a scan could ever want exists from the start.
|
||||||
|
for (int m = 0; m < kSearchMarks; ++m) {
|
||||||
|
const std::size_t mi = static_cast<std::size_t>(m);
|
||||||
|
r.search_v[mi] = r.plot->add_cursor(kParked, plot::Color::hex(kSearchColor));
|
||||||
|
r.plot->set_cursor_selectable(r.search_v[mi], false);
|
||||||
|
r.plot->set_cursor_stroke(r.search_v[mi], kMeasureStrokeWidth,
|
||||||
|
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||||
|
}
|
||||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||||
// snap is Free and the controller never rounds it onto a sample.
|
// snap is Free and the controller never rounds it onto a sample.
|
||||||
@@ -215,9 +252,23 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
|||||||
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
|
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RowSet::set_row_weights(std::vector<float> weights) {
|
||||||
|
// The count is not checked here: `apply_row_weights()` sizes it to the rows that exist, so a layout
|
||||||
|
// with rows added or removed keeps working instead of silently losing its sizing.
|
||||||
|
row_weights_ = std::move(weights);
|
||||||
|
if (figure_ != nullptr) apply_row_weights();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::apply_row_weights() {
|
||||||
|
if (row_weights_.empty() || figure_ == nullptr) return;
|
||||||
|
row_weights_.resize(static_cast<std::size_t>(rows_), 1.0f);
|
||||||
|
(void)figure_->set_row_weights(row_weights_);
|
||||||
|
}
|
||||||
|
|
||||||
void RowSet::rebuild_all() {
|
void RowSet::rebuild_all() {
|
||||||
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
||||||
figure_->set_row_gap(kRowGap);
|
figure_->set_row_gap(kRowGap);
|
||||||
|
apply_row_weights();
|
||||||
x_group_ = figure_->add_x_link();
|
x_group_ = figure_->add_x_link();
|
||||||
for (int i = 0; i < kMaxRows; ++i) {
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
Row& r = row(i);
|
Row& r = row(i);
|
||||||
@@ -354,6 +405,25 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RowSet::set_invalid_spans(ScopeModel& model, DisplayMode mode) {
|
||||||
|
spans_.clear();
|
||||||
|
if (mode == DisplayMode::Live || mode == DisplayMode::Capturing) {
|
||||||
|
// `Hole::x_begin`/`x_end` are stream seconds; the plots' x is `stream_t + epoch`.
|
||||||
|
const f64 epoch = model.display_epoch();
|
||||||
|
for (const Hole& h : model.holes()) {
|
||||||
|
// `repaired()` and not a second list: the band keeps reporting a repaired hole, in the
|
||||||
|
// fainter tint, until its slots age out of the ring and `forget_holes_before()` drops it.
|
||||||
|
spans_.push_back(plot::InvalidSpan{h.x_begin + epoch, h.x_end + epoch, h.repaired()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
// Every row shares the one x window, so every row gets the same spans: a hole is a gap in
|
||||||
|
// the stream and not in one signal.
|
||||||
|
if (r.plot) r.plot->set_invalid_spans(spans_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool RowSet::live_edge(double& newest) const {
|
bool RowSet::live_edge(double& newest) const {
|
||||||
bool any = false;
|
bool any = false;
|
||||||
for (int i = 0; i < rows_; ++i) {
|
for (int i = 0; i < rows_; ++i) {
|
||||||
@@ -382,7 +452,19 @@ void RowSet::set_time_window(double seconds) {
|
|||||||
// `{0, window}` here instead would state an offset of a whole epoch.
|
// `{0, window}` here instead would state an offset of a whole epoch.
|
||||||
figure_->set_x_link_range(x_group_, {newest - window_s_, newest});
|
figure_->set_x_link_range(x_group_, {newest - window_s_, newest});
|
||||||
} else {
|
} else {
|
||||||
figure_->set_x_link_range(x_group_, {0.0, window_s_});
|
// No edge to read: there are no samples bound yet, which is the state every structural
|
||||||
|
// change leaves behind -- a re-plan drops the rings' history, and this function is called
|
||||||
|
// from `apply_time_window()`, which is what re-plans. So this is not a rare fallback; it
|
||||||
|
// is the axis a mode change is drawn with until the first sample lands.
|
||||||
|
//
|
||||||
|
// Which is why it states the window the same way the branch above does, with its maximum
|
||||||
|
// *at* the edge the live view puts at x = 0. `{0, window}` -- what it asked for before --
|
||||||
|
// is an offset of a whole window by the rule the comment above states, so the frames
|
||||||
|
// between the re-plan and the first sample drew the time base one screen to the left of
|
||||||
|
// where the following frames drew it. Two or three frames, every time an event or a
|
||||||
|
// waveform was opened: a trace that jumps a screen sideways and comes back is the
|
||||||
|
// "the time base flickers and it is unusable" report.
|
||||||
|
figure_->set_x_link_range(x_group_, {-window_s_, 0.0});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +626,32 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RowSet::set_search_marks(const std::vector<double>& xs, int current) {
|
||||||
|
const int n = static_cast<int>(xs.size()) < kSearchMarks ? static_cast<int>(xs.size())
|
||||||
|
: kSearchMarks;
|
||||||
|
search_marks_placed_ = n;
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (!r.plot) continue;
|
||||||
|
// Every row, not the hit's own row: a hit is an *instant on the shared x window*, so the
|
||||||
|
// mark lines up down the column the way the vertical cursors do. There is no "the hit's
|
||||||
|
// row" to pick -- the condition was found on the trigger's channel, which may not be
|
||||||
|
// plotted at all.
|
||||||
|
for (int m = 0; m < n; ++m) {
|
||||||
|
const std::size_t mi = static_cast<std::size_t>(m);
|
||||||
|
r.plot->set_cursor_x(r.search_v[mi], xs[mi], plot::CursorPlacement::Free);
|
||||||
|
// The colour is re-set every frame rather than only when the selection moves: the pool is
|
||||||
|
// re-ordered each frame as the view rolls, so slot `m` is not the same hit it was.
|
||||||
|
r.plot->set_cursor_color(r.search_v[mi],
|
||||||
|
plot::Color::hex(m == current ? kSearchCurrentColor
|
||||||
|
: kSearchColor));
|
||||||
|
}
|
||||||
|
for (int m = n; m < kSearchMarks; ++m) {
|
||||||
|
r.plot->set_cursor_x(r.search_v[static_cast<std::size_t>(m)], kParked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool RowSet::update_time_labels() {
|
bool RowSet::update_time_labels() {
|
||||||
Row& first = row(0);
|
Row& first = row(0);
|
||||||
if (!first.plot || !figure_) return false;
|
if (!first.plot || !figure_) return false;
|
||||||
@@ -697,6 +805,9 @@ bool RowSet::readouts_locked() const noexcept {
|
|||||||
for (const plot::u32 id : r.measure_v) {
|
for (const plot::u32 id : r.measure_v) {
|
||||||
if (r.plot->cursor(id).selectable) return false;
|
if (r.plot->cursor(id).selectable) return false;
|
||||||
}
|
}
|
||||||
|
for (const plot::u32 id : r.search_v) {
|
||||||
|
if (r.plot->cursor(id).selectable) return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-1
@@ -12,6 +12,7 @@
|
|||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
@@ -40,6 +41,12 @@ namespace udpscope {
|
|||||||
/// count is fixed at construction, so there is one path for "a row's traces changed"
|
/// count is fixed at construction, so there is one path for "a row's traces changed"
|
||||||
/// and "the row count changed", and it costs no GL state -- the area keeps its
|
/// and "the row count changed", and it costs no GL state -- the area keeps its
|
||||||
/// context and is simply handed the new figure.
|
/// context and is simply handed the new figure.
|
||||||
|
/// The signal a channel name belongs to, with a group's layer suffix taken back off: `sine (A)`,
|
||||||
|
/// `sine (B)` and `sine #2` are all the signal `sine`. A comparison's model holds one channel per
|
||||||
|
/// (signal, layer), so this is what lets the signal list show each signal once, draw every layer of it
|
||||||
|
/// in the signal's own colour, and stop a second group from re-suffixing a name a first one left.
|
||||||
|
std::string layer_base_name(const std::string& name);
|
||||||
|
|
||||||
class RowSet {
|
class RowSet {
|
||||||
public:
|
public:
|
||||||
/// The ceiling on rows, not the count: rows are added and removed one at a time
|
/// The ceiling on rows, not the count: rows are added and removed one at a time
|
||||||
@@ -48,11 +55,20 @@ class RowSet {
|
|||||||
/// allocated somewhere, and because the figure keeps a floor height per row -- past
|
/// allocated somewhere, and because the figure keeps a floor height per row -- past
|
||||||
/// about this many a row is chrome and no plot.
|
/// about this many a row is chrome and no plot.
|
||||||
static constexpr int kMaxRows = 8;
|
static constexpr int kMaxRows = 8;
|
||||||
static constexpr int kSlotsPerRow = 4;
|
/// Traces one row can hold. Eight rather than the four a hand-arranged scope usually wants, because
|
||||||
|
/// the *default* layout is four signals in one row and an accumulation needs a slot per extra event:
|
||||||
|
/// with four, "accumulate these two events" over the layout the app starts with had nowhere to put
|
||||||
|
/// the second copy and silently did nothing. The free slots cost nothing -- a `Plot` creates its
|
||||||
|
/// traces up front either way -- and the user's own traces stay where they are.
|
||||||
|
static constexpr int kSlotsPerRow = 8;
|
||||||
/// Readout marks per row per orientation: enough for a handful of level and time
|
/// Readout marks per row per orientation: enough for a handful of level and time
|
||||||
/// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor
|
/// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor
|
||||||
/// set at construction.
|
/// set at construction.
|
||||||
static constexpr int kMeasureMarks = 6;
|
static constexpr int kMeasureMarks = 6;
|
||||||
|
/// Search hits drawn at once, per row. A scan can return thousands; a plot cannot grow its
|
||||||
|
/// cursor set, and sixteen lines over one window is already the most a reader can count. The
|
||||||
|
/// panel states how many of the hits it is showing.
|
||||||
|
static constexpr int kSearchMarks = 16;
|
||||||
|
|
||||||
RowSet();
|
RowSet();
|
||||||
|
|
||||||
@@ -112,6 +128,10 @@ class RowSet {
|
|||||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
std::array<plot::u32, kMeasureMarks> measure_h{};
|
||||||
std::array<plot::u32, kMeasureMarks> measure_v{};
|
std::array<plot::u32, kMeasureMarks> measure_v{};
|
||||||
|
|
||||||
|
/// The search hits' marks: the same arrangement as the measurement pool above, vertical only
|
||||||
|
/// (a hit is an instant, never a level).
|
||||||
|
std::array<plot::u32, kSearchMarks> search_v{};
|
||||||
|
|
||||||
/// One marker per trace per cursor: where each vertical cursor crosses that trace's
|
/// One marker per trace per cursor: where each vertical cursor crosses that trace's
|
||||||
/// series, drawn only in `mix` mode. Two per slot because the pair crosses a trace
|
/// series, drawn only in `mix` mode. Two per slot because the pair crosses a trace
|
||||||
/// at two places, and an annotation cannot be shared between them.
|
/// at two places, and an annotation cannot be shared between them.
|
||||||
@@ -126,6 +146,22 @@ class RowSet {
|
|||||||
};
|
};
|
||||||
|
|
||||||
int row_count() const noexcept { return rows_; }
|
int row_count() const noexcept { return rows_; }
|
||||||
|
|
||||||
|
/// The alpha every trace is drawn with. The *group* views use it: accumulating events wants their
|
||||||
|
/// traces translucent so that agreement between them reads as brightness, which is the whole point
|
||||||
|
/// of a persistence display. It is mixed into the palette colour where the trace is created, so it
|
||||||
|
/// belongs with `assign()` -- set it before the assignments that follow it, or they keep the alpha
|
||||||
|
/// the previous view left behind.
|
||||||
|
void set_trace_alpha(float alpha) noexcept { trace_alpha_ = alpha; }
|
||||||
|
|
||||||
|
/// The rows' shares of the figure's height, normalised by the plotter; an empty vector means equal
|
||||||
|
/// rows. Kept here, and re-applied after every rebuild: a `Figure` is built afresh whenever a row's
|
||||||
|
/// trace set or the row count changes, so weights that lived only in the figure would be lost on the
|
||||||
|
/// first drop or row change after a user had sized their layout.
|
||||||
|
void set_row_weights(std::vector<float> weights);
|
||||||
|
/// Sizes the stored weights to the rows that exist and hands them to the figure.
|
||||||
|
void apply_row_weights();
|
||||||
|
const std::vector<float>& row_weights() const noexcept { return row_weights_; }
|
||||||
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
|
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
|
||||||
/// count is fixed at construction.
|
/// count is fixed at construction.
|
||||||
void set_row_count(int rows);
|
void set_row_count(int rows);
|
||||||
@@ -171,6 +207,18 @@ class RowSet {
|
|||||||
/// and status line all describe the same display: the producer can open or close a
|
/// and status line all describe the same display: the producer can open or close a
|
||||||
/// capture between any two reads of `ScopeModel::display_mode()`.
|
/// capture between any two reads of `ScopeModel::display_mode()`.
|
||||||
void bind(ScopeModel& model, DisplayMode mode);
|
void bind(ScopeModel& model, DisplayMode mode);
|
||||||
|
/// Publishes `model.holes()` to every row as shading, in display coordinates.
|
||||||
|
///
|
||||||
|
/// Separate from `bind()` and called *after* the display epoch has been written, because the
|
||||||
|
/// epoch the band needs is this frame's: `ui.cpp` recomputes it from the newly bound traces'
|
||||||
|
/// live edge, and the previous frame's value is a whole production interval stale -- a third
|
||||||
|
/// of the screen at 1 MS/s over a 50 ms window, which would put the band beside the gap
|
||||||
|
/// rather than over it.
|
||||||
|
///
|
||||||
|
/// Shading only in `Live` and `Capturing`. A `Held` or `Paused` snapshot carries its own
|
||||||
|
/// stamps and its own x offset, and the live hole table does not address it; shading it would
|
||||||
|
/// accuse whichever samples happened to land under those coordinates.
|
||||||
|
void set_invalid_spans(ScopeModel& model, DisplayMode mode);
|
||||||
/// Publishes the live buffer's new samples.
|
/// Publishes the live buffer's new samples.
|
||||||
void acquire(ScopeModel& model) { model.acquire_all(); }
|
void acquire(ScopeModel& model) { model.acquire_all(); }
|
||||||
|
|
||||||
@@ -255,6 +303,17 @@ class RowSet {
|
|||||||
/// measurement is drawn", as opposed to merely computed.
|
/// measurement is drawn", as opposed to merely computed.
|
||||||
int measure_marks_placed() const noexcept { return measure_marks_placed_; }
|
int measure_marks_placed() const noexcept { return measure_marks_placed_; }
|
||||||
|
|
||||||
|
/// Places the search hits' marks and parks the rest of the pool. `xs` are **display** x values
|
||||||
|
/// (the caller has already taken `stream_at_x0()` off a stream time), the same convention
|
||||||
|
/// `set_measure_marks()` takes a time mark in, and the marks are unselectable for the same
|
||||||
|
/// reason: a hit is where the scope found the condition, not something the pointer may move.
|
||||||
|
///
|
||||||
|
/// `current` indexes the one the navigation is standing on, which is drawn in its own colour, or
|
||||||
|
/// is negative for none. The caller orders `xs`; this places them in the order it is given.
|
||||||
|
void set_search_marks(const std::vector<double>& xs, int current);
|
||||||
|
/// How many search marks the last call placed, which is `min(xs.size(), kSearchMarks)`.
|
||||||
|
int search_marks_placed() const noexcept { return search_marks_placed_; }
|
||||||
|
|
||||||
/// Whether every instrument this app *computed* is out of the pointer's reach: the trigger
|
/// Whether every instrument this app *computed* is out of the pointer's reach: the trigger
|
||||||
/// marker and the measurement marks. They are readouts -- where the scope put the edge, what a
|
/// marker and the measurement marks. They are readouts -- where the scope put the edge, what a
|
||||||
/// measurement measured -- and a user must not be able to drag them; the report in the dump is
|
/// measurement measured -- and a user must not be able to drag them; the report in the dump is
|
||||||
@@ -326,6 +385,13 @@ class RowSet {
|
|||||||
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
||||||
/// `fit_x()` branches on, which is why the fit probe reports it.
|
/// `fit_x()` branches on, which is why the fit probe reports it.
|
||||||
bool window_pinned() const noexcept { return pinned_; }
|
bool window_pinned() const noexcept { return pinned_; }
|
||||||
|
/// True when the pin already *is* this window. A caller that re-states a pin every frame -- the file
|
||||||
|
/// view does, from the ring's own extent -- uses this to update the pin's numbers with
|
||||||
|
/// `set_data_pin()` instead of writing the group's range again with `pin_event_window()`, which would
|
||||||
|
/// undo a gesture that `hold_pinned_window()` has not adopted yet.
|
||||||
|
bool pinned_window_is(double pre_s, double post_s) const noexcept {
|
||||||
|
return pinned_ && pinned_pre_ == pre_s && pinned_post_ == post_s;
|
||||||
|
}
|
||||||
|
|
||||||
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||||
/// a cursor), and nothing otherwise.
|
/// a cursor), and nothing otherwise.
|
||||||
@@ -446,6 +512,8 @@ class RowSet {
|
|||||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||||
plot::u32 x_group_ = 0;
|
plot::u32 x_group_ = 0;
|
||||||
int rows_ = 1;
|
int rows_ = 1;
|
||||||
|
float trace_alpha_ = 1.0f;
|
||||||
|
std::vector<float> row_weights_;
|
||||||
double window_s_ = 2.0;
|
double window_s_ = 2.0;
|
||||||
double trigger_marker_x_ = 0.0;
|
double trigger_marker_x_ = 0.0;
|
||||||
bool trigger_marker_on_ = false;
|
bool trigger_marker_on_ = false;
|
||||||
@@ -468,6 +536,8 @@ class RowSet {
|
|||||||
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
|
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
|
||||||
/// coloured without anything having to seed them.
|
/// coloured without anything having to seed them.
|
||||||
std::map<std::string, plot::u32> signal_colors_;
|
std::map<std::string, plot::u32> signal_colors_;
|
||||||
|
/// Reused across frames by `set_invalid_spans()`: see there.
|
||||||
|
std::vector<plot::InvalidSpan> spans_;
|
||||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||||
bool cursors_on_ = false;
|
bool cursors_on_ = false;
|
||||||
bool relative_x_ = false;
|
bool relative_x_ = false;
|
||||||
@@ -479,6 +549,7 @@ class RowSet {
|
|||||||
/// back, one row's stale echo at a time.
|
/// back, one row's stale echo at a time.
|
||||||
|
|
||||||
int measure_marks_placed_ = 0;
|
int measure_marks_placed_ = 0;
|
||||||
|
int search_marks_placed_ = 0;
|
||||||
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
|
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
|
||||||
double cursor_a_x_ = 0.0;
|
double cursor_a_x_ = 0.0;
|
||||||
double cursor_b_x_ = 0.0;
|
double cursor_b_x_ = 0.0;
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "search.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
double ms_since(const std::chrono::steady_clock::time_point& t0) {
|
||||||
|
const std::chrono::duration<double, std::milli> d = std::chrono::steady_clock::now() - t0;
|
||||||
|
return d.count();
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void BufferSearch::start(const EventCondition& c, int channel, double dt) {
|
||||||
|
clear();
|
||||||
|
channel_ = channel;
|
||||||
|
detector_.reset();
|
||||||
|
detector_.configure(c, dt);
|
||||||
|
// `configure()` is a no-op for an unchanged condition, so the explicit reset above is what
|
||||||
|
// makes a re-run of the *same* search start from a clean machine.
|
||||||
|
detector_.reset();
|
||||||
|
state_ = State::Running;
|
||||||
|
t_scratch_.resize(static_cast<std::size_t>(kChunk));
|
||||||
|
v_scratch_.resize(static_cast<std::size_t>(kChunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BufferSearch::cancel() noexcept {
|
||||||
|
if (state_ == State::Running) state_ = State::Cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BufferSearch::clear() noexcept {
|
||||||
|
state_ = State::Idle;
|
||||||
|
started_ = false;
|
||||||
|
truncated_ = false;
|
||||||
|
at_ = 0;
|
||||||
|
end_ = 0;
|
||||||
|
first_s_ = 0.0;
|
||||||
|
scanned_s_ = 0.0;
|
||||||
|
total_s_ = 0.0;
|
||||||
|
hits_.clear();
|
||||||
|
cursor_ = -1;
|
||||||
|
chunks_ = 0;
|
||||||
|
worst_chunk_ms_ = 0.0;
|
||||||
|
total_ms_ = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BufferSearch::select(int i) noexcept {
|
||||||
|
if (hits_.empty()) {
|
||||||
|
cursor_ = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (i < 0) i = 0;
|
||||||
|
if (static_cast<std::size_t>(i) >= hits_.size()) i = static_cast<int>(hits_.size()) - 1;
|
||||||
|
cursor_ = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BufferSearch::next() noexcept {
|
||||||
|
if (hits_.empty() || cursor_ + 1 >= static_cast<int>(hits_.size())) return false;
|
||||||
|
++cursor_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BufferSearch::prev() noexcept {
|
||||||
|
if (hits_.empty() || cursor_ <= 0) return false;
|
||||||
|
--cursor_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BufferSearch::pump(ScopeModel& model) {
|
||||||
|
if (state_ != State::Running) return false;
|
||||||
|
const auto chunk_t0 = std::chrono::steady_clock::now();
|
||||||
|
u64 n = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(model.mutex());
|
||||||
|
if (channel_ < 0 || static_cast<std::size_t>(channel_) >= model.channel_count()) {
|
||||||
|
state_ = State::Cancelled;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
BufferStore& store = model.live();
|
||||||
|
if (!started_) {
|
||||||
|
// The whole retained buffer: the bounds are deliberately absurd, because
|
||||||
|
// `live_window_locked()` clamps them to the time ring and to *every* value ring, which
|
||||||
|
// is the one cut that keeps the stamps and the values index-aligned by construction.
|
||||||
|
u64 first = 0;
|
||||||
|
u64 last = 0;
|
||||||
|
model.live_window_locked(-1.0e300, 1.0e300, first, last);
|
||||||
|
at_ = first;
|
||||||
|
end_ = last;
|
||||||
|
started_ = true;
|
||||||
|
if (end_ > at_) {
|
||||||
|
f64 stamp = 0.0;
|
||||||
|
store.time().copy_from(at_, 1, &stamp);
|
||||||
|
first_s_ = static_cast<double>(stamp);
|
||||||
|
store.time().copy_from(end_ - 1, 1, &stamp);
|
||||||
|
total_s_ = static_cast<double>(stamp) - first_s_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The producer keeps pushing while we scan, so the oldest samples go. `copy_from()` fixes
|
||||||
|
// *which* indices are read, not that the slots still hold them -- a read behind the base
|
||||||
|
// would silently return whatever overwrote it, which would be a hit at a time that never
|
||||||
|
// existed. Skipping forward and reporting `Truncated` is the honest answer.
|
||||||
|
const u64 base = store.time().base();
|
||||||
|
if (base > at_) {
|
||||||
|
at_ = base;
|
||||||
|
truncated_ = true;
|
||||||
|
detector_.reset(); // the sequence has a gap in it; no pulse spans it
|
||||||
|
}
|
||||||
|
if (at_ >= end_) {
|
||||||
|
state_ = truncated_ ? State::Truncated : State::Done;
|
||||||
|
total_ms_ += ms_since(chunk_t0);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
n = end_ - at_ < kChunk ? end_ - at_ : kChunk;
|
||||||
|
store.time().copy_from(at_, n, t_scratch_.data());
|
||||||
|
store.values(static_cast<std::size_t>(channel_)).copy_from(at_, n, v_scratch_.data());
|
||||||
|
}
|
||||||
|
// The evaluation is outside the lock: it is the expensive half, and it touches only the
|
||||||
|
// copies above.
|
||||||
|
for (u64 i = 0; i < n; ++i) {
|
||||||
|
const double t = static_cast<double>(t_scratch_[static_cast<std::size_t>(i)]);
|
||||||
|
const double e =
|
||||||
|
detector_.feed(static_cast<double>(v_scratch_[static_cast<std::size_t>(i)]), t);
|
||||||
|
if (std::isfinite(e)) {
|
||||||
|
hits_.push_back(e);
|
||||||
|
if (hits_.size() >= kHitsMax) {
|
||||||
|
at_ += i + 1;
|
||||||
|
scanned_s_ = t - first_s_;
|
||||||
|
state_ = State::Capped;
|
||||||
|
++chunks_;
|
||||||
|
const double cost = ms_since(chunk_t0);
|
||||||
|
if (cost > worst_chunk_ms_) worst_chunk_ms_ = cost;
|
||||||
|
total_ms_ += cost;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scanned_s_ = t - first_s_;
|
||||||
|
}
|
||||||
|
at_ += n;
|
||||||
|
++chunks_;
|
||||||
|
const double cost = ms_since(chunk_t0);
|
||||||
|
if (cost > worst_chunk_ms_) worst_chunk_ms_ = cost;
|
||||||
|
total_ms_ += cost;
|
||||||
|
if (at_ >= end_) {
|
||||||
|
state_ = truncated_ ? State::Truncated : State::Done;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "condition.hpp"
|
||||||
|
#include "model.hpp"
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// A scan of the whole retained buffer for every sample satisfying an `EventCondition`.
|
||||||
|
///
|
||||||
|
/// It runs an `EventDetector` -- the *same* evaluator the live trigger runs -- over the ring
|
||||||
|
/// in stream order, so a hit is by construction an instant the trigger would have fired on.
|
||||||
|
/// That is the claim the probe's armed-vs-scanned cross-check checks, and it is the reason
|
||||||
|
/// the detector was separated from the engine at all.
|
||||||
|
///
|
||||||
|
/// Everything here is in **ring-stamp space**, which is stream seconds relative to the
|
||||||
|
/// model's epoch: the clock `TriggerEngine::process()` is fed, the clock
|
||||||
|
/// `ScopeModel::live_window_locked()` searches, and the clock `publish_pause_view()` cuts in.
|
||||||
|
/// No conversion to display x happens in this class.
|
||||||
|
///
|
||||||
|
/// It runs on the GTK thread in chunks rather than on a thread of its own: the samples live
|
||||||
|
/// behind `ScopeModel::mutex()`, which the producer holds for each packet, and a scan that
|
||||||
|
/// took the lock once would stall acquisition for the whole buffer. One chunk per call holds
|
||||||
|
/// it for `kChunk` samples and no longer.
|
||||||
|
class BufferSearch {
|
||||||
|
public:
|
||||||
|
/// The hit list's ceiling. A condition matching a large share of the samples -- an edge
|
||||||
|
/// trigger on a noisy channel -- would otherwise fill memory with a list nobody can
|
||||||
|
/// navigate; the scan stops and says `Capped`, which is information rather than a hang.
|
||||||
|
static constexpr std::size_t kHitsMax = 10000;
|
||||||
|
/// Samples per `pump()`, and therefore the only latency knob: the producer waits on the
|
||||||
|
/// lock for exactly one chunk's scan.
|
||||||
|
static constexpr u64 kChunk = 8192;
|
||||||
|
|
||||||
|
enum class State {
|
||||||
|
Idle, ///< nothing has been searched.
|
||||||
|
Running, ///< `pump()` has more to do.
|
||||||
|
Done, ///< the whole retained buffer was scanned.
|
||||||
|
Truncated, ///< the producer overwrote samples before they were reached.
|
||||||
|
Capped, ///< `kHitsMax` was reached.
|
||||||
|
Cancelled, ///< the user stopped it, or the channel went away.
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Begins a scan of the channel for the condition. Discards any previous result.
|
||||||
|
void start(const EventCondition& c, int channel, double dt);
|
||||||
|
void cancel() noexcept;
|
||||||
|
void clear() noexcept;
|
||||||
|
|
||||||
|
/// Scans one chunk. Returns true while there is more to do. Takes `model.mutex()` itself,
|
||||||
|
/// so the caller must *not* hold it.
|
||||||
|
bool pump(ScopeModel& model);
|
||||||
|
|
||||||
|
State state() const noexcept { return state_; }
|
||||||
|
const std::vector<double>& hits() const noexcept { return hits_; }
|
||||||
|
/// Which hit the view is on, or -1 for none.
|
||||||
|
int cursor() const noexcept { return cursor_; }
|
||||||
|
void select(int i) noexcept;
|
||||||
|
/// Moves the cursor and returns whether it moved; the ends do not wrap, because a wrap
|
||||||
|
/// from the last hit to the first looks exactly like the view not having moved at all.
|
||||||
|
bool next() noexcept;
|
||||||
|
bool prev() noexcept;
|
||||||
|
|
||||||
|
/// How much of the buffer has been scanned, and how much there was, in stream seconds.
|
||||||
|
double scanned_s() const noexcept { return scanned_s_; }
|
||||||
|
double total_s() const noexcept { return total_s_; }
|
||||||
|
/// Cost, for the probe: chunks scanned, the worst single chunk, and the whole scan.
|
||||||
|
std::uint64_t chunks() const noexcept { return chunks_; }
|
||||||
|
double worst_chunk_ms() const noexcept { return worst_chunk_ms_; }
|
||||||
|
double total_ms() const noexcept { return total_ms_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
EventDetector detector_{};
|
||||||
|
int channel_ = -1;
|
||||||
|
State state_ = State::Idle;
|
||||||
|
bool started_ = false; ///< the index range has been established.
|
||||||
|
bool truncated_ = false; ///< the producer overtook the scan at least once; sticky for the whole scan.
|
||||||
|
u64 at_ = 0; ///< next absolute index to scan.
|
||||||
|
u64 end_ = 0; ///< one past the last absolute index to scan.
|
||||||
|
double first_s_ = 0.0; ///< stamp of `at_` when the scan began.
|
||||||
|
double scanned_s_ = 0.0;
|
||||||
|
double total_s_ = 0.0;
|
||||||
|
std::vector<double> hits_;
|
||||||
|
int cursor_ = -1;
|
||||||
|
std::uint64_t chunks_ = 0;
|
||||||
|
double worst_chunk_ms_ = 0.0;
|
||||||
|
double total_ms_ = 0.0;
|
||||||
|
std::vector<f64> t_scratch_;
|
||||||
|
std::vector<f32> v_scratch_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "settings.hpp"
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
|
||||||
|
#include <glib.h>
|
||||||
|
|
||||||
|
#include "layout_store.hpp" // `mkdirs()`, so a first run's config directory is created with parents
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// One `key = value` per line, `#` for a comment: the same shape the layout files use, and parsed just
|
||||||
|
/// as leniently -- an unknown key, an empty line or a file written by an older version is not an error.
|
||||||
|
bool parse_bool(const std::string& v, bool fallback) {
|
||||||
|
if (v == "true" || v == "1" || v == "yes") return true;
|
||||||
|
if (v == "false" || v == "0" || v == "no") return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string unquote(const std::string& v) {
|
||||||
|
if (v.size() >= 2 && v.front() == '"' && v.back() == '"') return v.substr(1, v.size() - 2);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Settings default_settings() { return Settings{}; }
|
||||||
|
|
||||||
|
std::string settings_path() {
|
||||||
|
const std::string dir = std::string(g_get_user_config_dir()) + "/udpscope";
|
||||||
|
mkdirs(dir); // every component: `<config>` itself may not exist on a fresh install
|
||||||
|
return dir + "/settings.toml";
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings load_settings() {
|
||||||
|
Settings s; // the defaults, so the file only has to mention what a user changed
|
||||||
|
std::ifstream in(settings_path());
|
||||||
|
if (!in) return s;
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(in, line)) {
|
||||||
|
const std::size_t hash = line.find('#');
|
||||||
|
if (hash != std::string::npos) line = line.substr(0, hash);
|
||||||
|
const std::size_t eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) continue;
|
||||||
|
std::string key = line.substr(0, eq);
|
||||||
|
std::string value = line.substr(eq + 1);
|
||||||
|
const auto trim = [](std::string& t) {
|
||||||
|
const std::size_t a = t.find_first_not_of(" \t\r\n");
|
||||||
|
const std::size_t b = t.find_last_not_of(" \t\r\n");
|
||||||
|
t = a == std::string::npos ? std::string() : t.substr(a, b - a + 1);
|
||||||
|
};
|
||||||
|
trim(key);
|
||||||
|
trim(value);
|
||||||
|
value = unquote(value);
|
||||||
|
if (value.empty()) continue;
|
||||||
|
if (key == "event_cache_dir") {
|
||||||
|
s.event_cache_dir = value;
|
||||||
|
} else if (key == "event_cache_max") {
|
||||||
|
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
|
||||||
|
} else if (key == "relative_time") {
|
||||||
|
s.relative_time = parse_bool(value, s.relative_time);
|
||||||
|
} else if (key == "theme") {
|
||||||
|
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool save_settings(const Settings& settings) {
|
||||||
|
std::ofstream out(settings_path(), std::ios::trunc);
|
||||||
|
if (!out) return false;
|
||||||
|
out << "# udpscope preferences -- written by the preferences dialog, read at start-up.\n";
|
||||||
|
out << "# A key that is absent means its default, so this file only has to mention what changed.\n";
|
||||||
|
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
|
||||||
|
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
||||||
|
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
||||||
|
out << "theme = \"" << settings.theme << "\"\n";
|
||||||
|
out.flush();
|
||||||
|
return out.good();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
//
|
||||||
|
// The preferences: what a user chose about this program rather than about a source, kept between runs
|
||||||
|
// in one file. Deliberately free of GTK and of the app's own state -- it is a handful of values, a
|
||||||
|
// file to read them from and a file to write them to -- so the dialog that edits them, the code that
|
||||||
|
// applies them and the tests that read them all go through the same struct.
|
||||||
|
|
||||||
|
#ifndef UDPSCOPE_SETTINGS_HPP
|
||||||
|
#define UDPSCOPE_SETTINGS_HPP
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Everything the preferences dialog can change.
|
||||||
|
///
|
||||||
|
/// The defaults are the *stated* ones: an absent file and a fresh install behave identically, so a
|
||||||
|
/// setting is only ever a user's decision and never an accident of what a file happened to contain.
|
||||||
|
struct Settings {
|
||||||
|
/// Where the event cache goes: the directory each source's folder is created in. Empty means the
|
||||||
|
/// standard `<cache>/udpscope/events`, and a path is used as given -- one directory for every
|
||||||
|
/// source, never one between them, because two sources writing one directory is two sources
|
||||||
|
/// overwriting each other's events.
|
||||||
|
std::string event_cache_dir;
|
||||||
|
/// How many events one source's cache keeps. 0 disables trimming, which is a decision about the
|
||||||
|
/// user's disk made deliberately rather than by default.
|
||||||
|
int event_cache_max = 32;
|
||||||
|
/// `--relative-time`'s setting, for a source whose wall-clock stamps jitter. A *startup* setting: the
|
||||||
|
/// buffers are planned before any stream is known, so it takes effect on the next start (the
|
||||||
|
/// preferences dialog says so, like the configurations dialog does).
|
||||||
|
bool relative_time = false;
|
||||||
|
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
|
||||||
|
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
|
||||||
|
std::string theme = "auto";
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
|
||||||
|
/// what a missing file yields.
|
||||||
|
Settings default_settings();
|
||||||
|
|
||||||
|
/// The file the settings live in: `<config>/udpscope/settings.toml`. Never empty.
|
||||||
|
std::string settings_path();
|
||||||
|
|
||||||
|
/// Reads the settings, filling anything the file does not mention from the defaults. A file that
|
||||||
|
/// cannot be read is not an error: it is a first run.
|
||||||
|
Settings load_settings();
|
||||||
|
|
||||||
|
/// Writes them. Returns false when the file cannot be written, which the dialog reports rather than
|
||||||
|
/// swallowing -- a preference that silently does not stick is worse than one that says it did not.
|
||||||
|
bool save_settings(const Settings& settings);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
|
|
||||||
|
#endif // UDPSCOPE_SETTINGS_HPP
|
||||||
+6
-3
@@ -38,9 +38,11 @@ constexpr int kAccelCount = static_cast<int>(sizeof(kAccels) / sizeof(kAccels[0]
|
|||||||
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
|
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
|
||||||
///
|
///
|
||||||
/// A space-separated accelerator is the two-key alternative form the shortcut label draws
|
/// A space-separated accelerator is the two-key alternative form the shortcut label draws
|
||||||
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator
|
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator syntax nor
|
||||||
/// syntax nor libadwaita's knows a wheel or a drag, and asking either to parse "Wheel"
|
/// libadwaita's knows a wheel or a drag, and asking either to parse "Wheel" is a parse warning and an
|
||||||
/// is a parse warning and an empty cell. Those rows are prose in the subtitle instead.
|
/// empty cell. The overview draws them from `src/ui/shortcuts.ui`, which gives each one GTK's generic
|
||||||
|
/// gesture type and an icon where the keycaps would be. That file is the *presentation*; this table is
|
||||||
|
/// what the application handles, which is why `[help]`'s `ui=` is checked against `tables=`.
|
||||||
constexpr Gesture kGestures[] = {
|
constexpr Gesture kGestures[] = {
|
||||||
{"plus minus", "", "Zoom in or out, both axes", "Plot keyboard"},
|
{"plus minus", "", "Zoom in or out, both axes", "Plot keyboard"},
|
||||||
{"<Control>plus <Control>minus", "", "Zoom the time axis only", "Plot keyboard"},
|
{"<Control>plus <Control>minus", "", "Zoom the time axis only", "Plot keyboard"},
|
||||||
@@ -55,6 +57,7 @@ constexpr Gesture kGestures[] = {
|
|||||||
{"", "Drag", "Pan", "Plot mouse"},
|
{"", "Drag", "Pan", "Plot mouse"},
|
||||||
{"", "Ctrl and drag", "Box zoom on time", "Plot mouse"},
|
{"", "Ctrl and drag", "Box zoom on time", "Plot mouse"},
|
||||||
{"", "Alt and drag", "Box zoom on values", "Plot mouse"},
|
{"", "Alt and drag", "Box zoom on values", "Plot mouse"},
|
||||||
|
{"", "Shift and drag", "Box zoom on time and values", "Plot mouse"},
|
||||||
};
|
};
|
||||||
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));
|
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));
|
||||||
|
|
||||||
|
|||||||
+645
-105
@@ -3,11 +3,17 @@
|
|||||||
|
|
||||||
#include "source_dialog.hpp"
|
#include "source_dialog.hpp"
|
||||||
|
|
||||||
|
#include "waveform.hpp"
|
||||||
|
|
||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
|
#include "settings.hpp"
|
||||||
|
#include "ui_prefs.hpp"
|
||||||
|
|
||||||
#include <adwaita.h>
|
#include <adwaita.h>
|
||||||
#include <gtk/gtk.h>
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -24,13 +30,25 @@ void apply_source(Config& cfg, const KnownSource& src) {
|
|||||||
cfg.port = src.port;
|
cfg.port = src.port;
|
||||||
cfg.multicast = src.multicast;
|
cfg.multicast = src.multicast;
|
||||||
cfg.interface_addr = src.interface_addr;
|
cfg.interface_addr = src.interface_addr;
|
||||||
|
// The configuration's own startup settings. They are read *before* the model exists, which is the
|
||||||
|
// only moment a buffer size can be applied at all -- and the reason they are stored per
|
||||||
|
// configuration rather than only passed on the command line.
|
||||||
|
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
|
||||||
|
// The list's own copy of the clock, but only where nothing newer has said: with the preferences
|
||||||
|
// open from this dialog a user can state relative time a minute before clicking the row, and that
|
||||||
|
// statement must survive the click that follows it.
|
||||||
|
if (!cfg.relative_time_specified) cfg.relative_time = src.relative_time;
|
||||||
|
cfg.capture_packets = src.capture_packets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
|
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
|
||||||
void report_sources(const std::vector<KnownSource>& known) {
|
void report_sources(const std::vector<KnownSource>& known) {
|
||||||
std::fprintf(stderr, "[sources] %zu known:", known.size());
|
std::fprintf(stderr, "[sources] %zu known:", known.size());
|
||||||
for (const KnownSource& s : known) {
|
for (const KnownSource& s : known) {
|
||||||
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
|
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d buffer=%.6g relative=%d capture=%u",
|
||||||
|
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb,
|
||||||
|
s.relative_time ? 1 : 0, s.capture_packets);
|
||||||
|
if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str());
|
||||||
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
|
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
|
||||||
if (!s.interface_addr.empty()) {
|
if (!s.interface_addr.empty()) {
|
||||||
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
|
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
|
||||||
@@ -51,20 +69,65 @@ struct Chooser {
|
|||||||
GtkWidget* window = nullptr;
|
GtkWidget* window = nullptr;
|
||||||
GtkWidget* list = nullptr;
|
GtkWidget* list = nullptr;
|
||||||
std::vector<KnownSource> known;
|
std::vector<KnownSource> known;
|
||||||
|
/// The window's frame is a `.ui` file (`src/ui/source-chooser.ui`): the window, its header bar, the two
|
||||||
|
/// pages and the buttons that are about the chooser rather than about a source. The builder is held
|
||||||
|
/// with the window for the same reason the form's is -- the window belongs to the builder's object list.
|
||||||
|
GtkBuilder* ui = nullptr;
|
||||||
|
/// The Waveforms page: the exports opened before, newest first, and the stack that holds the two pages.
|
||||||
|
/// A waveform is the *other* answer to "which source", so it gets a page of its own rather than a button
|
||||||
|
/// that hides a file dialog behind one more click. A `GtkNotebook` was the first shape of this and a tab
|
||||||
|
/// strip is the wrong one: an `AdwViewStack` under an `AdwViewSwitcherBar` is what current libadwaita
|
||||||
|
/// asks for, and the switcher's stack is wired in code because a property cannot name a widget declared
|
||||||
|
/// elsewhere in the same tree.
|
||||||
|
GtkWidget* page_stack = nullptr;
|
||||||
|
GtkWidget* switcher = nullptr;
|
||||||
|
/// The buttons the header bar holds, kept because two of them are read at runtime: `new_host` takes the
|
||||||
|
/// focus when the list is empty (there is nothing else to do), and all of them are read back by
|
||||||
|
/// `report_chooser_ui()` so a `.ui` that lost one is named rather than silently missing its button.
|
||||||
|
GtkWidget* prefs_button = nullptr;
|
||||||
|
GtkWidget* new_host = nullptr;
|
||||||
|
GtkWidget* waves_open = nullptr;
|
||||||
|
GtkWidget* cancel_button = nullptr;
|
||||||
|
GtkWidget* wave_list = nullptr;
|
||||||
|
std::vector<std::string> waves;
|
||||||
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
|
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
|
||||||
/// the chooser outlives the form by construction, so there is nothing to free on a close path
|
/// the chooser outlives the form by construction, so there is nothing to free on a close path
|
||||||
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
|
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
|
||||||
/// -free in the math editor.
|
/// -free in the math editor.
|
||||||
GtkWidget* form = nullptr;
|
AdwDialog* form = nullptr;
|
||||||
GtkWidget* form_server = nullptr;
|
/// The form is a `.ui` file (`src/ui/source-form.ui`), so its builder is held with it, and its rows
|
||||||
GtkWidget* form_port = nullptr;
|
/// are typed: a switch is read with `adw_switch_row_get_active()` and not off a widget.
|
||||||
GtkWidget* form_group = nullptr;
|
GtkBuilder* form_ui = nullptr;
|
||||||
GtkWidget* form_iface = nullptr;
|
AdwWindowTitle* form_title = nullptr;
|
||||||
|
AdwEntryRow* form_server = nullptr;
|
||||||
|
AdwSpinRow* form_port = nullptr;
|
||||||
|
AdwEntryRow* form_group = nullptr;
|
||||||
|
AdwEntryRow* form_iface = nullptr;
|
||||||
GtkWidget* form_connect = nullptr;
|
GtkWidget* form_connect = nullptr;
|
||||||
|
/// The configuration half of the form: what a source's *session* is called and the settings that
|
||||||
|
/// can only be applied before the model exists. A new host needs all of them, and so does an
|
||||||
|
/// existing one -- which is why the same form is used for both.
|
||||||
|
AdwEntryRow* form_name = nullptr;
|
||||||
|
AdwEntryRow* form_note = nullptr;
|
||||||
|
AdwSpinRow* form_buffer = nullptr;
|
||||||
|
AdwSwitchRow* form_relative = nullptr;
|
||||||
|
/// The configuration the form is editing: empty for a new host. Copied rather than referenced,
|
||||||
|
/// because `chooser_populate()` rebuilds `known` under the form.
|
||||||
|
std::string edit_host;
|
||||||
|
std::uint16_t edit_port = 0;
|
||||||
|
std::string edit_config;
|
||||||
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
|
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
|
||||||
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
|
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
|
||||||
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
|
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
|
||||||
guint pick_source = 0;
|
guint pick_source = 0;
|
||||||
|
/// What the *form* last wrote, for `report_pick()`: a save finishes the chooser and destroys the
|
||||||
|
/// list with it, so the row a report would name is gone by the time the probe reads it.
|
||||||
|
std::string saved_config;
|
||||||
|
std::string saved_note;
|
||||||
|
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
|
||||||
|
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
|
||||||
|
ui_impl::PrefsForm prefs;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void chooser_finish(Chooser& c, bool chosen) {
|
void chooser_finish(Chooser& c, bool chosen) {
|
||||||
@@ -75,28 +138,145 @@ void chooser_finish(Chooser& c, bool chosen) {
|
|||||||
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
|
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
|
||||||
// window returns having done nothing.
|
// window returns having done nothing.
|
||||||
if (c.form != nullptr) {
|
if (c.form != nullptr) {
|
||||||
GtkWidget* w = c.form;
|
AdwDialog* d = c.form;
|
||||||
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
|
c.form = nullptr; // before the close, so the `closed` handler cannot re-enter
|
||||||
gtk_window_destroy(GTK_WINDOW(w));
|
adw_dialog_force_close(d);
|
||||||
}
|
}
|
||||||
|
// The preferences go first: they are presented *on* this window, and a dialog still up when its
|
||||||
|
// window is destroyed is the lifetime this project has got wrong once already. `prefs_close()` is
|
||||||
|
// `adw_dialog_close()`, and the form's own `closed` handler clears it.
|
||||||
|
ui_impl::prefs_close(c.prefs);
|
||||||
if (c.window != nullptr) {
|
if (c.window != nullptr) {
|
||||||
GtkWidget* w = c.window;
|
GtkWidget* w = c.window;
|
||||||
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
||||||
gtk_window_destroy(GTK_WINDOW(w));
|
gtk_window_destroy(GTK_WINDOW(w));
|
||||||
}
|
}
|
||||||
|
// The window belonged to the builder's object list, so it is freed with it -- after the destroy, never
|
||||||
|
// before: a builder freed under a live window takes the window's children with it.
|
||||||
|
g_clear_object(&c.ui);
|
||||||
if (c.loop != nullptr) g_main_loop_quit(c.loop);
|
if (c.loop != nullptr) g_main_loop_quit(c.loop);
|
||||||
}
|
}
|
||||||
|
|
||||||
void chooser_populate(Chooser& c);
|
void chooser_populate(Chooser& c);
|
||||||
|
void chooser_populate_waves(Chooser& c);
|
||||||
|
void report_pick(const Chooser& c);
|
||||||
|
/// Defined below; the row buttons reach it, and the row builder sits above it.
|
||||||
|
void open_form(Chooser& c, int edit_index);
|
||||||
|
|
||||||
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||||
Chooser& c = *static_cast<Chooser*>(user);
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
|
||||||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||||
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
|
const KnownSource& src = c.known[static_cast<std::size_t>(idx)];
|
||||||
|
// Picking a configuration *is* choosing it as the active one: the app reads that back when it
|
||||||
|
// loads the session, and the next save goes to the same file. Without this, opening `bench`
|
||||||
|
// from the chooser would leave the source pointing at whatever was active before.
|
||||||
|
set_active_configuration(src.host, src.port, src.config);
|
||||||
|
apply_source(*c.cfg, src);
|
||||||
chooser_finish(c, true);
|
chooser_finish(c, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row per waveform opened before, newest first. The name first (that is what a person reads),
|
||||||
|
/// the directory under it, and a missing file said out loud rather than left to fail on the click.
|
||||||
|
void chooser_populate_waves(Chooser& c) {
|
||||||
|
while (GtkWidget* child = gtk_widget_get_first_child(c.wave_list)) {
|
||||||
|
gtk_list_box_remove(GTK_LIST_BOX(c.wave_list), child);
|
||||||
|
}
|
||||||
|
c.waves = recent_waveforms();
|
||||||
|
if (c.waves.empty()) {
|
||||||
|
GtkWidget* empty = gtk_label_new("No waveform opened yet -- use Open\xe2\x80\xa6");
|
||||||
|
gtk_widget_add_css_class(empty, "dim-label");
|
||||||
|
gtk_widget_set_margin_top(empty, 24);
|
||||||
|
gtk_widget_set_margin_bottom(empty, 24);
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), empty);
|
||||||
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_NONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_SINGLE);
|
||||||
|
for (std::size_t i = 0; i < c.waves.size(); ++i) {
|
||||||
|
const std::string& path = c.waves[i];
|
||||||
|
GtkWidget* row = gtk_list_box_row_new();
|
||||||
|
g_object_set_data(G_OBJECT(row), "wave-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||||
|
|
||||||
|
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
||||||
|
gtk_widget_set_margin_start(line, 12);
|
||||||
|
gtk_widget_set_margin_end(line, 6);
|
||||||
|
gtk_widget_set_margin_top(line, 6);
|
||||||
|
gtk_widget_set_margin_bottom(line, 6);
|
||||||
|
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||||
|
gtk_widget_set_hexpand(text, TRUE);
|
||||||
|
const std::size_t slash = path.rfind('/');
|
||||||
|
const std::string base = slash == std::string::npos ? path : path.substr(slash + 1);
|
||||||
|
GtkWidget* title = gtk_label_new(base.c_str());
|
||||||
|
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(text), title);
|
||||||
|
const bool present = ::access(path.c_str(), R_OK) == 0;
|
||||||
|
std::string sub = present ? path : path + " (missing)";
|
||||||
|
GtkWidget* under = gtk_label_new(sub.c_str());
|
||||||
|
gtk_widget_add_css_class(under, "dim-label");
|
||||||
|
gtk_widget_set_halign(under, GTK_ALIGN_START);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(under), PANGO_ELLIPSIZE_MIDDLE);
|
||||||
|
gtk_box_append(GTK_BOX(text), under);
|
||||||
|
gtk_box_append(GTK_BOX(line), text);
|
||||||
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_wave_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
if (row == nullptr) return;
|
||||||
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "wave-index"));
|
||||||
|
if (idx < 0 || static_cast<std::size_t>(idx) >= c.waves.size()) return;
|
||||||
|
const std::string& path = c.waves[static_cast<std::size_t>(idx)];
|
||||||
|
if (::access(path.c_str(), R_OK) != 0) {
|
||||||
|
std::fprintf(stderr, "[sources] waveform is gone: %s\n", path.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The file *is* the source: the chooser's answer is a path, not a socket.
|
||||||
|
c.cfg->open_waveform = path;
|
||||||
|
report_pick(c);
|
||||||
|
chooser_finish(c, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_wave_open_done(GObject* source, GAsyncResult* result, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
GFile* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), result, nullptr);
|
||||||
|
if (file == nullptr) return; // cancelled
|
||||||
|
char* path = g_file_get_path(file);
|
||||||
|
g_object_unref(file);
|
||||||
|
if (path != nullptr) {
|
||||||
|
c.cfg->open_waveform = path;
|
||||||
|
g_free(path);
|
||||||
|
report_pick(c);
|
||||||
|
chooser_finish(c, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_wave_open_clicked(GtkButton*, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
GtkFileDialog* dialog = gtk_file_dialog_new();
|
||||||
|
gtk_file_dialog_set_title(dialog, "Open an exported waveform");
|
||||||
|
GtkFileFilter* filter = gtk_file_filter_new();
|
||||||
|
gtk_file_filter_set_name(filter, "NumPy export (*.npy)");
|
||||||
|
gtk_file_filter_add_pattern(filter, "*.npy");
|
||||||
|
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
|
||||||
|
g_list_store_append(filters, filter);
|
||||||
|
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
|
||||||
|
gtk_file_dialog_open(dialog, GTK_WINDOW(c.window), nullptr, on_wave_open_done, &c);
|
||||||
|
g_object_unref(filters);
|
||||||
|
g_object_unref(filter);
|
||||||
|
g_object_unref(dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_edit_clicked(GtkButton* button, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
||||||
|
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||||
|
open_form(c, idx);
|
||||||
|
}
|
||||||
|
|
||||||
void on_forget_clicked(GtkButton* button, gpointer user) {
|
void on_forget_clicked(GtkButton* button, gpointer user) {
|
||||||
Chooser& c = *static_cast<Chooser*>(user);
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
||||||
@@ -145,13 +325,29 @@ void chooser_populate(Chooser& c) {
|
|||||||
// The *server*, even for a multicast session: that is the host the file is named after and
|
// The *server*, even for a multicast session: that is the host the file is named after and
|
||||||
// the one CONFIG is fetched from. Titling the row by the group would name it after something
|
// the one CONFIG is fetched from. Titling the row by the group would name it after something
|
||||||
// that is not its identity.
|
// that is not its identity.
|
||||||
const std::string title = s.host + ":" + std::to_string(s.port);
|
const std::string title = s.host + ":" + std::to_string(s.port) + " \u00b7 " + s.config;
|
||||||
GtkWidget* title_label = gtk_label_new(title.c_str());
|
GtkWidget* title_label = gtk_label_new(title.c_str());
|
||||||
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
||||||
gtk_box_append(GTK_BOX(text), title_label);
|
gtk_box_append(GTK_BOX(text), title_label);
|
||||||
|
|
||||||
|
// The note first when there is one: it is what tells two configurations of one source apart,
|
||||||
|
// and a list that shows only counts makes the user open them to find out.
|
||||||
|
if (!s.comment.empty()) {
|
||||||
|
GtkWidget* note = gtk_label_new(s.comment.c_str());
|
||||||
|
gtk_widget_set_halign(note, GTK_ALIGN_START);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(note), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_box_append(GTK_BOX(text), note);
|
||||||
|
}
|
||||||
|
|
||||||
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
|
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
|
||||||
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
|
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
|
||||||
|
if (s.buffer_mb > 0.0) {
|
||||||
|
sub += ", buffer " + std::to_string(static_cast<int>(s.buffer_mb)) + " MB";
|
||||||
|
}
|
||||||
|
if (s.relative_time) sub += ", relative time";
|
||||||
|
if (s.capture_packets > 0) {
|
||||||
|
sub += ", capture " + std::to_string(s.capture_packets) + " packets";
|
||||||
|
}
|
||||||
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
|
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
|
||||||
GtkWidget* sub_label = gtk_label_new(sub.c_str());
|
GtkWidget* sub_label = gtk_label_new(sub.c_str());
|
||||||
gtk_widget_add_css_class(sub_label, "dim-label");
|
gtk_widget_add_css_class(sub_label, "dim-label");
|
||||||
@@ -159,8 +355,18 @@ void chooser_populate(Chooser& c) {
|
|||||||
gtk_box_append(GTK_BOX(text), sub_label);
|
gtk_box_append(GTK_BOX(text), sub_label);
|
||||||
gtk_box_append(GTK_BOX(line), text);
|
gtk_box_append(GTK_BOX(line), text);
|
||||||
|
|
||||||
|
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
|
||||||
|
gtk_widget_set_tooltip_text(edit,
|
||||||
|
"Edit this configuration: its name and note, the buffer size and "
|
||||||
|
"the clock, or the address it points at");
|
||||||
|
gtk_widget_add_css_class(edit, "flat");
|
||||||
|
gtk_widget_set_valign(edit, GTK_ALIGN_CENTER);
|
||||||
|
g_object_set_data(G_OBJECT(edit), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||||
|
g_signal_connect(edit, "clicked", G_CALLBACK(on_edit_clicked), &c);
|
||||||
|
gtk_box_append(GTK_BOX(line), edit);
|
||||||
|
|
||||||
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
||||||
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
|
gtk_widget_set_tooltip_text(forget, "Forget this configuration (deletes its saved session)");
|
||||||
gtk_widget_add_css_class(forget, "flat");
|
gtk_widget_add_css_class(forget, "flat");
|
||||||
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
||||||
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||||
@@ -173,10 +379,9 @@ void chooser_populate(Chooser& c) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void form_close(Chooser& c) {
|
void form_close(Chooser& c) {
|
||||||
if (c.form == nullptr) return;
|
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how
|
||||||
GtkWidget* w = c.form;
|
// an `AdwDialog` closes.
|
||||||
c.form = nullptr; // before the close, so the handler cannot re-enter
|
if (c.form != nullptr) adw_dialog_close(c.form);
|
||||||
gtk_window_close(GTK_WINDOW(w));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||||||
@@ -185,97 +390,178 @@ void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
|||||||
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
||||||
// already does, and the starvation notice covers a source that takes the socket and says
|
// already does, and the starvation notice covers a source that takes the socket and says
|
||||||
// nothing -- so checking it twice would be two expressions of one rule.
|
// nothing -- so checking it twice would be two expressions of one rule.
|
||||||
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
|
const char* text = gtk_editable_get_text(entry);
|
||||||
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
|
gtk_widget_set_sensitive(c.form_connect, text != nullptr && text[0] != '\0');
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_form_connect(GtkButton*, gpointer user) {
|
void on_form_connect(GtkButton*, gpointer user) {
|
||||||
Chooser& c = *static_cast<Chooser*>(user);
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
||||||
if (server == nullptr || server[0] == '\0') return;
|
if (server == nullptr || server[0] == '\0') return;
|
||||||
c.cfg->server = server;
|
const std::string host = server;
|
||||||
c.cfg->port = static_cast<std::uint16_t>(
|
const std::uint16_t port = static_cast<std::uint16_t>(adw_spin_row_get_value(c.form_port));
|
||||||
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
|
const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
||||||
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
||||||
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
|
||||||
|
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
|
||||||
|
const double buffer_mb = adw_spin_row_get_value(c.form_buffer);
|
||||||
|
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
|
||||||
|
if (name.empty()) name = "default";
|
||||||
|
|
||||||
|
c.cfg->server = host;
|
||||||
|
c.cfg->port = port;
|
||||||
|
c.cfg->multicast = group;
|
||||||
|
c.cfg->interface_addr = iface;
|
||||||
|
// The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the
|
||||||
|
// only moment a buffer size can be applied at all -- and stored, so the next run gets them back.
|
||||||
|
c.cfg->buffer_mb = buffer_mb;
|
||||||
|
c.cfg->relative_time = relative;
|
||||||
|
|
||||||
|
LayoutState st;
|
||||||
|
if (!c.edit_config.empty()) {
|
||||||
|
// An edit keeps everything the configuration already holds -- plots, calibration, math,
|
||||||
|
// measurements -- and changes only what the form shows.
|
||||||
|
load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st);
|
||||||
|
}
|
||||||
|
st.comment = note;
|
||||||
|
st.buffer_mb = buffer_mb;
|
||||||
|
st.relative_time = relative;
|
||||||
|
st.multicast = group;
|
||||||
|
st.interface_addr = iface;
|
||||||
|
|
||||||
|
const bool editing = !c.edit_config.empty();
|
||||||
|
const bool moved =
|
||||||
|
editing && (c.edit_host != host || c.edit_port != port || c.edit_config != name);
|
||||||
|
if (save_layout(configuration_path(host, port, name), st)) {
|
||||||
|
set_active_configuration(host, port, name);
|
||||||
|
// A renamed configuration, or one re-addressed, is a *move*: the old file has no reason to exist
|
||||||
|
// and leaving it would list the same session twice.
|
||||||
|
if (moved) forget_configuration(c.edit_host, c.edit_port, c.edit_config);
|
||||||
|
c.saved_config = name;
|
||||||
|
c.saved_note = note;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[sources] configuration %s: %s:%u/%s note='%s' buffer=%.6g relative=%d\n",
|
||||||
|
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
|
||||||
|
buffer_mb, relative ? 1 : 0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
} else {
|
||||||
|
std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port,
|
||||||
|
name.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
form_close(c);
|
form_close(c);
|
||||||
chooser_finish(c, true);
|
chooser_finish(c, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A labelled row for the form's grid.
|
/// Opens the connection form: on a new host, or on an existing configuration (`edit_index`). One
|
||||||
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
|
/// form for both, because the fields are the same and a configuration *is* a host plus its
|
||||||
GtkWidget* l = gtk_label_new(label);
|
/// settings -- the only difference is where the values start and what the button is called.
|
||||||
gtk_widget_set_halign(l, GTK_ALIGN_END);
|
void open_form(Chooser& c, int edit_index = -1) {
|
||||||
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
|
|
||||||
gtk_widget_set_hexpand(widget, TRUE);
|
|
||||||
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
|
|
||||||
return widget;
|
|
||||||
}
|
|
||||||
|
|
||||||
void open_form(Chooser& c) {
|
|
||||||
if (c.form != nullptr) {
|
if (c.form != nullptr) {
|
||||||
gtk_window_present(GTK_WINDOW(c.form));
|
gtk_window_present(GTK_WINDOW(c.form));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
c.form = gtk_window_new();
|
const KnownSource* src =
|
||||||
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
|
edit_index >= 0 && static_cast<std::size_t>(edit_index) < c.known.size()
|
||||||
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
|
? &c.known[static_cast<std::size_t>(edit_index)]
|
||||||
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
|
: nullptr;
|
||||||
|
c.edit_host = src != nullptr ? src->host : std::string();
|
||||||
|
c.edit_port = src != nullptr ? src->port : 0;
|
||||||
|
c.edit_config = src != nullptr ? src->config : std::string();
|
||||||
|
|
||||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
// A builder per opening, as with every other `.ui` here: the dialog belongs to its object list and
|
||||||
gtk_widget_set_margin_start(box, 12);
|
// `closed` destroys it, so the resource is parsed again rather than a dead dialog fetched.
|
||||||
gtk_widget_set_margin_end(box, 12);
|
g_clear_object(&c.form_ui);
|
||||||
gtk_widget_set_margin_top(box, 12);
|
c.form_ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-form.ui");
|
||||||
gtk_widget_set_margin_bottom(box, 12);
|
if (c.form_ui == nullptr) return;
|
||||||
gtk_window_set_child(GTK_WINDOW(c.form), box);
|
GObject* dialog = gtk_builder_get_object(c.form_ui, "source_form");
|
||||||
|
c.form_server = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "server_row"));
|
||||||
|
c.form_port = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "port_row"));
|
||||||
|
c.form_group = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "group_row"));
|
||||||
|
c.form_iface = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "iface_row"));
|
||||||
|
c.form_name = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "name_row"));
|
||||||
|
c.form_note = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "note_row"));
|
||||||
|
c.form_buffer = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "buffer_row"));
|
||||||
|
c.form_relative = ADW_SWITCH_ROW(gtk_builder_get_object(c.form_ui, "form_relative_row"));
|
||||||
|
c.form_title = ADW_WINDOW_TITLE(gtk_builder_get_object(c.form_ui, "form_title"));
|
||||||
|
c.form_connect = GTK_WIDGET(gtk_builder_get_object(c.form_ui, "form_apply"));
|
||||||
|
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every read below would
|
||||||
|
// be a null row.
|
||||||
|
if (dialog == nullptr || c.form_server == nullptr || c.form_port == nullptr ||
|
||||||
|
c.form_group == nullptr || c.form_iface == nullptr || c.form_name == nullptr ||
|
||||||
|
c.form_note == nullptr || c.form_buffer == nullptr || c.form_relative == nullptr ||
|
||||||
|
c.form_connect == nullptr) {
|
||||||
|
g_clear_object(&c.form_ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.form = ADW_DIALOG(dialog);
|
||||||
|
|
||||||
GtkWidget* grid = gtk_grid_new();
|
// Which mode the form is in, said in the header bar: the title names it and the action's label says
|
||||||
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
|
// what pressing it does.
|
||||||
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
|
const bool editing = src != nullptr;
|
||||||
gtk_box_append(GTK_BOX(box), grid);
|
if (c.form_title != nullptr) {
|
||||||
|
adw_window_title_set_title(c.form_title, editing ? "Edit configuration" : "New host");
|
||||||
|
}
|
||||||
|
gtk_button_set_label(GTK_BUTTON(c.form_connect), editing ? "Save" : "Connect");
|
||||||
|
|
||||||
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
|
// The values: the configuration being edited, or what the command line asked for. The hints live in
|
||||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
|
// the rows' subtitles, so an empty field still says what empty means.
|
||||||
// A spin button holds no decimals by default, which is right for a port and wrong for every
|
gtk_editable_set_text(GTK_EDITABLE(c.form_server), src != nullptr ? src->host.c_str() : "127.0.0.1");
|
||||||
// other numeric field in this project -- see the math dialog's fields.
|
adw_spin_row_set_value(c.form_port, src != nullptr ? src->port : 8000);
|
||||||
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
|
gtk_editable_set_text(GTK_EDITABLE(c.form_group), src != nullptr ? src->multicast.c_str() : "");
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
|
gtk_editable_set_text(GTK_EDITABLE(c.form_iface),
|
||||||
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
|
src != nullptr ? src->interface_addr.c_str() : "");
|
||||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
|
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
|
||||||
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
|
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
|
||||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
|
adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0
|
||||||
|
? src->buffer_mb
|
||||||
|
: std::max(1.0, c.cfg->buffer_mb));
|
||||||
|
adw_switch_row_set_active(c.form_relative,
|
||||||
|
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
|
||||||
|
: FALSE);
|
||||||
|
|
||||||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
if (GObject* cancel = gtk_builder_get_object(c.form_ui, "form_cancel"); cancel != nullptr) {
|
||||||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
|
||||||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
|
||||||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
form_close(*static_cast<Chooser*>(user));
|
form_close(*static_cast<Chooser*>(user));
|
||||||
}),
|
}),
|
||||||
&c);
|
&c);
|
||||||
c.form_connect = gtk_button_new_with_label("Connect");
|
}
|
||||||
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
|
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
|
||||||
gtk_box_append(GTK_BOX(actions), cancel);
|
|
||||||
gtk_box_append(GTK_BOX(actions), c.form_connect);
|
|
||||||
gtk_box_append(GTK_BOX(box), actions);
|
|
||||||
|
|
||||||
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
|
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
|
||||||
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
|
// `closed` clears the pointer. An `AdwDialog` emits that and not `close-request`, and it fires only
|
||||||
// not, which is how the math dialog once kept presenting a finalized window.
|
// when the dialog really went away -- which is what the comment here used to be about.
|
||||||
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
|
g_signal_connect(c.form, "closed", G_CALLBACK(+[](AdwDialog*, gpointer user) {
|
||||||
static_cast<Chooser*>(user)->form = nullptr;
|
static_cast<Chooser*>(user)->form = nullptr;
|
||||||
return FALSE;
|
|
||||||
}),
|
}),
|
||||||
&c);
|
&c);
|
||||||
|
|
||||||
gtk_window_present(GTK_WINDOW(c.form));
|
adw_dialog_present(c.form, GTK_WIDGET(c.window));
|
||||||
gtk_widget_grab_focus(c.form_server);
|
gtk_widget_grab_focus(GTK_WIDGET(c.form_server));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the pick probes report: the connection the chooser came back with. One helper rather than
|
/// What the pick probes report: the connection the chooser came back with. One helper rather than
|
||||||
/// the same four lines in each branch, since both probes assert against this exact line.
|
/// the same four lines in each branch, since both probes assert against this exact line.
|
||||||
void report_pick(const Chooser& c) {
|
void report_pick(const Chooser& c) {
|
||||||
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(),
|
if (!c.cfg->open_waveform.empty()) {
|
||||||
c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str());
|
std::fprintf(stderr, "[sources] picked waveform %s\n", c.cfg->open_waveform.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The settings the configuration carried, not just the address: a pick that dropped the buffer
|
||||||
|
// size would look identical here otherwise, and the buffer is the one of them a user notices
|
||||||
|
// only after a long capture has been sized wrong.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[sources] picked %s:%u multicast=%s interface=%s buffer=%.6g relative=%d "
|
||||||
|
"capture=%u config=%s note='%s'\n",
|
||||||
|
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
|
||||||
|
c.cfg->interface_addr.c_str(), c.cfg->buffer_mb, c.cfg->relative_time ? 1 : 0,
|
||||||
|
c.cfg->capture_packets,
|
||||||
|
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config)
|
||||||
|
: c.saved_config)
|
||||||
|
.c_str(),
|
||||||
|
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().comment)
|
||||||
|
: c.saved_note)
|
||||||
|
.c_str());
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,10 +579,55 @@ gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifier
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The chooser's way into the preferences -- and the reason the form has no owner: this dialog runs
|
||||||
|
/// before `App` exists. Relative time has to reach the run this chooser is about to start, because
|
||||||
|
/// `main()` plans the buffers only after this returns; the rest is read from the file when that run
|
||||||
|
/// starts, so it is honoured without anything here doing it.
|
||||||
|
void open_chooser_preferences(Chooser& c) {
|
||||||
|
ui_impl::prefs_open(c.prefs, c.window, load_settings(), [&c](const Settings& s, bool written) {
|
||||||
|
// The newest statement of intent there is for this run: it beats the list's own copy and the
|
||||||
|
// stored configuration, and it works in both directions -- turning it on *and* off.
|
||||||
|
c.cfg->relative_time = s.relative_time;
|
||||||
|
c.cfg->relative_time_specified = true;
|
||||||
|
if (!written) {
|
||||||
|
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||||||
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
||||||
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
||||||
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
||||||
|
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
|
||||||
|
/// switcher wired to the stack, and the four buttons the code resolved. `[help] ui=` does the same against
|
||||||
|
/// its tables -- the frame is what the file holds, so the frame is what is read, while the rows a user sees
|
||||||
|
/// are read by the probes that drive them.
|
||||||
|
void report_chooser_ui(const Chooser& c) {
|
||||||
|
std::fprintf(stderr, "[sources] chooser ui:");
|
||||||
|
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
|
||||||
|
w = gtk_widget_get_next_sibling(w)) {
|
||||||
|
// A page is not the child: `AdwViewStackPage` is a `GObject` that *wraps* one, so the stack is what
|
||||||
|
// turns a child back into its page.
|
||||||
|
AdwViewStackPage* page = adw_view_stack_get_page(ADW_VIEW_STACK(c.page_stack), w);
|
||||||
|
if (page == nullptr) continue;
|
||||||
|
std::fprintf(stderr, " page='%s' title='%s' icon=%s", adw_view_stack_page_get_name(page),
|
||||||
|
adw_view_stack_page_get_title(page),
|
||||||
|
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
|
||||||
|
}
|
||||||
|
std::fprintf(stderr,
|
||||||
|
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d]\n",
|
||||||
|
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
|
||||||
|
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
|
||||||
|
ADW_VIEW_STACK(c.page_stack)
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
|
||||||
|
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
gboolean pick_probe(gpointer user) {
|
gboolean pick_probe(gpointer user) {
|
||||||
Chooser& c = *static_cast<Chooser*>(user);
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
|
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
|
||||||
@@ -304,7 +635,49 @@ gboolean pick_probe(gpointer user) {
|
|||||||
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
|
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
|
||||||
c.pick_source = 0;
|
c.pick_source = 0;
|
||||||
|
|
||||||
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row.
|
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
|
||||||
|
report_chooser_ui(c);
|
||||||
|
|
||||||
|
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
|
||||||
|
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
|
||||||
|
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
|
||||||
|
// which is the half of the contract the app's probe cannot see. The window is deliberately left
|
||||||
|
// open: the pick below destroys the chooser with it still up, which is the lifetime at risk.
|
||||||
|
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_PREFS") != nullptr) {
|
||||||
|
open_chooser_preferences(c);
|
||||||
|
std::fprintf(stderr, "[sources] prefs: opened=%d rows=%d\n", c.prefs.dialog != nullptr ? 1 : 0,
|
||||||
|
c.prefs.dir_row != nullptr ? 1 : 0);
|
||||||
|
ui_impl::prefs_probe_change(c.prefs);
|
||||||
|
const Settings back = load_settings();
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[sources] prefs: dir=%s keep=%d rel=%d theme=%s cfg_relative=%d\n",
|
||||||
|
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
|
||||||
|
back.theme.c_str(), c.cfg->relative_time ? 1 : 0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `UDPSCOPE_PROBE_WAVEFORMS=1` is the waveforms page without a display: the recent list, and the
|
||||||
|
// page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=<path>` is the file dialog's
|
||||||
|
// answer without the dialog -- the same field, set the same way.
|
||||||
|
if (g_getenv("UDPSCOPE_PROBE_WAVEFORMS") != nullptr) {
|
||||||
|
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(c.page_stack), "waveforms");
|
||||||
|
std::fprintf(stderr, "[sources] waveforms: %zu recent", c.waves.size());
|
||||||
|
for (const std::string& p : c.waves) {
|
||||||
|
std::fprintf(stderr, " %s(%s)", p.c_str(), ::access(p.c_str(), R_OK) == 0 ? "ok" : "missing");
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, " page=%s\n",
|
||||||
|
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)));
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
if (const char* path = g_getenv("UDPSCOPE_PROBE_WAVE")) {
|
||||||
|
c.cfg->open_waveform = path;
|
||||||
|
report_pick(c);
|
||||||
|
chooser_finish(c, true);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row: the address, and the four
|
||||||
|
// things a host could not be given before -- a name, a note, a buffer size and a clock.
|
||||||
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
|
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
|
||||||
open_form(c);
|
open_form(c);
|
||||||
const std::string s = spec;
|
const std::string s = spec;
|
||||||
@@ -312,15 +685,72 @@ gboolean pick_probe(gpointer user) {
|
|||||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
||||||
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
||||||
if (colon != std::string::npos) {
|
if (colon != std::string::npos) {
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
|
adw_spin_row_set_value(c.form_port, std::atof(s.c_str() + colon + 1));
|
||||||
}
|
}
|
||||||
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
|
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
|
||||||
|
adw_spin_row_set_value(c.form_buffer, 32);
|
||||||
|
adw_switch_row_set_active(c.form_relative, TRUE);
|
||||||
|
// 8/8: the form's row titles are mnemonics (`_Server`, `_Port`, `Multicast _group`, ...). The two
|
||||||
|
// buttons already asked for it; the rows did not, and drew their underscores.
|
||||||
|
std::fprintf(stderr, "[sources] form mnemonics=%d/8\n",
|
||||||
|
(row_underlines(G_OBJECT(c.form_server)) + row_underlines(G_OBJECT(c.form_port)) +
|
||||||
|
row_underlines(G_OBJECT(c.form_group)) + row_underlines(G_OBJECT(c.form_iface)) +
|
||||||
|
row_underlines(G_OBJECT(c.form_name)) + row_underlines(G_OBJECT(c.form_note)) +
|
||||||
|
row_underlines(G_OBJECT(c.form_buffer)) + row_underlines(G_OBJECT(c.form_relative))));
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
|
||||||
|
"connect_sensitive=%d\n",
|
||||||
|
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
|
||||||
|
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
|
||||||
|
adw_spin_row_get_value(c.form_buffer),
|
||||||
|
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
|
||||||
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
||||||
g_signal_emit_by_name(c.form_connect, "clicked");
|
g_signal_emit_by_name(c.form_connect, "clicked");
|
||||||
report_pick(c);
|
report_pick(c);
|
||||||
return G_SOURCE_REMOVE;
|
return G_SOURCE_REMOVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `UDPSCOPE_PROBE_EDITHOST=host:port` opens the existing configuration's form, changes its
|
||||||
|
// settings and saves: the path a user reaches by clicking the row's pencil.
|
||||||
|
if (const char* spec = g_getenv("UDPSCOPE_PROBE_EDITHOST")) {
|
||||||
|
const std::string want = spec;
|
||||||
|
int idx = -1;
|
||||||
|
for (std::size_t i = 0; i < c.known.size(); ++i) {
|
||||||
|
if (c.known[i].host + ":" + std::to_string(c.known[i].port) == want) {
|
||||||
|
idx = static_cast<int>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idx < 0) {
|
||||||
|
std::fprintf(stderr, "[sources] edit: no configuration for '%s'\n", want.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
chooser_finish(c, false);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
open_form(c, idx);
|
||||||
|
// The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts
|
||||||
|
// against, and the button's label says which mode it is in.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n",
|
||||||
|
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
|
||||||
|
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
|
||||||
|
adw_spin_row_get_value(c.form_buffer),
|
||||||
|
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
|
||||||
|
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
|
||||||
|
adw_spin_row_set_value(c.form_buffer, 16);
|
||||||
|
adw_switch_row_set_active(c.form_relative, FALSE);
|
||||||
|
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
|
||||||
|
// survive, or the chooser lists the same session twice.
|
||||||
|
if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) {
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(c.form_name), rename_to);
|
||||||
|
}
|
||||||
|
g_signal_emit_by_name(c.form_connect, "clicked");
|
||||||
|
report_pick(c);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
|
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
|
||||||
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
|
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
|
||||||
// even when the only list child is the empty-state placeholder label.
|
// even when the only list child is the empty-state placeholder label.
|
||||||
@@ -341,6 +771,108 @@ gboolean pick_probe(gpointer user) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void apply_stored_settings(Config& cfg) {
|
||||||
|
const std::string want = active_configuration(cfg.server, cfg.port);
|
||||||
|
if (want.empty()) return;
|
||||||
|
LayoutState st;
|
||||||
|
if (!load_layout(configuration_path(cfg.server, cfg.port, want), st)) return;
|
||||||
|
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
|
||||||
|
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a
|
||||||
|
// surprise from whoever just made it.
|
||||||
|
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
|
||||||
|
const bool relative = !cfg.relative_time_specified && st.relative_time;
|
||||||
|
if (relative) cfg.relative_time = true;
|
||||||
|
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
|
||||||
|
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
|
||||||
|
st.capture_packets > 0;
|
||||||
|
if (applied) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n",
|
||||||
|
want.c_str(), cfg.buffer_mb, cfg.relative_time ? 1 : 0, cfg.capture_packets);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The loading window and its builder. File scope because its life spans two calls made from two
|
||||||
|
/// translation units -- `main()` raises it, `run_ui()` takes it down once the real window is up --
|
||||||
|
/// and there is exactly one of it: the gap it covers happens once per run.
|
||||||
|
GtkWidget* g_loading_window = nullptr;
|
||||||
|
GtkBuilder* g_loading_ui = nullptr;
|
||||||
|
|
||||||
|
/// Runs the default main context for `ms`, the chooser's nested-loop idiom again. Pumping pending
|
||||||
|
/// events with `g_main_context_iteration()` is not enough: a window needs a frame-clock cycle to be
|
||||||
|
/// mapped and drawn, and the frame clock only ticks while a loop runs.
|
||||||
|
void pump_main_context(unsigned ms) {
|
||||||
|
GMainLoop* loop = g_main_loop_new(nullptr, FALSE);
|
||||||
|
g_timeout_add(
|
||||||
|
ms,
|
||||||
|
+[](gpointer user) {
|
||||||
|
g_main_loop_quit(static_cast<GMainLoop*>(user));
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
},
|
||||||
|
loop);
|
||||||
|
g_main_loop_run(loop);
|
||||||
|
g_main_loop_unref(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void loading_show(const Config& cfg) {
|
||||||
|
if (g_loading_window != nullptr) return;
|
||||||
|
g_loading_ui = gtk_builder_new_from_resource("/org/udpscope/ui/loading.ui");
|
||||||
|
g_loading_window = GTK_WIDGET(gtk_builder_get_object(g_loading_ui, "loading_window"));
|
||||||
|
GtkWidget* title = GTK_WIDGET(gtk_builder_get_object(g_loading_ui, "loading_title"));
|
||||||
|
GtkWidget* detail = GTK_WIDGET(gtk_builder_get_object(g_loading_ui, "loading_detail"));
|
||||||
|
if (g_loading_window == nullptr || title == nullptr || detail == nullptr) {
|
||||||
|
std::fprintf(stderr, "[sources] loading ui: loading.ui is missing an id this code reads\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
g_loading_window = nullptr;
|
||||||
|
g_clear_object(&g_loading_ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wording names the *wait*, not the destination: "connecting" with nothing else said is what
|
||||||
|
// this window was accused of being, and the buffer size is the number that explains the ten
|
||||||
|
// seconds.
|
||||||
|
std::string head;
|
||||||
|
std::string sub;
|
||||||
|
if (cfg.open_waveform.empty()) {
|
||||||
|
head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port);
|
||||||
|
if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")";
|
||||||
|
char buf[128];
|
||||||
|
std::snprintf(buf, sizeof(buf), "planning the %.0f MB sample buffer", cfg.buffer_mb);
|
||||||
|
sub = buf;
|
||||||
|
} else {
|
||||||
|
const std::size_t slash = cfg.open_waveform.rfind('/');
|
||||||
|
head = "Opening " + (slash == std::string::npos ? cfg.open_waveform
|
||||||
|
: cfg.open_waveform.substr(slash + 1));
|
||||||
|
sub = "reading the recording";
|
||||||
|
}
|
||||||
|
gtk_label_set_text(GTK_LABEL(title), head.c_str());
|
||||||
|
gtk_label_set_text(GTK_LABEL(detail), sub.c_str());
|
||||||
|
gtk_window_present(GTK_WINDOW(g_loading_window));
|
||||||
|
// Painted *here*, before the caller blocks. Everything this window covers -- the buffer
|
||||||
|
// allocation, the first CONFIG, a recording read -- runs on a thread that never returns to a main
|
||||||
|
// loop, so a window presented and not yet drawn would be an empty rectangle for the whole wait,
|
||||||
|
// which is the defect this is meant to fix rather than restate.
|
||||||
|
pump_main_context(180);
|
||||||
|
std::fprintf(stderr, "[sources] loading: title='%s' detail='%s'\n", head.c_str(), sub.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loading_hide() {
|
||||||
|
if (g_loading_window == nullptr) return;
|
||||||
|
GtkWidget* w = g_loading_window;
|
||||||
|
g_loading_window = nullptr; // before the destroy, as the chooser's own close does
|
||||||
|
gtk_window_destroy(GTK_WINDOW(w));
|
||||||
|
// The window belonged to the builder's object list, so the builder goes after the destroy.
|
||||||
|
g_clear_object(&g_loading_ui);
|
||||||
|
std::fprintf(stderr, "[sources] loading: closed\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
bool choose_source(Config& cfg) {
|
bool choose_source(Config& cfg) {
|
||||||
const std::vector<KnownSource> known = known_sources();
|
const std::vector<KnownSource> known = known_sources();
|
||||||
|
|
||||||
@@ -359,45 +891,53 @@ bool choose_source(Config& cfg) {
|
|||||||
|
|
||||||
Chooser c;
|
Chooser c;
|
||||||
c.cfg = &cfg;
|
c.cfg = &cfg;
|
||||||
c.window = gtk_window_new();
|
|
||||||
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
|
|
||||||
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
|
|
||||||
|
|
||||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
// The frame is a `.ui` file; the rows are not, because a source row and a waveform row are data and are
|
||||||
gtk_window_set_child(GTK_WINDOW(c.window), box);
|
// rebuilt whenever the known hosts or the recent files change. `make test-ui` checks the file's types
|
||||||
|
// against the GIR; this checks that the code and the file still agree on the names, which nothing else
|
||||||
|
// can -- a missing id would otherwise open a window with a hole in it, or crash on the first read.
|
||||||
|
c.ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-chooser.ui");
|
||||||
|
c.window = GTK_WIDGET(gtk_builder_get_object(c.ui, "chooser_window"));
|
||||||
|
c.page_stack = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_stack"));
|
||||||
|
c.switcher = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_switcher"));
|
||||||
|
c.list = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_list"));
|
||||||
|
c.wave_list = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_list"));
|
||||||
|
c.prefs_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "prefs_button"));
|
||||||
|
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
|
||||||
|
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
|
||||||
|
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
|
||||||
|
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
|
||||||
|
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
|
||||||
|
c.waves_open == nullptr || c.cancel_button == nullptr) {
|
||||||
|
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
|
||||||
|
|
||||||
GtkWidget* scroller = gtk_scrolled_window_new();
|
|
||||||
gtk_widget_set_vexpand(scroller, TRUE);
|
|
||||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
|
|
||||||
GTK_POLICY_AUTOMATIC);
|
|
||||||
c.list = gtk_list_box_new();
|
|
||||||
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
|
|
||||||
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
||||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
|
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
|
||||||
gtk_box_append(GTK_BOX(box), scroller);
|
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
|
||||||
|
g_signal_connect(c.new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
||||||
gtk_widget_set_margin_start(actions, 12);
|
|
||||||
gtk_widget_set_margin_end(actions, 12);
|
|
||||||
gtk_widget_set_margin_top(actions, 12);
|
|
||||||
gtk_widget_set_margin_bottom(actions, 12);
|
|
||||||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
|
||||||
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
|
|
||||||
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
|
||||||
open_form(*static_cast<Chooser*>(user));
|
open_form(*static_cast<Chooser*>(user));
|
||||||
}),
|
}),
|
||||||
&c);
|
&c);
|
||||||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
g_signal_connect(c.cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
|
||||||
chooser_finish(*static_cast<Chooser*>(user), false);
|
chooser_finish(*static_cast<Chooser*>(user), false);
|
||||||
}),
|
}),
|
||||||
&c);
|
&c);
|
||||||
gtk_box_append(GTK_BOX(actions), new_host);
|
// The preferences are reachable from here as well as from the main window's menu: this dialog is the
|
||||||
gtk_box_append(GTK_BOX(actions), cancel);
|
// only window that exists before anything is planned, so it is where a user still has the chance to say
|
||||||
gtk_box_append(GTK_BOX(box), actions);
|
// how this run should behave.
|
||||||
|
g_signal_connect(c.prefs_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
open_chooser_preferences(*static_cast<Chooser*>(user));
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
|
||||||
chooser_populate(c);
|
chooser_populate(c);
|
||||||
if (c.known.empty()) gtk_widget_grab_focus(new_host);
|
// The other page's rows too: fills the sources, and this is the pair of it.
|
||||||
|
chooser_populate_waves(c);
|
||||||
|
if (c.known.empty()) gtk_widget_grab_focus(c.new_host);
|
||||||
|
|
||||||
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
|
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
|
||||||
GtkEventController* keys = gtk_event_controller_key_new();
|
GtkEventController* keys = gtk_event_controller_key_new();
|
||||||
|
|||||||
@@ -16,4 +16,27 @@ namespace udpscope {
|
|||||||
/// same process, which works because `gtk_init()` is idempotent.
|
/// same process, which works because `gtk_init()` is idempotent.
|
||||||
bool choose_source(Config& cfg);
|
bool choose_source(Config& cfg);
|
||||||
|
|
||||||
|
/// Fills in the settings a *stored* configuration carries for `cfg`'s source -- the buffer size,
|
||||||
|
/// relative time and the packet capture's ring size -- leaving alone anything the command line
|
||||||
|
/// actually asked for.
|
||||||
|
///
|
||||||
|
/// The chooser applies these when it opens a source; a source named with `--server` never reaches
|
||||||
|
/// the chooser, so without this a saved host's buffer size and clock were silently ignored and the
|
||||||
|
/// edit that set them read as one that never took effect. The layout is *not* applied here: it
|
||||||
|
/// names signals, so it is loaded by the app once a CONFIG has described them.
|
||||||
|
void apply_stored_settings(Config& cfg);
|
||||||
|
|
||||||
|
/// Puts a small window on screen saying what `cfg` is about to be opened as, and paints it before
|
||||||
|
/// returning. Call it after the chooser has answered and *before* the buffer is planned or a
|
||||||
|
/// recording is read: both of those run with no main loop, so nothing drawn later than this would
|
||||||
|
/// appear until they are over.
|
||||||
|
///
|
||||||
|
/// Returns having pumped the main context long enough for the window to be mapped and drawn. The
|
||||||
|
/// caller owes a `loading_hide()`, which `run_ui()` does once the main window is presented.
|
||||||
|
void loading_show(const Config& cfg);
|
||||||
|
|
||||||
|
/// Takes the loading window down. A no-op when none is up, so the paths that never show one -- a
|
||||||
|
/// source named with `--server` -- need no notion of it.
|
||||||
|
void loading_hide();
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -79,6 +79,21 @@ std::string format_detail(const StatusSnapshot& s) {
|
|||||||
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
||||||
static_cast<unsigned long long>(s.time_stride));
|
static_cast<unsigned long long>(s.time_stride));
|
||||||
std::string out = buf;
|
std::string out = buf;
|
||||||
|
// Nothing about resequencing is said at all while all six read zero, which is a stream that
|
||||||
|
// never lost ordering: the field would otherwise be six zeros on every clean run, and this
|
||||||
|
// string is read by eye out of the exit dump.
|
||||||
|
if (s.resequenced || s.late || s.stale || s.holes || s.hole_slots || s.backfilled) {
|
||||||
|
char note[160];
|
||||||
|
std::snprintf(note, sizeof(note),
|
||||||
|
" | reseq %llu late %llu stale %llu holes %llu (%llu slots, %llu filled)",
|
||||||
|
static_cast<unsigned long long>(s.resequenced),
|
||||||
|
static_cast<unsigned long long>(s.late),
|
||||||
|
static_cast<unsigned long long>(s.stale),
|
||||||
|
static_cast<unsigned long long>(s.holes),
|
||||||
|
static_cast<unsigned long long>(s.hole_slots),
|
||||||
|
static_cast<unsigned long long>(s.backfilled));
|
||||||
|
out += note;
|
||||||
|
}
|
||||||
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
||||||
if (s.trigger_on) {
|
if (s.trigger_on) {
|
||||||
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ struct StatusSnapshot {
|
|||||||
/// the plot.
|
/// the plot.
|
||||||
bool paused = false;
|
bool paused = false;
|
||||||
double paused_for_s = 0.0;
|
double paused_for_s = 0.0;
|
||||||
|
|
||||||
|
/// The resequencing counters, gathered with `packets`/`frames`/`gaps` so the detail tier and
|
||||||
|
/// the dump quote one consistent set. All six zero means the stream never lost ordering, and
|
||||||
|
/// the detail tier then says nothing about it at all.
|
||||||
|
std::uint64_t resequenced = 0;
|
||||||
|
std::uint64_t late = 0;
|
||||||
|
std::uint64_t stale = 0;
|
||||||
|
std::uint64_t holes = 0;
|
||||||
|
std::uint64_t hole_slots = 0;
|
||||||
|
std::uint64_t backfilled = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
||||||
|
|||||||
+33
-36
@@ -4,6 +4,7 @@
|
|||||||
#include "trigger.hpp"
|
#include "trigger.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
@@ -12,15 +13,19 @@ double TriggerConfig::post_span_s(double buffer_span_s) const {
|
|||||||
return buffer_span_s * (1.0 - pre);
|
return buffer_span_s * (1.0 - pre);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TriggerEngine::configure(const TriggerConfig& cfg, double post_seconds,
|
void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name,
|
||||||
double pre_seconds, double oldest_s) {
|
double post_seconds, double pre_seconds, double oldest_s,
|
||||||
const bool channel_changed = cfg.channel != cfg_.channel;
|
double dt) {
|
||||||
|
const bool channel_changed = channel_name != channel_name_;
|
||||||
|
if (channel_changed) channel_name_ = channel_name;
|
||||||
cfg_ = cfg;
|
cfg_ = cfg;
|
||||||
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
|
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
|
||||||
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
|
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
|
||||||
oldest_s_ = oldest_s;
|
oldest_s_ = oldest_s;
|
||||||
|
// A no-op unless the condition actually changed: this runs on every packet.
|
||||||
|
detector_.configure(cfg_.condition, dt);
|
||||||
if (channel_changed) {
|
if (channel_changed) {
|
||||||
have_prev_ = false;
|
detector_.reset();
|
||||||
pending_ = false;
|
pending_ = false;
|
||||||
post_accum_count_ = 0;
|
post_accum_count_ = 0;
|
||||||
}
|
}
|
||||||
@@ -31,23 +36,21 @@ void TriggerEngine::arm(double now_s) noexcept {
|
|||||||
pending_ = false;
|
pending_ = false;
|
||||||
post_accum_count_ = 0.0;
|
post_accum_count_ = 0.0;
|
||||||
holdoff_until_s_ = now_s;
|
holdoff_until_s_ = now_s;
|
||||||
have_prev_ = false;
|
detector_.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool TriggerEngine::process(double value, double now_s) noexcept {
|
bool TriggerEngine::process(double value, double now_s) noexcept {
|
||||||
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
|
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
|
||||||
|
|
||||||
// Hold-off: settled, waiting for the next edge.
|
// The detector is fed **first and unconditionally**, on every sample the engine sees --
|
||||||
if (!pending_ && now_s < holdoff_until_s_) {
|
// including during the hold-off and during an open capture, where its answer is thrown
|
||||||
prev_ = value;
|
// away. It is a sequential machine: a pulse whose opening edge fell in a skipped stretch
|
||||||
have_prev_ = true;
|
// can never be closed, and the symptom would be a width condition that silently stops
|
||||||
last_t_s_ = now_s;
|
// firing after the first hold-off.
|
||||||
return false;
|
const double event_t = detector_.feed(value, now_s);
|
||||||
}
|
|
||||||
|
|
||||||
if (!have_prev_) {
|
// Hold-off: settled, waiting for the next event.
|
||||||
prev_ = value;
|
if (!pending_ && now_s < holdoff_until_s_) {
|
||||||
have_prev_ = true;
|
|
||||||
last_t_s_ = now_s;
|
last_t_s_ = now_s;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,6 @@ bool TriggerEngine::process(double value, double now_s) noexcept {
|
|||||||
// past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
|
// past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
|
||||||
// so the capture covers exactly the requested span whatever the cadence is.
|
// so the capture covers exactly the requested span whatever the cadence is.
|
||||||
++post_accum_count_;
|
++post_accum_count_;
|
||||||
prev_ = value;
|
|
||||||
if (now_s - pending_start_s_ >= post_seconds_) {
|
if (now_s - pending_start_s_ >= post_seconds_) {
|
||||||
pending_ = false;
|
pending_ = false;
|
||||||
post_accum_count_ = 0;
|
post_accum_count_ = 0;
|
||||||
@@ -73,33 +75,28 @@ bool TriggerEngine::process(double value, double now_s) noexcept {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge detection against the level.
|
if (!std::isfinite(event_t)) return false;
|
||||||
const bool crossed_up = prev_ < cfg_.level && value >= cfg_.level;
|
|
||||||
const bool crossed_down = prev_ > cfg_.level && value <= cfg_.level;
|
|
||||||
const bool fired = (cfg_.edge == TriggerEdge::Rising && crossed_up) ||
|
|
||||||
(cfg_.edge == TriggerEdge::Falling && crossed_down) ||
|
|
||||||
(cfg_.edge == TriggerEdge::Both && (crossed_up || crossed_down));
|
|
||||||
prev_ = value;
|
|
||||||
if (!fired) return false;
|
|
||||||
|
|
||||||
// The pre-trigger window has to already be behind this edge, or the capture opens
|
// The pre-trigger window has to already be behind the event, or the capture opens on a
|
||||||
// on a half-filled pre window and the event is short on its left side -- the plot
|
// half-filled pre window and the event is short on its left side -- the plot then shows a
|
||||||
// then shows a window that is correctly sized but only partly drawn. The buffer
|
// window that is correctly sized but only partly drawn. The buffer fills whether or not the
|
||||||
// fills whether or not the trigger is armed, so this normally passes on the first
|
// trigger is armed, so this normally passes on the first event; it only bites just after a
|
||||||
// edge; it only bites just after a rebuild (a CONFIG, or enabling the signal),
|
// rebuild (a CONFIG, or enabling the signal), when the history genuinely is not there yet.
|
||||||
// when the history genuinely is not there yet.
|
//
|
||||||
if (now_s - oldest_s_ < pre_seconds_) return false;
|
// Measured from the *event's own* time, not from `now_s`: for a qualified condition the two
|
||||||
|
// differ (a pulse is reported at its closing edge), and the pre window belongs to the event.
|
||||||
|
if (event_t - oldest_s_ < pre_seconds_) return false;
|
||||||
|
|
||||||
// The edge's own time, which is what a trigger marker marks: the latch itself
|
// The event's own time, which is what a trigger marker marks: the latch itself may happen
|
||||||
// may happen a post-window later.
|
// a post-window later.
|
||||||
last_trigger_s_ = now_s;
|
last_trigger_s_ = event_t;
|
||||||
|
|
||||||
// The edge always opens a capture; there is no "latch on the edge itself" case.
|
// The event always opens a capture; there is no "latch on the event itself" case.
|
||||||
// A zero-length post window latches on the very next sample, which is the least a
|
// A zero-length post window latches on the very next sample, which is the least a
|
||||||
// capture can hold, and every longer window is just more samples -- no conversion
|
// capture can hold, and every longer window is just more samples -- no conversion
|
||||||
// from seconds to a sample count, so no conversion to get wrong.
|
// from seconds to a sample count, so no conversion to get wrong.
|
||||||
pending_ = true;
|
pending_ = true;
|
||||||
pending_start_s_ = now_s;
|
pending_start_s_ = event_t;
|
||||||
post_accum_count_ = 0;
|
post_accum_count_ = 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-12
@@ -4,12 +4,12 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "condition.hpp"
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
/// Which way the trigger signal crosses the level.
|
|
||||||
enum class TriggerEdge { Rising, Falling, Both };
|
|
||||||
|
|
||||||
/// How an armed trigger re-arms after it fired.
|
/// How an armed trigger re-arms after it fired.
|
||||||
enum class TriggerMode { Normal, Single };
|
enum class TriggerMode { Normal, Single };
|
||||||
|
|
||||||
@@ -23,8 +23,10 @@ struct TriggerConfig {
|
|||||||
bool enabled = false; ///< trigger mode on/off (toolbar).
|
bool enabled = false; ///< trigger mode on/off (toolbar).
|
||||||
bool armed = false; ///< arm/disarm button.
|
bool armed = false; ///< arm/disarm button.
|
||||||
int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
|
int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
|
||||||
double level = 0.0;
|
/// What counts as an event on that channel: the level and edge, and for the qualified
|
||||||
TriggerEdge edge = TriggerEdge::Rising;
|
/// shapes the width, band or timeout as well. The engine does not interpret it -- it
|
||||||
|
/// hands it to an `EventDetector`.
|
||||||
|
EventCondition condition{};
|
||||||
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
|
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
|
||||||
double holdoff_s = 0.010; ///< default from the spec.
|
double holdoff_s = 0.010; ///< default from the spec.
|
||||||
TriggerMode mode = TriggerMode::Normal;
|
TriggerMode mode = TriggerMode::Normal;
|
||||||
@@ -39,7 +41,16 @@ struct TriggerConfig {
|
|||||||
class TriggerEngine {
|
class TriggerEngine {
|
||||||
public:
|
public:
|
||||||
/// Configures the channel and the post-trigger window. Resets the state when the
|
/// Configures the channel and the post-trigger window. Resets the state when the
|
||||||
/// channel changes.
|
/// trigger moves to a different channel.
|
||||||
|
///
|
||||||
|
/// `channel_name` is that channel's name, and it -- not `cfg.channel` -- is what
|
||||||
|
/// identifies it here. The index is not stable: every rebuild of the channel list
|
||||||
|
/// renumbers it (enabling a signal that sorts before the trigger's own is enough),
|
||||||
|
/// and the model re-resolves the trigger by name afterwards. Keying the reset on the
|
||||||
|
/// index therefore made any such rebuild abandon an open capture *and set no
|
||||||
|
/// hold-off*, so the next edge inside the post window became t0 -- which is the
|
||||||
|
/// "re-triggers mid-capture and ignores the hold-off" defect. Choosing a genuinely
|
||||||
|
/// different source still resets, because the name changes with it.
|
||||||
///
|
///
|
||||||
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
|
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
|
||||||
/// `process()` is fed and the same clock the x axis reads, so the capture covers
|
/// `process()` is fed and the same clock the x axis reads, so the capture covers
|
||||||
@@ -57,8 +68,9 @@ class TriggerEngine {
|
|||||||
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
|
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
|
||||||
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
|
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
|
||||||
/// off, so the history is usually already there and waiting would be wrong.
|
/// off, so the history is usually already there and waiting would be wrong.
|
||||||
void configure(const TriggerConfig& cfg, double post_seconds, double pre_seconds,
|
void configure(const TriggerConfig& cfg, const std::string& channel_name,
|
||||||
double oldest_s);
|
double post_seconds, double pre_seconds, double oldest_s,
|
||||||
|
double dt);
|
||||||
/// Disarms without forgetting the configuration.
|
/// Disarms without forgetting the configuration.
|
||||||
void disarm() noexcept { armed_ = false; pending_ = false; }
|
void disarm() noexcept { armed_ = false; pending_ = false; }
|
||||||
void arm(double now_s) noexcept;
|
void arm(double now_s) noexcept;
|
||||||
@@ -82,8 +94,8 @@ class TriggerEngine {
|
|||||||
/// pre-trigger window an edge needs before it may open a capture.
|
/// pre-trigger window an edge needs before it may open a capture.
|
||||||
double history() const noexcept { return last_t_s_ - oldest_s_; }
|
double history() const noexcept { return last_t_s_ - oldest_s_; }
|
||||||
double pre_seconds() const noexcept { return pre_seconds_; }
|
double pre_seconds() const noexcept { return pre_seconds_; }
|
||||||
double last_value() const noexcept { return prev_; }
|
double last_value() const noexcept { return detector_.last_value(); }
|
||||||
bool have_last() const noexcept { return have_prev_; }
|
bool have_last() const noexcept { return detector_.have_last(); }
|
||||||
double last_t() const noexcept { return last_t_s_; }
|
double last_t() const noexcept { return last_t_s_; }
|
||||||
double holdoff_until() const noexcept { return holdoff_until_s_; }
|
double holdoff_until() const noexcept { return holdoff_until_s_; }
|
||||||
|
|
||||||
@@ -95,9 +107,11 @@ class TriggerEngine {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
TriggerConfig cfg_{};
|
TriggerConfig cfg_{};
|
||||||
|
std::string channel_name_; ///< the trigger channel's stable identity (see `configure`).
|
||||||
bool armed_ = false;
|
bool armed_ = false;
|
||||||
bool have_prev_ = false;
|
/// What an event *is*. The engine owns when one may open a capture; this owns whether
|
||||||
double prev_ = 0.0;
|
/// the sample is one at all.
|
||||||
|
EventDetector detector_{};
|
||||||
bool pending_ = false; ///< an edge was seen; collecting the post window
|
bool pending_ = false; ///< an edge was seen; collecting the post window
|
||||||
double pending_start_s_ = 0.0;
|
double pending_start_s_ = 0.0;
|
||||||
double holdoff_until_s_ = 0.0;
|
double holdoff_until_s_ = 0.0;
|
||||||
|
|||||||
+1898
-5865
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The cursors panel: a header with the mode's dropdown and the reset button, and the grid the two
|
||||||
|
cursor times are read in, one line per row of the figure.
|
||||||
|
|
||||||
|
The grid's *contents* are not here: they follow the cursor mode and the rows, and they are rebuilt
|
||||||
|
whenever either changes (`rebuild_cursors_pane()`). What is here is the panel's shape, the dropdown
|
||||||
|
and the button, and the grid they sit above.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkBox" id="cursors_panel">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<property name="margin-top">6</property>
|
||||||
|
<property name="margin-bottom">6</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<property name="margin-end">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="head">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="title">
|
||||||
|
<property name="label" translatable="yes">Cursors</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkDropDown" id="mode_drop"/>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="reset">
|
||||||
|
<property name="icon-name">edit-clear-symbolic</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="css-classes">flat</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkGrid" id="body">
|
||||||
|
<property name="row-spacing">2</property>
|
||||||
|
<property name="column-spacing">4</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The events pane: the revealer that slides it in, the header with the way back to the live stream, the two
|
||||||
|
actions that read the selection, and the list inside its scroller.
|
||||||
|
|
||||||
|
The rows are not here. There is one per latched event in the cache, it carries the time, the duration, the
|
||||||
|
signals and the rate, and the whole list is rebuilt whenever the cache changes — so the file holds the
|
||||||
|
frame and the code holds the rows.
|
||||||
|
|
||||||
|
The list is multiple-selection and activates on *double* click, which is a statement about what the pane is
|
||||||
|
for rather than a default: its other job is choosing which events to compare or accumulate, and with
|
||||||
|
single-click activation a plain click — the gesture that builds a selection — would replay the event
|
||||||
|
instead. Both actions start insensitive because a selection starts empty, and `update_group_buttons()`
|
||||||
|
owns them from there.
|
||||||
|
|
||||||
|
A width floor, not a width: the divider sets the pane's width, and this is what it needs to stay usable at
|
||||||
|
its narrowest.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkRevealer" id="events_pane">
|
||||||
|
<property name="transition-type">slide-right</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="events_box">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="width-request">170</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<property name="margin-end">6</property>
|
||||||
|
<property name="margin-top">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="events_head">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="events_title">
|
||||||
|
<property name="label" translatable="yes"><b>Events</b></property>
|
||||||
|
<property name="use-markup">1</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="events_live">
|
||||||
|
<property name="label" translatable="yes">Live</property>
|
||||||
|
<property name="css-classes">flat</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">go back to the live stream after replaying a stored event</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="events_actions">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="events_compare">
|
||||||
|
<property name="label" translatable="yes">Compare</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">the two selected events in two plots side by side, with the axes synchronised</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="sensitive">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="events_accumulate">
|
||||||
|
<property name="label" translatable="yes">Accumulate</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">up to four selected events overlaid in the same traces, translucent, so where they agree the trace is brighter</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="sensitive">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkScrolledWindow" id="events_scroll">
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkListBox" id="events_list">
|
||||||
|
<property name="activate-on-single-click">0</property>
|
||||||
|
<property name="selection-mode">multiple</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The header bar: the time-window control's slot, the trigger, pause and events group with the separator that
|
||||||
|
marks where the *view's* controls end, and the five at the far end.
|
||||||
|
|
||||||
|
The bar carries only the controls you reach for *while watching a signal*; everything you set up once lives
|
||||||
|
in the overflow menu, which is code — its rows act on the `RowSet` directly and two of them are toggles
|
||||||
|
whose state the rest of the interface reads, so a `GMenu` model would only add a second copy of that state.
|
||||||
|
|
||||||
|
The order is the file's, and it is the one thing here that is not self-evident: `pack_end()` lays its
|
||||||
|
children out right to left, so a `<child type="end">` is added leftwards-from-the-right in document order
|
||||||
|
and the screen reads the reverse of it — cursors, measurements, math, sidebar, menu.
|
||||||
|
|
||||||
|
The first start child is a slot and not the control: the time-window control is code (its menu's rows come
|
||||||
|
from the buffer's limits), and `gtk_header_bar_pack_start()` appends *after* whatever a file has already
|
||||||
|
packed, so a control added by code would land to the right of this whole group instead of at its head.
|
||||||
|
|
||||||
|
The events toggle starts hidden: the event history is a *trigger* thing, and asking for it is one click
|
||||||
|
from the mode that produces it.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkHeaderBar" id="header_bar">
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkBox" id="window_control_slot">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkToggleButton" id="trigger_toggle">
|
||||||
|
<property name="icon-name">udpscope-trigger-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">trigger mode: freeze the view and show one captured event at a time</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkToggleButton" id="pause_button">
|
||||||
|
<property name="icon-name">media-playback-pause-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">pause acquisition</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkSeparator">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkToggleButton" id="events_toggle">
|
||||||
|
<property name="icon-name">udpscope-events-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">the event history: the triggered acquisitions kept in the cache, with a picture of each</property>
|
||||||
|
<property name="visible">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkMenuButton" id="menu_button">
|
||||||
|
<property name="icon-name">open-menu-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">view options</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkToggleButton" id="sidebar_toggle">
|
||||||
|
<property name="icon-name">sidebar-show-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">show the side panel</property>
|
||||||
|
<property name="active">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkToggleButton" id="math_toggle">
|
||||||
|
<property name="icon-name">udpscope-math-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">channels derived from the others by a graph of operations: gain, absolute value, filters, sums and the rest. An enabled one is acquired, plotted and measured exactly like a signal</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkToggleButton" id="measures_toggle">
|
||||||
|
<property name="icon-name">udpscope-measure-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">compute a value over the visible window of a signal, and optionally mark it on the plot</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkToggleButton" id="cursors_toggle">
|
||||||
|
<property name="icon-name">udpscope-cursors-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">the cursor instruments: two times, two levels, or the two read off each trace where they cross</property>
|
||||||
|
<property name="active">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The help window's chrome: a window, its own header bar, and a two-pane split whose sidebar is the
|
||||||
|
list of pages and whose content is one page at a time.
|
||||||
|
|
||||||
|
The split is AdwNavigationSplitView rather than a GtkBox with a separator in it, so the sidebar
|
||||||
|
collapses on a narrow window and comes back behind the header bar's button instead of squeezing the
|
||||||
|
text. On a wide one it is the same two panes it always was.
|
||||||
|
|
||||||
|
The page list itself is *not* here: its rows are the fourteen pages in `help_window.cpp`, each with
|
||||||
|
section headings between the groups, and a template cannot know them. What is here is the shape they
|
||||||
|
are put into: an id to find the list by, the scroller around the page, and the label that holds one
|
||||||
|
page's markup with the screenshot column above it.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="AdwWindow" id="help_window">
|
||||||
|
<property name="title" translatable="yes">udpscope help</property>
|
||||||
|
<property name="default-width">860</property>
|
||||||
|
<property name="default-height">640</property>
|
||||||
|
<property name="content">
|
||||||
|
<object class="AdwToolbarView">
|
||||||
|
<child type="top">
|
||||||
|
<object class="AdwHeaderBar">
|
||||||
|
<property name="show-title">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<property name="content">
|
||||||
|
<object class="AdwNavigationSplitView">
|
||||||
|
<property name="sidebar">
|
||||||
|
<object class="AdwNavigationPage">
|
||||||
|
<property name="title" translatable="yes">Pages</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="vscrollbar-policy">automatic</property>
|
||||||
|
<property name="width-request">220</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkListBox" id="page_list">
|
||||||
|
<property name="selection-mode">single</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="content">
|
||||||
|
<object class="AdwNavigationPage">
|
||||||
|
<property name="title" translatable="yes">Page</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="vscrollbar-policy">automatic</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkBox" id="page_box">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="shot_box">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<property name="margin-start">18</property>
|
||||||
|
<property name="margin-end">18</property>
|
||||||
|
<property name="margin-top">14</property>
|
||||||
|
<property name="visible">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="body">
|
||||||
|
<property name="use-markup">1</property>
|
||||||
|
<property name="wrap">1</property>
|
||||||
|
<property name="xalign">0</property>
|
||||||
|
<property name="yalign">0</property>
|
||||||
|
<property name="selectable">1</property>
|
||||||
|
<property name="margin-start">18</property>
|
||||||
|
<property name="margin-end">18</property>
|
||||||
|
<property name="margin-top">14</property>
|
||||||
|
<property name="margin-bottom">14</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
What is on screen between the chooser closing and the main window appearing: the only window that
|
||||||
|
exists while the buffer is planned or a recording is read. Both of those run with no main loop, so
|
||||||
|
this window is painted once and then stays still — which is why it says what is happening in words
|
||||||
|
rather than only spinning, and why there is no progress bar: nothing can move it.
|
||||||
|
|
||||||
|
Deliberately undecorated (`deletable=0`, no title buttons): a close button that cannot be serviced
|
||||||
|
until the work finishes is a lie, and the work is not cancellable once the buffer is being planned.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="AdwWindow" id="loading_window">
|
||||||
|
<property name="title" translatable="yes">udpscope</property>
|
||||||
|
<property name="default-width">420</property>
|
||||||
|
<property name="default-height">220</property>
|
||||||
|
<property name="resizable">0</property>
|
||||||
|
<property name="deletable">0</property>
|
||||||
|
<property name="content">
|
||||||
|
<object class="AdwToolbarView">
|
||||||
|
<child type="top">
|
||||||
|
<object class="AdwHeaderBar">
|
||||||
|
<property name="show-start-title-buttons">0</property>
|
||||||
|
<property name="show-end-title-buttons">0</property>
|
||||||
|
<property name="title-widget">
|
||||||
|
<object class="AdwWindowTitle">
|
||||||
|
<property name="title" translatable="yes">udpscope</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<property name="content">
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">14</property>
|
||||||
|
<property name="halign">center</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="margin-start">24</property>
|
||||||
|
<property name="margin-end">24</property>
|
||||||
|
<property name="margin-top">12</property>
|
||||||
|
<property name="margin-bottom">24</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkSpinner">
|
||||||
|
<property name="spinning">1</property>
|
||||||
|
<property name="width-request">36</property>
|
||||||
|
<property name="height-request">36</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="loading_title">
|
||||||
|
<property name="wrap">1</property>
|
||||||
|
<property name="justify">center</property>
|
||||||
|
<property name="css-classes">title-4</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="loading_detail">
|
||||||
|
<property name="wrap">1</property>
|
||||||
|
<property name="justify">center</property>
|
||||||
|
<property name="css-classes">dim-label</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The main window's skeleton: the window, the split view that puts the sidebar beside the plot, the
|
||||||
|
events pane's divider, the plot column with the status line under it, and the sidebar's scroller.
|
||||||
|
|
||||||
|
The sidebar is an `AdwOverlaySplitView`, which is what a utility sidebar is in GNOME (as against
|
||||||
|
`AdwNavigationSplitView`, which is for navigation lists). Three things follow from that, and they are
|
||||||
|
the reasons it is here:
|
||||||
|
|
||||||
|
- its width is *its own*: `sidebar-width-fraction` in **pixels** (the unit says so), floored by
|
||||||
|
`min-sidebar-width`. A `GtkPaned` kept a child's size in a divider position, which is a number of
|
||||||
|
pixels *of the window* — so on a window narrower than that number the sidebar came up with no
|
||||||
|
width at all, which is the bug this widget replaces;
|
||||||
|
- it resizes by dragging its own edge, within `min-sidebar-width`..`max-sidebar-width`;
|
||||||
|
- when the window is too narrow for both columns it *overlays* the plot instead of squeezing it,
|
||||||
|
which is what `collapsed` is for; `tick()` sets it from the window's width.
|
||||||
|
|
||||||
|
What is *not* here is as deliberate as what is. Everything this file nests is fixed — the shape of
|
||||||
|
the window does not depend on the stream, the trigger or the layout — while the subtrees that do are
|
||||||
|
built by code and put in place: the events pane, into `events_divider`'s start side; the two plot
|
||||||
|
areas, into the split inside `plot_box`; the sidebar's panels, appended to `side_column`; and the
|
||||||
|
status popover, attached to `status_info`.
|
||||||
|
|
||||||
|
The header bar is not here either: it is the window's own titlebar, built by `build_toolbar()`
|
||||||
|
because every button on it is wired to an action. The status line is not a bottom bar — it belongs to
|
||||||
|
the plot column, not to the window, so it stays inside `left_column` where the divider can move past
|
||||||
|
it.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkApplicationWindow" id="main_window">
|
||||||
|
<property name="title">udpscope</property>
|
||||||
|
<property name="icon-name">org.udpscope.app</property>
|
||||||
|
<!-- The size the window asks for, stated in code as well as here: a window whose content has a
|
||||||
|
larger minimum opens at that minimum instead (see `ui.cpp`). -->
|
||||||
|
<property name="default-width">1280</property>
|
||||||
|
<property name="default-height">800</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwOverlaySplitView" id="side_view">
|
||||||
|
<property name="sidebar-position">end</property>
|
||||||
|
<!-- Its width in pixels, not as a fraction of the window: 320 on a first run, and never less
|
||||||
|
than 240 however small the window gets. -->
|
||||||
|
<!-- Both ends of the range, in pixels: `sidebar-width-fraction` is a 0..1 *fraction* of the
|
||||||
|
window (setting a pixel count there is a GObject critical and falls back to 0.25), so the
|
||||||
|
width is pinned by min and max instead. -->
|
||||||
|
<property name="sidebar-width-unit">px</property>
|
||||||
|
<property name="min-sidebar-width">320</property>
|
||||||
|
<property name="max-sidebar-width">320</property>
|
||||||
|
<property name="sidebar">
|
||||||
|
<!-- The column scrolls as a whole, so a long signal list or a revealed trigger panel can
|
||||||
|
never push either panel out of the window. -->
|
||||||
|
<object class="GtkScrolledWindow" id="side_scroll">
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="vscrollbar-policy">automatic</property>
|
||||||
|
<property name="min-content-width">300</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkBox" id="side_column">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="width-request">300</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="content">
|
||||||
|
<object class="GtkPaned" id="events_divider">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<!-- Shrinkable, or the pane's own minimum is reserved as an empty column even while it is
|
||||||
|
closed and the plot pays for a pane nobody opened. -->
|
||||||
|
<property name="shrink-start-child">1</property>
|
||||||
|
<property name="resize-start-child">0</property>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkBox" id="left_column">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="plot_box">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="homogeneous">1</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkPaned" id="plot_split">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="shrink-end-child">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="status_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="margin-top">2</property>
|
||||||
|
<property name="margin-bottom">2</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="status_badge"/>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<!-- The line is rewritten every frame and its counters grow, so it is
|
||||||
|
ellipsized: a label's minimum width is its text, and an unellipsized one
|
||||||
|
would push the divider around as the numbers change. -->
|
||||||
|
<object class="GtkLabel" id="status_label">
|
||||||
|
<property name="label">starting…</property>
|
||||||
|
<property name="ellipsize">end</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="halign">fill</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkMenuButton" id="status_info">
|
||||||
|
<property name="icon-name">help-about-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">Stream and buffer detail</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The math channel editor's window and its identity row: the name the channel is called, and the unit its
|
||||||
|
readouts carry.
|
||||||
|
|
||||||
|
The canvas below them is *declared* here but *drawn* by code (`GtkDrawingArea`); the parameter form beside
|
||||||
|
it and the palette are code as well, because the form follows which node is selected and the palette is the
|
||||||
|
operation table itself — neither is data a file should hold. What is here is the frame: the window, the
|
||||||
|
identity row, the body with the canvas in it, and the two buttons that end the dialog.
|
||||||
|
|
||||||
|
The window's title is not here: it says which mode the editor is in ("Edit math channel" against "New math
|
||||||
|
channel"), which the code that opens it knows and a file cannot.
|
||||||
|
|
||||||
|
The name takes the slack and the unit does not: the name is also how another graph refers to this channel
|
||||||
|
as a source, so it is the one that should grow as the window does.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkWindow" id="math_window">
|
||||||
|
<property name="default-width">720</property>
|
||||||
|
<property name="default-height">420</property>
|
||||||
|
<property name="modal">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="math_outer">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<property name="margin-top">10</property>
|
||||||
|
<property name="margin-bottom">10</property>
|
||||||
|
<property name="margin-start">10</property>
|
||||||
|
<property name="margin-end">10</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="math_head">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkEntry" id="math_name">
|
||||||
|
<property name="placeholder-text" translatable="yes">name</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkEntry" id="math_unit">
|
||||||
|
<property name="placeholder-text" translatable="yes">unit (optional)</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="math_body">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">10</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkDrawingArea" id="math_canvas">
|
||||||
|
<property name="width-request">420</property>
|
||||||
|
<property name="height-request">300</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="math_actions">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="halign">end</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="math_cancel">
|
||||||
|
<property name="label" translatable="yes">Cancel</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="math_apply">
|
||||||
|
<property name="label" translatable="yes">Add</property>
|
||||||
|
<property name="css-classes">suggested-action</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The math panel: a heading row with the button that adds a channel, and the list they go in.
|
||||||
|
|
||||||
|
The rows are not here. A math channel's row carries its name, its unit, its expression, its colour and its
|
||||||
|
own edit and remove buttons, all of which follow the channel — so the file holds the frame and the code
|
||||||
|
holds the rows, as in the measurements and signals panels beside it.
|
||||||
|
|
||||||
|
The heading takes the slack instead of the button, which is what keeps the `+` at the row's end however
|
||||||
|
long the title or the panel is.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkBox" id="math_panel">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<property name="margin-top">6</property>
|
||||||
|
<property name="margin-bottom">6</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<property name="margin-end">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="math_head">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="math_title">
|
||||||
|
<property name="label" translatable="yes">Math channels</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="math_add">
|
||||||
|
<property name="icon-name">list-add-symbolic</property>
|
||||||
|
<property name="css-classes">flat</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">add a math channel</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="math_list">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">2</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The measurements panel: a title row with the button that adds one, the one statistics switch the whole
|
||||||
|
panel shares, and the box the readings go into.
|
||||||
|
|
||||||
|
The readings are not here. Each one is a measurement the user placed — its name, its markers and its
|
||||||
|
readout row — so the rows are built and rebuilt in code, and this file holds the frame they land in
|
||||||
|
(`measures_list`) plus the three widgets whose state outlives them.
|
||||||
|
|
||||||
|
One statistics switch for the panel rather than one per reading: statistics are either what a user is
|
||||||
|
looking at or they are not, and a per-row switch is a column of chrome paid for on every row to answer a
|
||||||
|
question asked once.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkBox" id="measures_panel">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<property name="margin-top">6</property>
|
||||||
|
<property name="margin-bottom">6</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<property name="margin-end">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="measures_title">
|
||||||
|
<property name="label" translatable="yes">Measurements</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="measures_add">
|
||||||
|
<property name="icon-name">list-add-symbolic</property>
|
||||||
|
<property name="css-classes">flat</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">add a measurement</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkCheckButton" id="measures_stats_check">
|
||||||
|
<property name="label" translatable="yes">Statistics</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">show how each reading has varied: passes, mean, standard deviation and extremes, under the reading itself</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="measures_stats_reset">
|
||||||
|
<property name="label" translatable="yes">Reset</property>
|
||||||
|
<property name="css-classes">flat</property>
|
||||||
|
<property name="sensitive">0</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">start every measurement's statistics again from the next pass</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="measures_list">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">2</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The preferences, as an AdwPreferencesDialog: pages of groups of rows, which is what every GNOME
|
||||||
|
application shows and what libadwaita's own widgets are for. The rows are AdwEntryRow, AdwSpinRow,
|
||||||
|
AdwSwitchRow, AdwComboRow and AdwButtonRow rather than a label and a bare GtkEntry in a GtkBox, so
|
||||||
|
they behave the way a reader expects — each one is a whole row, with its own title, an optional
|
||||||
|
subtitle underneath, and the editing widget on the right where a finger expects it.
|
||||||
|
|
||||||
|
**There is no Apply button, deliberately.** A preferences row applies as it changes, which is the
|
||||||
|
practice these widgets are designed around and why they carry an `apply` of their own (AdwEntryRow's
|
||||||
|
show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth
|
||||||
|
batching). The two settings that cannot take effect immediately — the cache directory and relative
|
||||||
|
time, both of which are planned before any source is known — say so in their subtitles instead of
|
||||||
|
being held back by a button.
|
||||||
|
|
||||||
|
Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens,
|
||||||
|
and writes it back on every change. This file is the shape, that is the state.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="AdwPreferencesDialog" id="preferences">
|
||||||
|
<property name="title" translatable="yes">Preferences</property>
|
||||||
|
<property name="search-enabled">0</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesPage">
|
||||||
|
<property name="title" translatable="yes">Event cache</property>
|
||||||
|
<property name="icon-name">udpscope-events-symbolic</property>
|
||||||
|
<property name="description" translatable="yes">Latches are written here as they happen; whether anything is written at all is the trigger panel's own switch.</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesGroup">
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="dir_row">
|
||||||
|
<property name="title" translatable="yes">_Directory</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">One directory in which every source gets a folder of its own, never one directory between two sources</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwSpinRow" id="keep_row">
|
||||||
|
<property name="title" translatable="yes">Keep _events</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="subtitle" translatable="yes">Per source; 0 keeps every event, which is a decision about the disk</property>
|
||||||
|
<property name="adjustment">
|
||||||
|
<object class="GtkAdjustment">
|
||||||
|
<property name="lower">0</property>
|
||||||
|
<property name="upper">100000</property>
|
||||||
|
<property name="step-increment">1</property>
|
||||||
|
<property name="value">32</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesPage">
|
||||||
|
<property name="title" translatable="yes">Plot</property>
|
||||||
|
<property name="icon-name">udpscope-measure-symbolic</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesGroup">
|
||||||
|
<child>
|
||||||
|
<object class="AdwSwitchRow" id="relative_row">
|
||||||
|
<property name="title" translatable="yes">_Relative time</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="subtitle" translatable="yes">Take the plot's time from the sample rate and the packet counter rather than from the stream's own clock, so arrival and clock jitter cannot move a sample; absolute time is still kept for export. Read at start-up, because the buffers are planned before any source is known.</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwComboRow" id="theme_row">
|
||||||
|
<property name="title" translatable="yes">_Theme</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="subtitle" translatable="yes">What the interface looks like; auto follows the system</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesPage">
|
||||||
|
<property name="title" translatable="yes">Reset</property>
|
||||||
|
<property name="icon-name">view-refresh-symbolic</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesGroup">
|
||||||
|
<child>
|
||||||
|
<object class="AdwButtonRow" id="reset_row">
|
||||||
|
<property name="title" translatable="yes">_Reset to defaults</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="start-icon-name">view-refresh-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The shortcut overview, as a UI file instead of a couple of hundred lines of widget construction, and
|
||||||
|
in gedit's arrangement: one GtkShortcutsWindow holding a section per page (the window draws the
|
||||||
|
sidebar and the search box over them), each section holding a group, each group holding rows.
|
||||||
|
|
||||||
|
The mouse rows are the reason this file exists. An accelerator cannot spell "Ctrl and drag", and
|
||||||
|
libadwaita's AdwShortcutsItem, the modern replacement for this widget, has no icon property at all,
|
||||||
|
so its cell reads "No Shortcut". GtkShortcutsShortcut has exactly one gesture type whose *icon* is
|
||||||
|
drawn (shortcut-type "gesture" plus the icon property, per its own documentation), and that is what
|
||||||
|
puts the mouse glyph where the keycaps would otherwise be.
|
||||||
|
|
||||||
|
Keep this file in step with the tables in shortcuts.cpp: those are what the application handles,
|
||||||
|
this is what it shows. The `[help]` probe's `ui=` against `tables=` is the guard against the two
|
||||||
|
drifting apart.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkShortcutsWindow" id="help_overlay">
|
||||||
|
<property name="modal">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsSection">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="section-name">Acquisition</property>
|
||||||
|
<property name="title" translatable="yes">Acquisition</property>
|
||||||
|
<property name="max-height">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">space</property>
|
||||||
|
<property name="title" translatable="yes">Pause or resume the display</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">a</property>
|
||||||
|
<property name="title" translatable="yes">Arm or disarm the trigger</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">t</property>
|
||||||
|
<property name="title" translatable="yes">Show or hide the trigger panel</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsSection">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="section-name">Panes and editors</property>
|
||||||
|
<property name="title" translatable="yes">Panes and editors</property>
|
||||||
|
<property name="max-height">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">c</property>
|
||||||
|
<property name="title" translatable="yes">Show or hide the cursors pane</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">m</property>
|
||||||
|
<property name="title" translatable="yes">Show or hide the measurements pane</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">g</property>
|
||||||
|
<property name="title" translatable="yes">Show or hide the math pane</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">l</property>
|
||||||
|
<property name="title" translatable="yes">Show or hide the layout editor</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">s</property>
|
||||||
|
<property name="title" translatable="yes">Show or hide the side panel</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsSection">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="section-name">Files and windows</property>
|
||||||
|
<property name="title" translatable="yes">Files and windows</property>
|
||||||
|
<property name="max-height">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator"><Control>e</property>
|
||||||
|
<property name="title" translatable="yes">Export the visible range</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">F1</property>
|
||||||
|
<property name="title" translatable="yes">Open the help window</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator"><Control>slash</property>
|
||||||
|
<property name="title" translatable="yes">Open this shortcut overview</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator"><Control>q</property>
|
||||||
|
<property name="title" translatable="yes">Quit udpscope</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsSection">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="section-name">Plot keyboard</property>
|
||||||
|
<property name="title" translatable="yes">Plot keyboard</property>
|
||||||
|
<property name="max-height">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">plus minus</property>
|
||||||
|
<property name="title" translatable="yes">Zoom in or out, both axes</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator"><Control>plus <Control>minus</property>
|
||||||
|
<property name="title" translatable="yes">Zoom the time axis only</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator"><Alt>plus <Alt>minus</property>
|
||||||
|
<property name="title" translatable="yes">Zoom the value axis only</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">Left Right</property>
|
||||||
|
<property name="title" translatable="yes">Pan along time</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">Up Down</property>
|
||||||
|
<property name="title" translatable="yes">Pan along values</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">f</property>
|
||||||
|
<property name="title" translatable="yes">Fit the view to the data</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator">Escape</property>
|
||||||
|
<property name="title" translatable="yes">Cancel the gesture in progress</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsSection">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="section-name">Plot mouse</property>
|
||||||
|
<property name="title" translatable="yes">Plot mouse</property>
|
||||||
|
<property name="max-height">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="shortcut-type">gesture</property>
|
||||||
|
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||||
|
reference to another object and fails the whole file with "Object with ID … not
|
||||||
|
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||||
|
<property name="icon">
|
||||||
|
<object class="GThemedIcon">
|
||||||
|
<property name="names">input-mouse-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Zoom about the pointer, both axes</property>
|
||||||
|
<property name="subtitle" translatable="yes">Wheel</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="shortcut-type">gesture</property>
|
||||||
|
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||||
|
reference to another object and fails the whole file with "Object with ID … not
|
||||||
|
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||||
|
<property name="icon">
|
||||||
|
<object class="GThemedIcon">
|
||||||
|
<property name="names">input-mouse-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Zoom the time axis only</property>
|
||||||
|
<property name="subtitle" translatable="yes">Shift and wheel</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="shortcut-type">gesture</property>
|
||||||
|
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||||
|
reference to another object and fails the whole file with "Object with ID … not
|
||||||
|
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||||
|
<property name="icon">
|
||||||
|
<object class="GThemedIcon">
|
||||||
|
<property name="names">input-mouse-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Zoom the value axis only</property>
|
||||||
|
<property name="subtitle" translatable="yes">Ctrl and wheel</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="shortcut-type">gesture</property>
|
||||||
|
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||||
|
reference to another object and fails the whole file with "Object with ID … not
|
||||||
|
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||||
|
<property name="icon">
|
||||||
|
<object class="GThemedIcon">
|
||||||
|
<property name="names">input-mouse-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Pan</property>
|
||||||
|
<property name="subtitle" translatable="yes">Drag</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="shortcut-type">gesture</property>
|
||||||
|
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||||
|
reference to another object and fails the whole file with "Object with ID … not
|
||||||
|
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||||
|
<property name="icon">
|
||||||
|
<object class="GThemedIcon">
|
||||||
|
<property name="names">input-mouse-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Box zoom on time</property>
|
||||||
|
<property name="subtitle" translatable="yes">Ctrl and drag</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="shortcut-type">gesture</property>
|
||||||
|
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||||
|
reference to another object and fails the whole file with "Object with ID … not
|
||||||
|
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||||
|
<property name="icon">
|
||||||
|
<object class="GThemedIcon">
|
||||||
|
<property name="names">input-mouse-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Box zoom on values</property>
|
||||||
|
<property name="subtitle" translatable="yes">Alt and drag</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="shortcut-type">gesture</property>
|
||||||
|
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||||
|
reference to another object and fails the whole file with "Object with ID … not
|
||||||
|
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||||
|
<property name="icon">
|
||||||
|
<object class="GThemedIcon">
|
||||||
|
<property name="names">input-mouse-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Box zoom on time and values</property>
|
||||||
|
<property name="subtitle" translatable="yes">Shift and drag</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The signals panel: a heading, the hint that says what a signal is for, and the list they go in.
|
||||||
|
|
||||||
|
The rows are not here. There is one per signal in the stream, it carries the switch that decides whether
|
||||||
|
that signal is acquired, its colour, its calibration and its drop targets, and the whole list is rebuilt
|
||||||
|
whenever the stream's signals change — so the file holds the frame and the code holds the rows.
|
||||||
|
|
||||||
|
The list's size request is **width only**, deliberately. A height floor here made the list occupy 120 px
|
||||||
|
whatever it held, which hid the compact rows' height behind it and wasted the space on an empty stream;
|
||||||
|
the scroller this panel sits in already caps how tall it can grow.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkBox" id="signal_panel">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="margin-top">8</property>
|
||||||
|
<property name="margin-bottom">8</property>
|
||||||
|
<property name="margin-start">8</property>
|
||||||
|
<property name="margin-end">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="signals_heading">
|
||||||
|
<property name="label" translatable="yes">Signals</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="signals_hint">
|
||||||
|
<property name="label" translatable="yes">drag a signal onto a plot</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="css-classes">dim-label</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkListBox" id="signal_list">
|
||||||
|
<property name="selection-mode">none</property>
|
||||||
|
<property name="width-request">260</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
What to watch: one page of sources it can connect to, one page of waveforms opened before, and the
|
||||||
|
three buttons that are about the chooser rather than about a source.
|
||||||
|
|
||||||
|
This window exists *before* the application does. `main()` asks it what to open and only then builds
|
||||||
|
the model and the rest of the interface, which is why the preferences are reachable from here at all
|
||||||
|
— and why nothing in this file may reach for `App`.
|
||||||
|
|
||||||
|
The two pages are an `AdwViewStack` under an `AdwViewSwitcherBar` and not a `GtkNotebook`: a tab strip
|
||||||
|
is the wrong shape for two views of one thing, and the switcher is what current libadwaita asks for.
|
||||||
|
The bar is revealed unconditionally because this window is small enough that it always is narrow.
|
||||||
|
|
||||||
|
The rows are not here, deliberately. A source row and a waveform row are *data* — `source_dialog.cpp`
|
||||||
|
builds them from the known hosts and the recent files, and rebuilds them whenever either changes — so
|
||||||
|
this file holds the frame and the fixed buttons, and nothing here carries a value.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="AdwWindow" id="chooser_window">
|
||||||
|
<property name="title" translatable="yes">udpscope — choose a source</property>
|
||||||
|
<property name="default-width">420</property>
|
||||||
|
<property name="default-height">360</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwToolbarView">
|
||||||
|
<child type="top">
|
||||||
|
<object class="AdwHeaderBar">
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkButton" id="prefs_button">
|
||||||
|
<property name="label" translatable="yes">_Preferences…</property>
|
||||||
|
<property name="use-underline">1</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">the event cache, relative time and the interface's theme — the same dialog as the main window's menu, and the only place relative time can still change this run</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkButton" id="cancel_button">
|
||||||
|
<property name="label" translatable="yes">_Cancel</property>
|
||||||
|
<property name="use-underline">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkButton" id="new_host_button">
|
||||||
|
<property name="label" translatable="yes">_New host…</property>
|
||||||
|
<property name="use-underline">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<property name="content">
|
||||||
|
<object class="AdwViewStack" id="page_stack">
|
||||||
|
<child>
|
||||||
|
<object class="AdwViewStackPage">
|
||||||
|
<property name="name">sources</property>
|
||||||
|
<property name="title" translatable="yes">Sources</property>
|
||||||
|
<property name="icon-name">network-transmit-receive-symbolic</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkListBox" id="sources_list">
|
||||||
|
<property name="activate-on-single-click">1</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwViewStackPage">
|
||||||
|
<property name="name">waveforms</property>
|
||||||
|
<property name="title" translatable="yes">Waveforms</property>
|
||||||
|
<property name="icon-name">document-open-recent-symbolic</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkListBox" id="waves_list">
|
||||||
|
<property name="activate-on-single-click">1</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="halign">end</property>
|
||||||
|
<property name="margin-top">6</property>
|
||||||
|
<property name="margin-bottom">6</property>
|
||||||
|
<property name="margin-end">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="waves_open">
|
||||||
|
<property name="label" translatable="yes">_Open…</property>
|
||||||
|
<property name="use-underline">1</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">choose an exported .npy to view and analyse</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<child type="bottom">
|
||||||
|
<object class="AdwViewSwitcherBar" id="page_switcher">
|
||||||
|
<property name="reveal">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The form a source is added or edited with: one dialog, two groups, eight rows.
|
||||||
|
|
||||||
|
A grid of labels and bare entries is what this was, and a grid is the wrong shape for it. Each row
|
||||||
|
here is a whole row — a title, a subtitle under it where the hint used to be a placeholder, and the
|
||||||
|
editing widget on the right — so the form reads the way every GNOME application's does, and the hints
|
||||||
|
survive being typed over (a placeholder does not: it disappears the moment there is a value, which is
|
||||||
|
exactly when a user may want to know what empty meant).
|
||||||
|
|
||||||
|
The action is in the header bar rather than a button row at the foot: it is what the dialog is *for*,
|
||||||
|
it is what the keyboard's Enter reaches, and its label says which mode the form is in — `Connect` for
|
||||||
|
a new host, `Save` for an existing configuration, which `source_dialog.cpp` sets.
|
||||||
|
|
||||||
|
Nothing here carries a value. The dialog is filled from the source being edited, or from the command
|
||||||
|
line for a new one, by the code that opens it.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="AdwDialog" id="source_form">
|
||||||
|
<property name="content-width">420</property>
|
||||||
|
<property name="content-height">560</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwToolbarView">
|
||||||
|
<child type="top">
|
||||||
|
<object class="AdwHeaderBar">
|
||||||
|
<property name="title-widget">
|
||||||
|
<object class="AdwWindowTitle" id="form_title">
|
||||||
|
<property name="title" translatable="yes">New host</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkButton" id="form_cancel">
|
||||||
|
<property name="label" translatable="yes">_Cancel</property>
|
||||||
|
<property name="use-underline">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkButton" id="form_apply">
|
||||||
|
<property name="label" translatable="yes">Conn_ect</property>
|
||||||
|
<property name="use-underline">1</property>
|
||||||
|
<property name="receives-default">1</property>
|
||||||
|
<property name="css-classes">suggested-action</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<property name="content">
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="propagate-natural-height">1</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="AdwPreferencesPage">
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesGroup">
|
||||||
|
<property name="title" translatable="yes">Connection</property>
|
||||||
|
<property name="description" translatable="yes">Leave the multicast group and the interface empty for unicast on the default route</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="server_row">
|
||||||
|
<property name="title" translatable="yes">_Server</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwSpinRow" id="port_row">
|
||||||
|
<property name="title" translatable="yes">_Port</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="adjustment">
|
||||||
|
<object class="GtkAdjustment">
|
||||||
|
<property name="lower">1</property>
|
||||||
|
<property name="upper">65535</property>
|
||||||
|
<property name="step-increment">1</property>
|
||||||
|
<property name="value">8000</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="group_row">
|
||||||
|
<property name="title" translatable="yes">Multicast _group</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="iface_row">
|
||||||
|
<property name="title" translatable="yes">_Interface</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesGroup">
|
||||||
|
<property name="title" translatable="yes">Configuration</property>
|
||||||
|
<property name="description" translatable="yes">The name is what this source's session is called. The buffer and the clock are decided before the first socket is opened.</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="name_row">
|
||||||
|
<property name="title" translatable="yes">_Name</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="note_row">
|
||||||
|
<property name="title" translatable="yes">N_ote</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwSpinRow" id="buffer_row">
|
||||||
|
<property name="title" translatable="yes">_Buffer MB</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="subtitle" translatable="yes">sample buffer per source, applied when this source is opened</property>
|
||||||
|
<property name="adjustment">
|
||||||
|
<object class="GtkAdjustment">
|
||||||
|
<property name="lower">1</property>
|
||||||
|
<property name="upper">65536</property>
|
||||||
|
<property name="step-increment">1</property>
|
||||||
|
<property name="value">64</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwSwitchRow" id="form_relative_row">
|
||||||
|
<property name="title" translatable="yes">_Relative time</property>
|
||||||
|
<property name="use-underline">True</property>
|
||||||
|
<property name="subtitle" translatable="yes">plot time from the packet counter and the declared rate, instead of the stream's own time signal</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The trigger panel's frame: the heading, one labelled row per setting, the two buttons that run it, and
|
||||||
|
the search block.
|
||||||
|
|
||||||
|
Every row here is a label and a place for a control, and the control itself is built in code. A
|
||||||
|
dropdown's model and a spin button's range are the *application's* (the condition list, the level
|
||||||
|
range that follows the channel's scale), while a label is text a translator owns, so the split runs
|
||||||
|
between them: the row is the file's and the control is appended into it. That is the arrangement
|
||||||
|
`labeled_box()` used to make in code, with the label moved where all the other labels are.
|
||||||
|
|
||||||
|
Between the controls, the labels and the search block are what "read the panel" means, so they are
|
||||||
|
here: which of these rows `sync_trigger_rows()` shows depends on the condition (a pulse width has a
|
||||||
|
width, a window has two levels, a dropout has a timeout), and that is the code's decision, not this
|
||||||
|
file's — this file just has to have a row for each of them to show or hide.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<object class="GtkBox" id="trigger_panel">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="margin-top">8</property>
|
||||||
|
<property name="margin-bottom">8</property>
|
||||||
|
<property name="margin-start">8</property>
|
||||||
|
<property name="margin-end">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="trigger_heading">
|
||||||
|
<property name="label" translatable="yes">Trigger</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="signal_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">signal</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="condition_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">condition</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="edge_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">edge</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="cmp_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">width is</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="width_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">width s</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="width2_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">and s</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="level2_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">to level</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="timeout_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">timeout s</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="level_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">level</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="pre_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">pre-trigger %</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="holdoff_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">hold-off s</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="band_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">noise band</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="hf_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">HF reject Hz</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="mode_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">mode</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="record_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="record_label">
|
||||||
|
<property name="label" translatable="yes">record events</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="trigger_arm">
|
||||||
|
<property name="label" translatable="yes">Arm</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="trigger_live">
|
||||||
|
<property name="label" translatable="yes">Go live (trigger off)</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">disable trigger mode and stream continuously</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkExpander" id="search_exp">
|
||||||
|
<property name="label" translatable="yes">Search the buffer</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">find every place in the retained buffer where the condition above holds, and step through them</property>
|
||||||
|
<property name="child">
|
||||||
|
|
||||||
|
<object class="GtkBox" id="search_sbox">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<property name="margin-top">4</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="search_run">
|
||||||
|
<property name="label" translatable="yes">Search</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="search_cancel">
|
||||||
|
<property name="label" translatable="yes">Stop</property>
|
||||||
|
<property name="sensitive">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="search_status">
|
||||||
|
<property name="label" translatable="yes">not searched</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="wrap">1</property>
|
||||||
|
<property name="css-classes">dim-label</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="search_prev">
|
||||||
|
<property name="icon-name">go-previous-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">the previous hit</property>
|
||||||
|
<property name="sensitive">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="search_pos">
|
||||||
|
<property name="label">--</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="search_next">
|
||||||
|
<property name="icon-name">go-next-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">the next hit</property>
|
||||||
|
<property name="sensitive">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="search_marks">
|
||||||
|
<property name="label"> </property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="css-classes">dim-label</property>
|
||||||
|
<property name="ellipsize">3</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The application's UI files, compiled into the binary beside the icons and the help pages, so there
|
||||||
|
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file:
|
||||||
|
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
|
||||||
|
not open, which is a silent failure at runtime and a loud one here.
|
||||||
|
-->
|
||||||
|
<gresources>
|
||||||
|
<gresource prefix="/org/udpscope/ui">
|
||||||
|
<file preprocess="xml-stripblanks">shortcuts.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">preferences.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">help.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">source-form.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">source-chooser.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">loading.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">main-window.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">cursors-panel.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">measures-panel.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">math-panel.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">math-editor.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">events-pane.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">header-bar.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">trigger-panel.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">signals-panel.ui</file>
|
||||||
|
</gresource>
|
||||||
|
</gresources>
|
||||||
+1499
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
//
|
||||||
|
// The cursors pane: what the two vertical markers and the per-row rulers read, in the four cursor\n// modes, rebuilt only when the rows or the mode change.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
|
||||||
|
/// `%.6g s` is exact enough for a cursor readout and short enough for the panel.
|
||||||
|
void update_cursors_pane(App& app) {
|
||||||
|
if (app.cursors_body == nullptr || !app.rows->cursors_visible()) return;
|
||||||
|
if (app.cursors_pane_mode != static_cast<int>(app.rows->cursor_mode()) ||
|
||||||
|
app.cursors_pane_rows != app.rows->row_count() ||
|
||||||
|
app.cursors_pane_generation != app.model->generation() ||
|
||||||
|
app.cursors_pane_bound != bound_slot_mask(app)) {
|
||||||
|
rebuild_cursors_pane(app);
|
||||||
|
}
|
||||||
|
const double t0 = app.rows->cursor_a_x();
|
||||||
|
const double t1 = app.rows->cursor_b_x();
|
||||||
|
const auto put = [](GtkWidget* w, const std::string& text) {
|
||||||
|
if (w != nullptr) gtk_label_set_text(GTK_LABEL(w), text.c_str());
|
||||||
|
};
|
||||||
|
|
||||||
|
const RowSet::CursorMode mode = app.rows->cursor_mode();
|
||||||
|
const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both;
|
||||||
|
const bool crossings = mode == RowSet::CursorMode::Mix;
|
||||||
|
for (const App::CursorLine& line : app.cursors_lines) {
|
||||||
|
if (line.a == nullptr) continue;
|
||||||
|
// One measurement per cell, each with the unit its own axis declares: the pane is a
|
||||||
|
// readout beside a plot, where the values are the point.
|
||||||
|
std::string va;
|
||||||
|
std::string vb;
|
||||||
|
std::string vd;
|
||||||
|
if (line.slot == -2) {
|
||||||
|
va = format_measure(t0, "s");
|
||||||
|
vb = format_measure(t1, "s");
|
||||||
|
vd = format_measure(t1 - t0, "s");
|
||||||
|
} else if (line.slot == -1 && rulers) {
|
||||||
|
const std::string unit = row_unit(app, line.row);
|
||||||
|
const double y1 = app.rows->ruler_y(line.row);
|
||||||
|
const double y2 = app.rows->ruler_b_y(line.row);
|
||||||
|
va = format_measure(y1, unit);
|
||||||
|
vb = format_measure(y2, unit);
|
||||||
|
vd = format_measure(y2 - y1, unit);
|
||||||
|
} else if (crossings && line.slot >= 0) {
|
||||||
|
// Where each cursor crosses this trace: the value the trace has at that time,
|
||||||
|
// read the way a crosshair reads it.
|
||||||
|
// The same call the crossing markers use, on the same trace: the pane and the
|
||||||
|
// marks on the plot cannot disagree about what the value is.
|
||||||
|
const RowSet::Row& row = app.rows->row(line.row);
|
||||||
|
double y1 = 0.0;
|
||||||
|
double y2 = 0.0;
|
||||||
|
const bool a = row.plot != nullptr &&
|
||||||
|
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t0, y1);
|
||||||
|
const bool b = row.plot != nullptr &&
|
||||||
|
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t1, y2);
|
||||||
|
if (a && b) {
|
||||||
|
const std::string unit = slot_unit(app, line.row, line.slot);
|
||||||
|
va = format_measure(y1, unit);
|
||||||
|
vb = format_measure(y2, unit);
|
||||||
|
vd = format_measure(y2 - y1, unit);
|
||||||
|
} else {
|
||||||
|
va = vb = vd = a || b ? "partial" : "--";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
va = vb = vd = "--";
|
||||||
|
}
|
||||||
|
put(line.a, va);
|
||||||
|
put(line.b, vb);
|
||||||
|
put(line.d, vd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* build_cursors_panel(App& app) {
|
||||||
|
// The panel's shape is a UI file; the *contents* of its grid are not, because they follow the cursor
|
||||||
|
// mode and the rows and are rebuilt whenever either changes.
|
||||||
|
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/cursors-panel.ui");
|
||||||
|
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_panel"));
|
||||||
|
app.cursors_mode_drop = GTK_WIDGET(gtk_builder_get_object(ui, "mode_drop"));
|
||||||
|
app.cursors_reset = GTK_WIDGET(gtk_builder_get_object(ui, "reset"));
|
||||||
|
app.cursors_body = GTK_WIDGET(gtk_builder_get_object(ui, "body"));
|
||||||
|
if (box == nullptr || app.cursors_mode_drop == nullptr || app.cursors_reset == nullptr ||
|
||||||
|
app.cursors_body == nullptr) {
|
||||||
|
// A missing id is a mistake in the `.ui`. The panel comes up empty rather than taking the window
|
||||||
|
// with it, and `[cursors]` reads differently rather than the application failing to start.
|
||||||
|
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mode names are the dropdown's model, and they are a C array, so they are set here.
|
||||||
|
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
|
||||||
|
kCursorModeNames[3], nullptr};
|
||||||
|
gtk_drop_down_set_model(GTK_DROP_DOWN(app.cursors_mode_drop),
|
||||||
|
G_LIST_MODEL(gtk_string_list_new(names)));
|
||||||
|
gtk_widget_set_tooltip_text(app.cursors_mode_drop,
|
||||||
|
"vertical: two times, shared by every row\n"
|
||||||
|
"horizontal: one value per row, at the two times\n"
|
||||||
|
"both: the two times and one value per row\n"
|
||||||
|
"mix: the two times, read off each trace where they cross");
|
||||||
|
g_signal_connect(app.cursors_mode_drop, "notify::selected",
|
||||||
|
G_CALLBACK(on_cursor_mode_changed), &app);
|
||||||
|
|
||||||
|
// Reset lives beside the mode it resets rather than in the overflow menu: it acts on this pane's
|
||||||
|
// instruments and is only visible while they are on, both of which are this pane's facts.
|
||||||
|
gtk_widget_set_tooltip_text(app.cursors_reset, "re-place the cursors at the window's edges");
|
||||||
|
g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app);
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_cursors_toggled(GtkToggleButton* button, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
const bool on = gtk_toggle_button_get_active(button);
|
||||||
|
app->rows->set_cursors_visible(on);
|
||||||
|
// The reset button exists for the cursors: with them hidden there is nothing to
|
||||||
|
// reset, and a button that does nothing reads as broken.
|
||||||
|
gtk_widget_set_visible(app->cursors_reset, on);
|
||||||
|
// The pane is the cursors' readout: with them off there is nothing to read.
|
||||||
|
if (app->cursors_revealer != nullptr) {
|
||||||
|
gtk_revealer_set_reveal_child(GTK_REVEALER(app->cursors_revealer), on);
|
||||||
|
}
|
||||||
|
if (on) {
|
||||||
|
app->rows->reset_cursors();
|
||||||
|
rebuild_cursors_pane(*app);
|
||||||
|
update_cursors_pane(*app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_cursors_reset(GtkButton*, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
// A reset the user cannot see reads as a dead button, so it also switches the
|
||||||
|
// cursors on.
|
||||||
|
if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->cursors_toggle))) {
|
||||||
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->cursors_toggle), true);
|
||||||
|
}
|
||||||
|
app->rows->reset_cursors();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
+1023
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,480 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
//
|
||||||
|
// The layout editor and the persisted session: the overlay buttons that add and remove rows, the\n// debounced save, and the configurations dialog with the layouts it loads and stores.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
|
||||||
|
/// Reads the session layout back out of the model and rows: the display window,
|
||||||
|
/// each row's slot signal names, and each signal's calibration.
|
||||||
|
LayoutState collect_layout(App& app) {
|
||||||
|
LayoutState st;
|
||||||
|
st.window_s = app.rows->time_window();
|
||||||
|
ScopeModel& model = *app.model;
|
||||||
|
for (int i = 0; i < app.rows->row_count(); ++i) {
|
||||||
|
LayoutState::Row row;
|
||||||
|
const RowSet::Row& r = app.rows->row(i);
|
||||||
|
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||||
|
row.signals.push_back(r.signal_name[static_cast<std::size_t>(s)]);
|
||||||
|
}
|
||||||
|
const std::vector<float>& weights = app.rows->row_weights();
|
||||||
|
if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)];
|
||||||
|
st.rows.push_back(std::move(row));
|
||||||
|
}
|
||||||
|
for (std::size_t si = 0; si < model.signals().size(); ++si) {
|
||||||
|
for (std::size_t c = 0; c < model.channels().size(); ++c) {
|
||||||
|
const ChannelInfo& ch = model.channels()[c];
|
||||||
|
if (ch.signal != si) continue;
|
||||||
|
st.calibration.push_back({model.signals()[si].name, ch.gain, ch.offset});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The *enable* state, not just what is plotted: a signal can be enabled only as a math channel's
|
||||||
|
// source, and a session that came back with it off would come back with a NaN trace. "Enabled" is
|
||||||
|
// read off the channels the model built -- it has no per-signal accessor and does not need one,
|
||||||
|
// since an enabled signal is exactly one that produced a channel.
|
||||||
|
std::vector<bool> on(model.signals().size(), false);
|
||||||
|
for (const ChannelInfo& ch : model.channels()) {
|
||||||
|
if (ch.math) continue;
|
||||||
|
if (ch.signal < on.size()) on[ch.signal] = true;
|
||||||
|
}
|
||||||
|
for (std::size_t si = 0; si < model.signals().size(); ++si) {
|
||||||
|
if (model.signals()[si].math || !on[si]) continue;
|
||||||
|
st.enabled.push_back(model.signals()[si].name);
|
||||||
|
}
|
||||||
|
// Only the signals the user actually re-coloured: the rest take the positional palette, and
|
||||||
|
// writing those out would freeze a colour that is meant to follow the signal list.
|
||||||
|
for (const auto& [name, rgb] : app.rows->signal_colors()) {
|
||||||
|
st.colors.push_back({name, rgb});
|
||||||
|
}
|
||||||
|
// The app's own lists are the pane's source of truth for both of these.
|
||||||
|
st.math = app.math;
|
||||||
|
st.measures = app.measures;
|
||||||
|
st.measure_stats = app.measure_stats_on;
|
||||||
|
// The connection is the session's too: the file's name carries only host and port.
|
||||||
|
st.multicast = app.source_multicast;
|
||||||
|
st.interface_addr = app.source_interface;
|
||||||
|
// The configuration's own identity and the startup settings it carries. The buffer size is a
|
||||||
|
// *startup* parameter -- the rings are allocated before any stream is known -- so a configuration
|
||||||
|
// is the only place it can be remembered per source, and the chooser applies it on open.
|
||||||
|
st.comment = app.config_comment;
|
||||||
|
st.buffer_mb = app.model->config().buffer_mb;
|
||||||
|
st.relative_time = app.model->config().relative_time;
|
||||||
|
st.capture_packets = app.model->config().capture_packets;
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||||
|
ConfigDialog* d = static_cast<ConfigDialog*>(user);
|
||||||
|
if (row == nullptr) return;
|
||||||
|
const char* name = static_cast<const char*>(g_object_get_data(G_OBJECT(row), "config-name"));
|
||||||
|
if (name == nullptr) return;
|
||||||
|
App& app = *d->app;
|
||||||
|
|
||||||
|
// The session on screen is saved first: switching configurations must not be the one gesture
|
||||||
|
// that loses the arrangement it is switching *from*.
|
||||||
|
save_layout_now(app);
|
||||||
|
|
||||||
|
LayoutState st;
|
||||||
|
const std::string path = configuration_path(app.source_host, app.source_port, name);
|
||||||
|
if (!load_layout(path, st)) {
|
||||||
|
set_notice(app, std::string("could not read the configuration \"") + name + "\"", 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.config_name = name;
|
||||||
|
app.config_comment = st.comment;
|
||||||
|
set_active_configuration(app.source_host, app.source_port, name);
|
||||||
|
apply_layout(app, st);
|
||||||
|
|
||||||
|
// A buffer size, a clock and a capture ring are decided before the first socket: saying so beats
|
||||||
|
// a configuration that appears to half-apply.
|
||||||
|
const Config& cfg = app.model->config();
|
||||||
|
std::string later;
|
||||||
|
if (st.buffer_mb > 0.0 && st.buffer_mb != cfg.buffer_mb) {
|
||||||
|
later += "buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
|
||||||
|
}
|
||||||
|
if (st.relative_time != cfg.relative_time) {
|
||||||
|
later += later.empty() ? "" : ", ";
|
||||||
|
later += st.relative_time ? "relative time" : "stream time";
|
||||||
|
}
|
||||||
|
if (st.capture_packets != cfg.capture_packets) {
|
||||||
|
later += later.empty() ? "" : ", ";
|
||||||
|
later += "packet capture";
|
||||||
|
}
|
||||||
|
set_notice(app, "configuration \"" + std::string(name) + "\" loaded" +
|
||||||
|
(later.empty() ? "" : " (" + later + " takes effect on the next start)"),
|
||||||
|
4);
|
||||||
|
config_dialog_refresh(*d);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_config_forget_clicked(GtkButton* button, gpointer user) {
|
||||||
|
ConfigDialog* d = static_cast<ConfigDialog*>(user);
|
||||||
|
const char* name = static_cast<const char*>(g_object_get_data(G_OBJECT(button), "config-name"));
|
||||||
|
if (name == nullptr) return;
|
||||||
|
App& app = *d->app;
|
||||||
|
if (app.config_name == name) {
|
||||||
|
// The active one is being deleted: the next save picks whatever is left, which is what
|
||||||
|
// `active_configuration()` answers once the name is gone.
|
||||||
|
app.config_name.clear();
|
||||||
|
app.config_comment.clear();
|
||||||
|
}
|
||||||
|
forget_configuration(app.source_host, app.source_port, name);
|
||||||
|
config_dialog_refresh(*d);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_config_save_as(GtkButton*, gpointer user) {
|
||||||
|
ConfigDialog* d = static_cast<ConfigDialog*>(user);
|
||||||
|
const std::string name = gtk_editable_get_text(GTK_EDITABLE(d->name));
|
||||||
|
const std::string comment = gtk_editable_get_text(GTK_EDITABLE(d->comment));
|
||||||
|
if (name.empty()) {
|
||||||
|
set_notice(*d->app, "a configuration needs a name", 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!save_configuration_as(*d->app, name, comment)) {
|
||||||
|
set_notice(*d->app, "could not write the configuration", 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(d->name), "");
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(d->comment), "");
|
||||||
|
config_dialog_refresh(*d);
|
||||||
|
}
|
||||||
|
|
||||||
|
void config_dialog_refresh(ConfigDialog& d) {
|
||||||
|
while (GtkWidget* child = gtk_widget_get_first_child(d.list)) {
|
||||||
|
gtk_list_box_remove(GTK_LIST_BOX(d.list), child);
|
||||||
|
}
|
||||||
|
const std::vector<std::string> names =
|
||||||
|
configuration_names(d.app->source_host, d.app->source_port);
|
||||||
|
if (names.empty()) {
|
||||||
|
GtkWidget* empty = gtk_label_new("No configurations yet -- save this session below.");
|
||||||
|
gtk_widget_add_css_class(empty, "dim-label");
|
||||||
|
gtk_widget_set_margin_top(empty, 12);
|
||||||
|
gtk_widget_set_margin_bottom(empty, 12);
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(d.list), empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const std::string& name : names) {
|
||||||
|
const std::string path = configuration_path(d.app->source_host, d.app->source_port, name);
|
||||||
|
LayoutState st;
|
||||||
|
if (!load_layout(path, st)) continue;
|
||||||
|
|
||||||
|
GtkWidget* row = gtk_list_box_row_new();
|
||||||
|
g_object_set_data_full(G_OBJECT(row), "config-name", g_strdup(name.c_str()), g_free);
|
||||||
|
|
||||||
|
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
|
||||||
|
gtk_widget_set_margin_start(line, 10);
|
||||||
|
gtk_widget_set_margin_end(line, 4);
|
||||||
|
gtk_widget_set_margin_top(line, 5);
|
||||||
|
gtk_widget_set_margin_bottom(line, 5);
|
||||||
|
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||||
|
gtk_widget_set_hexpand(text, TRUE);
|
||||||
|
|
||||||
|
std::string title = name;
|
||||||
|
if (name == d.app->config_name) title += " (active)";
|
||||||
|
GtkWidget* title_label = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(title_label), ("<b>" + title + "</b>").c_str());
|
||||||
|
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(text), title_label);
|
||||||
|
|
||||||
|
if (!st.comment.empty()) {
|
||||||
|
GtkWidget* note = gtk_label_new(st.comment.c_str());
|
||||||
|
gtk_widget_add_css_class(note, "dim-label");
|
||||||
|
gtk_widget_set_halign(note, GTK_ALIGN_START);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(note), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_box_append(GTK_BOX(text), note);
|
||||||
|
}
|
||||||
|
|
||||||
|
int signals = 0;
|
||||||
|
for (const LayoutState::Row& r : st.rows) {
|
||||||
|
for (const std::string& sig : r.signals) {
|
||||||
|
if (!sig.empty()) ++signals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) +
|
||||||
|
" signals";
|
||||||
|
if (st.buffer_mb > 0.0) {
|
||||||
|
sub += ", buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
|
||||||
|
}
|
||||||
|
if (st.relative_time) sub += ", relative time";
|
||||||
|
GtkWidget* counts = gtk_label_new(sub.c_str());
|
||||||
|
gtk_widget_add_css_class(counts, "dim-label");
|
||||||
|
gtk_widget_set_halign(counts, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(text), counts);
|
||||||
|
gtk_box_append(GTK_BOX(line), text);
|
||||||
|
|
||||||
|
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
||||||
|
gtk_widget_set_tooltip_text(forget, "Delete this configuration");
|
||||||
|
gtk_widget_add_css_class(forget, "flat");
|
||||||
|
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
||||||
|
g_object_set_data_full(G_OBJECT(forget), "config-name", g_strdup(name.c_str()), g_free);
|
||||||
|
g_signal_connect(forget, "clicked", G_CALLBACK(on_config_forget_clicked), &d);
|
||||||
|
gtk_box_append(GTK_BOX(line), forget);
|
||||||
|
|
||||||
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(d.list), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the layout file (a debounced schedule lands here once). Errors go to
|
||||||
|
/// stderr; success is silent -- a save per click must not narrate.
|
||||||
|
void save_layout_now(App& app) {
|
||||||
|
if (app.source_port == 0) return;
|
||||||
|
// Nothing is known about this source yet -- no CONFIG ever arrived -- so every slot name is
|
||||||
|
// empty and this would overwrite a configured session with `signals = ["", ...]`. The startup
|
||||||
|
// chooser filters such a file out of its list, so a source whose streamer was down during one
|
||||||
|
// run would silently vanish from the only place it can be picked. Keeping the old file loses
|
||||||
|
// nothing: this run configured nothing.
|
||||||
|
if (app.model->signals().empty()) return;
|
||||||
|
// A file view is not the source's session, and the same argument applies: this view configured
|
||||||
|
// nothing about the stream. A replay leaves the rows where the user put them, so its save would
|
||||||
|
// merely be pointless -- but a *group* rebinds every slot to its own renamed channels, and a save
|
||||||
|
// taken while one was up wrote `signals = ["square (A)", ...]` into the file. The next load then
|
||||||
|
// matched no live signal, enabled nothing, and the live view came back with no channels at all,
|
||||||
|
// on the window the poisoned file carried. Skipping costs nothing: the live session's own file is
|
||||||
|
// already on disk, and `go_live_again()` puts the rest back.
|
||||||
|
if (app.replaying) return;
|
||||||
|
if (app.config_name.empty()) app.config_name = active_configuration(app.source_host,
|
||||||
|
app.source_port);
|
||||||
|
const std::string path =
|
||||||
|
configuration_path(app.source_host, app.source_port, app.config_name);
|
||||||
|
if (!save_layout(path, collect_layout(app))) {
|
||||||
|
std::fprintf(stderr, "[udpscope] failed to write the layout file\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Which configuration the source is on is part of remembering it: the next run opens this one.
|
||||||
|
set_active_configuration(app.source_host, app.source_port, app.config_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the current session under a *new* name, with a note, and makes it the source's active
|
||||||
|
/// configuration. The one entry point for both the dialog's button and the probe: a probe that
|
||||||
|
/// rolled its own would test a different path than the user's.
|
||||||
|
bool save_configuration_as(App& app, const std::string& name, const std::string& comment) {
|
||||||
|
if (name.empty() || app.source_port == 0 || app.model->signals().empty()) return false;
|
||||||
|
app.config_name = name;
|
||||||
|
app.config_comment = comment;
|
||||||
|
const std::string path = configuration_path(app.source_host, app.source_port, name);
|
||||||
|
if (!save_layout(path, collect_layout(app))) {
|
||||||
|
std::fprintf(stderr, "[udpscope] failed to write the configuration file\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set_active_configuration(app.source_host, app.source_port, name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot debounce: a burst of changes (a drop, a row insert, a calibration
|
||||||
|
/// commit in the same second) writes once, 400 ms after the last one.
|
||||||
|
gboolean layout_save_tick(gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
app->layout_save_source = 0;
|
||||||
|
save_layout_now(*app);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void schedule_layout_save(App& app) {
|
||||||
|
if (app.source_port == 0) return;
|
||||||
|
if (app.layout_save_source != 0) return;
|
||||||
|
app.layout_save_source = g_timeout_add(400, layout_save_tick, &app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a loaded layout: rows, slot assignments, calibration, window. Names
|
||||||
|
/// that no longer exist in the stream are skipped rather than freeing their slot
|
||||||
|
/// (a stream that renamed a signal must not blank the user's layout).
|
||||||
|
void apply_layout(App& app, const LayoutState& st) {
|
||||||
|
ScopeModel& model = *app.model;
|
||||||
|
const auto& signals = model.signals();
|
||||||
|
|
||||||
|
// The window before anything structural. Nothing is enabled yet, so there are no channels
|
||||||
|
// to plan for and this only *records* the span -- but it is the span every plan below is
|
||||||
|
// then made with, so the `apply_time_window()` at the end of this function finds the
|
||||||
|
// decimation already right and re-plans nothing. Stated last, as it used to be, it moved
|
||||||
|
// the stride and bought a second full allocation of the buffer after the data was already
|
||||||
|
// flowing into the first.
|
||||||
|
if (st.window_s > 0.0) model.set_target_span(st.window_s);
|
||||||
|
|
||||||
|
// The math graph before the enables, because a math channel's *name* is only a signal once
|
||||||
|
// its graph is installed -- a row that holds one would otherwise be skipped as a name the
|
||||||
|
// stream no longer has -- and because installing it while nothing is enabled is the one
|
||||||
|
// ordering in which it does not pay for a buffer of its own. `signals` is a reference to
|
||||||
|
// the model's own vector, so the synthetic entries this appends are visible below.
|
||||||
|
app.math = st.math;
|
||||||
|
apply_math_channels(app);
|
||||||
|
|
||||||
|
// What the session had on, collected and enabled **once**, at the end of this function.
|
||||||
|
// Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole
|
||||||
|
// of `--buffer-mb`: seven signals one at a time is seven allocations before a single sample
|
||||||
|
// is kept, which is the ten seconds of blank window a large buffer used to start with. The
|
||||||
|
// math channels are not collected here: their enable state is the session's `math` and
|
||||||
|
// `set_math_channels()` has already restored it, so listing them again could turn on one
|
||||||
|
// the user saved off.
|
||||||
|
std::vector<std::size_t> wanted;
|
||||||
|
wanted.reserve(st.enabled.size());
|
||||||
|
for (const std::string& name : st.enabled) {
|
||||||
|
for (std::size_t si = 0; si < signals.size(); ++si) {
|
||||||
|
if (signals[si].name != name || signals[si].math) continue;
|
||||||
|
wanted.push_back(si);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.measures = st.measures;
|
||||||
|
app.measure_stats_on = st.measure_stats;
|
||||||
|
app.updating_panels = true;
|
||||||
|
if (app.measures_stats_check != nullptr) {
|
||||||
|
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
|
||||||
|
app.measure_stats_on);
|
||||||
|
}
|
||||||
|
// The toggled handler is guarded by updating_panels, so the Reset button's sensitivity
|
||||||
|
// and the stats-label visibility are set directly from the loaded state here; the labels'
|
||||||
|
// visibility is also set per-row inside rebuild_measures_pane via measures_row().
|
||||||
|
if (app.measures_stats_reset != nullptr)
|
||||||
|
gtk_widget_set_sensitive(app.measures_stats_reset, app.measure_stats_on);
|
||||||
|
app.updating_panels = false;
|
||||||
|
rebuild_measures_pane(app);
|
||||||
|
|
||||||
|
int rows = static_cast<int>(st.rows.size());
|
||||||
|
if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows;
|
||||||
|
if (rows > 0) app.rows->set_row_count(rows);
|
||||||
|
|
||||||
|
// The rows' shares of the height come from the same file as their signals: a sizing a user dragged is
|
||||||
|
// an arrangement, and the layout is where an arrangement lives. Absent from an older file, the weights
|
||||||
|
// are all 1 -- equal rows -- which is what such a file was saved with.
|
||||||
|
{
|
||||||
|
std::vector<float> weights;
|
||||||
|
weights.reserve(st.rows.size());
|
||||||
|
for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast<float>(wr.weight));
|
||||||
|
app.rows->set_row_weights(std::move(weights));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < rows; ++i) {
|
||||||
|
int slot = 0;
|
||||||
|
for (const std::string& name : st.rows[static_cast<std::size_t>(i)].signals) {
|
||||||
|
if (slot >= RowSet::kSlotsPerRow) break;
|
||||||
|
if (name.empty()) continue;
|
||||||
|
for (std::size_t si = 0; si < signals.size(); ++si) {
|
||||||
|
if (signals[si].name != name) continue;
|
||||||
|
// A signal the model will never build a channel for must not take a slot. The
|
||||||
|
// stream's time signal is skipped by `build_channels_locked()` however often it is
|
||||||
|
// enabled, so such a slot holds a name for ever and data never -- and because
|
||||||
|
// `collect_layout()` writes the names straight back, one that got in stayed in and
|
||||||
|
// was re-saved every session. On the plot it drew nothing; in the cursors pane's
|
||||||
|
// `mix` mode it drew a line that could only read `--`.
|
||||||
|
if (ScopeModel::is_time_signal(signals, si)) break;
|
||||||
|
// Enabling is what starts acquisition (a loaded plot nobody acquires would
|
||||||
|
// come back blank), and it is what a drop does for the signal it places.
|
||||||
|
app.rows->assign(i, slot, name, name, signal_color_index(signals, si));
|
||||||
|
wanted.push_back(si);
|
||||||
|
++slot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The one structural change of a restored session: everything the file had on, and
|
||||||
|
// everything its rows placed, in a single re-plan. Enabling is what starts acquisition (a
|
||||||
|
// loaded plot nobody acquires would come back blank), and it is what a drop does for the
|
||||||
|
// signal it places -- so a row holding a signal the `enabled` list did not name is enabled
|
||||||
|
// here, by having been collected above.
|
||||||
|
model.set_signals_enabled(wanted, true);
|
||||||
|
|
||||||
|
for (const auto& cal : st.calibration) {
|
||||||
|
for (std::size_t si = 0; si < signals.size(); ++si) {
|
||||||
|
if (signals[si].name != cal.name) continue;
|
||||||
|
model.set_calibration(si, cal.gain, cal.offset);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped for a name the stream no longer carries, like the calibration above: an override the
|
||||||
|
// signal list cannot show is one `collect_layout()` would write back every session for ever.
|
||||||
|
for (const auto& color : st.colors) {
|
||||||
|
for (std::size_t si = 0; si < signals.size(); ++si) {
|
||||||
|
if (signals[si].name != color.name) continue;
|
||||||
|
app.rows->set_signal_color(color.name, color.rgb);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (st.window_s > 0.0) apply_time_window(app, st.window_s);
|
||||||
|
|
||||||
|
// set_row_count() rebuilt the figure (its row count is fixed at construction):
|
||||||
|
// re-point the area, as every other row-count change must, and refresh the
|
||||||
|
// signal panel's chips so they name the traces the file brought back.
|
||||||
|
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
|
||||||
|
app.relayout_pending = true;
|
||||||
|
rebuild_signal_panels(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_layout_row_remove(GtkButton* button, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
const int row = row_key(GTK_WIDGET(button));
|
||||||
|
if (!app->rows->remove_row(row)) {
|
||||||
|
app->notice = "a figure needs at least one row";
|
||||||
|
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `remove_row()` has already built the new figure (a `Figure`'s row count is fixed at
|
||||||
|
// construction), so the area has to be re-pointed here rather than through
|
||||||
|
// `relayout_areas()`, which only acts on a *dirty* row.
|
||||||
|
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
|
||||||
|
rebuild_layout_editor(*app);
|
||||||
|
// The signal list's chips name the trace a signal is drawn as, and a removed row took
|
||||||
|
// its traces with it.
|
||||||
|
rebuild_signal_panels(*app);
|
||||||
|
schedule_layout_save(*app);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_layout_row_insert(GtkButton* button, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
const int at = row_key(GTK_WIDGET(button));
|
||||||
|
if (!app->rows->insert_row(at)) {
|
||||||
|
app->notice = "at most " + std::to_string(RowSet::kMaxRows) + " rows";
|
||||||
|
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
|
||||||
|
rebuild_layout_editor(*app);
|
||||||
|
rebuild_signal_panels(*app);
|
||||||
|
schedule_layout_save(*app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Done` button: the toolbar toggle is the one piece of state that says whether the
|
||||||
|
/// editor is up, so this turns *that* off and the existing `toggled` handler does the rest
|
||||||
|
/// (including taking this button down).
|
||||||
|
void on_layout_editor_done(GtkButton*, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->layout_toggle), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_layout_editor_toggled(GtkToggleButton* button, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
app->layout_editing = gtk_toggle_button_get_active(button);
|
||||||
|
rebuild_layout_editor(*app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The editor button of one kind at one index, or null. The probe clicks the real
|
||||||
|
/// widgets rather than calling the handlers, so what it drives is what a user reaches.
|
||||||
|
GtkWidget* layout_button(App& a, bool insert, int index) {
|
||||||
|
for (GtkWidget* b : a.layout_buttons) {
|
||||||
|
const bool is_insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
|
||||||
|
if (is_insert == insert && row_key(b) == index) return b;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicks one editor button. `::clicked` directly and not `gtk_widget_activate()`: a
|
||||||
|
/// `GtkButton` activated that way holds the press for `ACTIVATE_TIMEOUT` (250 ms) and only
|
||||||
|
/// then emits `::clicked`, so every step of the probe read the *previous* step's result --
|
||||||
|
/// and the button whose timeout was pending is destroyed by the rebuild the step before it
|
||||||
|
/// caused, which dropped the click entirely.
|
||||||
|
void layout_click(GtkWidget* b) {
|
||||||
|
if (b != nullptr) g_signal_emit_by_name(b, "clicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user