Merge pull request 'Feature/source plugins' (#1) from feature/source-plugins into main
Reviewed-on: #1
No files matched your search
@@ -5,8 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## What this project is
|
## What this project is
|
||||||
|
|
||||||
udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one
|
udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one
|
||||||
UDP stream with `deps/udpsclient`, buffers it in a large ring (default 200 MB), and draws it
|
data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered
|
||||||
with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of
|
source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 10 M samples per signal, under an 8 GiB memory limit), and draws it
|
||||||
|
with `deps/plotter` — a figure of up to eight plots in stacked rows (a row can be split into up to four side by side) sharing one x window. On top of
|
||||||
the live view it provides the things a bench scope provides: a trigger with a pre/post event
|
the live view it provides the things a bench scope provides: a trigger with a pre/post event
|
||||||
window, a pause that freezes the visible window, cursors, statistical measurements,
|
window, a pause that freezes the visible window, cursors, statistical measurements,
|
||||||
user-defined math channels built in a node editor, and an export of the visible window to
|
user-defined math channels built in a node editor, and an export of the visible window to
|
||||||
@@ -18,9 +19,14 @@ Two documents bound the work and are **not** interchangeable with this one:
|
|||||||
- **`README.md`** documents what was actually *built*, including deliberate deviations from
|
- **`README.md`** documents what was actually *built*, including deliberate deviations from
|
||||||
the spec and known limitations. **Read it before concluding something is a bug.**
|
the spec and known limitations. **Read it before concluding something is a bug.**
|
||||||
|
|
||||||
|
**Deliberate deviation from `SPECS.md` line 1:** the sample buffer is budgeted in *samples per signal*
|
||||||
|
(`--buffer-samples`, rounded down to a power of two), not in megabytes, under a separate hard
|
||||||
|
`--memory-limit-mb` (also a Preferences row). There is no `--buffer-mb`; see README and `SPECS.md`'s
|
||||||
|
"Deviations from the spec".
|
||||||
|
|
||||||
Three things shape almost every change here, and the linked files expand on each:
|
Three things shape almost every change here, and the linked files expand on each:
|
||||||
|
|
||||||
- **There are exactly two threads** — a producer that owns the UDP client and the GTK main
|
- **There are exactly two threads** — a producer that owns the data source and the GTK main
|
||||||
thread that owns every widget and the GL context — and they share `ScopeModel` under one
|
thread that owns every widget and the GL context — and they share `ScopeModel` under one
|
||||||
mutex. Samples cross through lock-free rings, never through the lock.
|
mutex. Samples cross through lock-free rings, never through the lock.
|
||||||
- **There is no unit test suite.** The only automated check is the application's own headless
|
- **There is no unit test suite.** The only automated check is the application's own headless
|
||||||
@@ -38,20 +44,20 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
|
|||||||
| File | What it covers |
|
| File | What it covers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
|
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
|
||||||
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly three build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
|
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly one build warning; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
|
||||||
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
|
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
|
||||||
|
|
||||||
### How the application is put together
|
### How the application is put together
|
||||||
|
|
||||||
| File | What it covers |
|
| File | What it covers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`). |
|
| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`); the source plugins; the window's chrome (slide-over, badge bar, dock) and when each is built. |
|
||||||
|
|
||||||
### Invariants worth knowing before editing
|
### Invariants worth knowing before editing
|
||||||
|
|
||||||
| File | What it covers |
|
| File | What it covers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's overlay children, and the rule that **layout must not depend on content**. |
|
| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's and the slide-over's overlay children (with the pick rule), the badge bar's fixed height, and the rule that **layout must not depend on content**. |
|
||||||
| [invariants-readouts-and-cursors.md](docs/claude/invariants-readouts-and-cursors.md) | A readout quotes a stored sample and never a value between two; how the cursors are read back from the plots; the one formatter both panes share. |
|
| [invariants-readouts-and-cursors.md](docs/claude/invariants-readouts-and-cursors.md) | A readout quotes a stored sample and never a value between two; how the cursors are read back from the plots; the one formatter both panes share. |
|
||||||
| [invariants-data-path.md](docs/claude/invariants-data-path.md) | What rebuilds the buffer and drops history, why decimation at ingest is an **average** and never a stride, and the two rules the `f64` time ring and its cached first stamp impose. |
|
| [invariants-data-path.md](docs/claude/invariants-data-path.md) | What rebuilds the buffer and drops history, why decimation at ingest is an **average** and never a stride, and the two rules the `f64` time ring and its cached first stamp impose. |
|
||||||
| [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. |
|
| [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. |
|
||||||
@@ -60,4 +66,4 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
|
|||||||
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted with hysteresis, and which mean over a split is which. |
|
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted with hysteresis, and which mean over a split is which. |
|
||||||
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
|
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
|
||||||
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
|
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
|
||||||
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and how the Mallard manual is opened. |
|
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the bundled icons and their authoring rules, how the slide-over's click-away coexists with the plot's gestures, how the guided tour opens a page, and how the Mallard manual is opened. |
|
||||||
@@ -151,7 +151,7 @@ add_custom_command(
|
|||||||
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
|
# 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
|
# 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 bundle
|
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle
|
||||||
# is small enough per file never to need it), so the build stays at its three warnings. The manual is
|
# is small enough per file never to need it), so the build stays at its one warning. The manual is
|
||||||
# no longer compiled in, so the help bundle's own warning is gone with it.
|
# no longer compiled in, so the help bundle's own warning is gone with it.
|
||||||
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
||||||
@@ -236,6 +236,20 @@ add_executable(udpscope
|
|||||||
src/waveform.cpp
|
src/waveform.cpp
|
||||||
src/event_store.cpp
|
src/event_store.cpp
|
||||||
src/acquisition.cpp
|
src/acquisition.cpp
|
||||||
|
src/source/source_config.cpp
|
||||||
|
src/source/data_source.cpp
|
||||||
|
src/source/registry.cpp
|
||||||
|
src/source/udps_source.cpp
|
||||||
|
src/source/udps_kind.cpp
|
||||||
|
src/source/probe_source.cpp
|
||||||
|
src/source/e1608/e1608_link.cpp
|
||||||
|
src/source/e1608/e1608_device.cpp
|
||||||
|
src/source/e1608/e1608_source.cpp
|
||||||
|
src/source/e1608/e1608_kind.cpp
|
||||||
|
src/source/e1608/e1608_panels.cpp
|
||||||
|
src/source/flatudp/flatudp_signals.cpp
|
||||||
|
src/source/flatudp/flatudp_source.cpp
|
||||||
|
src/source/flatudp/flatudp_kind.cpp
|
||||||
src/plot_host.cpp
|
src/plot_host.cpp
|
||||||
src/export.cpp
|
src/export.cpp
|
||||||
src/search.cpp
|
src/search.cpp
|
||||||
@@ -255,10 +269,15 @@ add_executable(udpscope
|
|||||||
src/ui_status.cpp
|
src/ui_status.cpp
|
||||||
src/ui_measure.cpp
|
src/ui_measure.cpp
|
||||||
src/ui_cursors.cpp
|
src/ui_cursors.cpp
|
||||||
|
src/ui_dock.cpp
|
||||||
src/ui_math.cpp
|
src/ui_math.cpp
|
||||||
src/ui_layout.cpp
|
src/ui_layout.cpp
|
||||||
|
src/ui_source.cpp
|
||||||
|
src/ui_slideover.cpp
|
||||||
|
src/ui_badges.cpp
|
||||||
src/ui_trigger.cpp
|
src/ui_trigger.cpp
|
||||||
src/ui_signals.cpp
|
src/ui_signals.cpp
|
||||||
|
src/ui_signal_page.cpp
|
||||||
src/ui_context_help.cpp
|
src/ui_context_help.cpp
|
||||||
src/ui_tour.cpp
|
src/ui_tour.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,23 +22,48 @@ It is used for exactly one widget — the shortcut overview is an `AdwShortcutsD
|
|||||||
GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its
|
GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its
|
||||||
replacement.
|
replacement.
|
||||||
|
|
||||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
|
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples`
|
||||||
(size of the sample buffer, default 200), `--max-window` (largest time
|
(samples kept per acquired signal, default 10M; see "The sample buffer is budgeted in samples" below),
|
||||||
|
`--memory-limit-mb` (hard ceiling on the ring storage, default 8192), `--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 plots to start with, one per row, 1..8; rows are
|
||||||
added and removed from the **Layout editor** afterwards), `--relative-time` (see below),
|
added and removed and a row can be split into side-by-side plots from the **Layout editor** afterwards), `--relative-time` (see below),
|
||||||
`--capture-packets N` (see "Packet capture" below).
|
`--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; see "Data sources" below),
|
||||||
|
`--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer:
|
||||||
|
`cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both
|
||||||
|
`GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH
|
||||||
|
limitation below).
|
||||||
|
|
||||||
**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The
|
**The sample buffer is budgeted in samples per signal, under a memory limit.** `--buffer-samples N`
|
||||||
buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about
|
(`250000`, `64k`, `100M`, `1.5G`; k, M and G are decimal, a *sample* count and not a byte size) is how
|
||||||
|
many samples to keep for **every acquired signal**, and for the shared time ring. A ring is a power of
|
||||||
|
two, so the request is rounded **down** to one --- "up to" --- and the UI shows both figures: the
|
||||||
|
default 10 M asks for, and gets, 8 388 608 per signal, and `--buffer-samples 100M` gets 67 108 864. What that
|
||||||
|
costs in bytes follows from the channel count (`ticks x 8` for the clock, doubled in relative mode, plus
|
||||||
|
`ticks x 4` per channel), and the status popover and the badge text quote the *planned* bytes rather than a
|
||||||
|
figure the user typed. The ceiling on those bytes is a separate application setting,
|
||||||
|
**`--memory-limit-mb`** (default 8192 MiB; also a *Memory limit* row in **Preferences**, which re-plans the
|
||||||
|
live buffer when changed). `plan_partition()` starts at the power-of-two floor of the request and
|
||||||
|
**halves** it until the rings fit under the limit, and throws only when 128 samples do not fit. A clipped
|
||||||
|
plan is never silent: stderr gets one line (`[udpscope] buffer limited to 8.39 M samples per signal (100 M
|
||||||
|
requested) by the 256 MiB memory limit; the rings take 192 MiB`), the status popover's buffer row names the
|
||||||
|
limit, and the source form's note says so, assuming one signal there because the channel count is not
|
||||||
|
known before a stream is. `--buffer-mb` and the stored `buffer_mb` key are gone, with no compatibility
|
||||||
|
shim: the flag is an unknown argument, and an old `buffer_mb` in a saved configuration is ignored (the
|
||||||
|
source falls back to the default until it is saved again as `buffer_samples`). **This is a deliberate
|
||||||
|
change of the budget's unit** (it was megabytes of ring, which made the retained history depend on the
|
||||||
|
channel count and on relative time); it is recorded under "Deviations from the spec" in `SPECS.md`.
|
||||||
|
|
||||||
|
**What a large buffer 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 the planned rings --- about
|
||||||
a second per gigabyte, mostly first-touch page faults --- and it happens on the producer thread
|
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
|
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
|
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
|
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
|
for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at a gigabyte of rings), and
|
||||||
every enable of a signal is applied in one batch rather than re-planning per signal. Second, the
|
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
|
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
|
for --- `connecting to 127.0.0.1:8000 — planning the buffer: 134 M samples per signal` --- because the remaining second
|
||||||
or so at a multi-gigabyte buffer is unavoidable, and a blank window that says nothing is the part of
|
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
|
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
|
(`[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms`), so a run that prints a column of them
|
||||||
@@ -69,7 +94,7 @@ switches trigger mode on, waits for an event, then leaves trigger mode the way t
|
|||||||
"Go live" button does, closes the window, and prints on stdout the status line in
|
"Go live" button does, closes the window, and prints on stdout the status line in
|
||||||
both states, the frame and per-channel sample counts, each plot's painted-frame
|
both states, the frame and per-channel sample counts, each plot's painted-frame
|
||||||
count, which channel each trace slot holds, and the allocated size and reveal
|
count, which channel each trace slot holds, and the allocated size and reveal
|
||||||
state of both side panels.
|
state of the dock, and the events page's controls (present and nested in the page).
|
||||||
|
|
||||||
Every size it prints is read from a value the frame tick captured while the window was alive, not
|
Every size it prints is read from a value the frame tick captured while the window was alive, not
|
||||||
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
||||||
@@ -92,16 +117,172 @@ src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x lin
|
|||||||
src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers
|
src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers
|
||||||
src/layout_store.* the per-source session layout file (TOML), written and read back
|
src/layout_store.* the per-source session layout file (TOML), written and read back
|
||||||
src/event_series.hpp an immutable captured event as a plot::data::Series
|
src/event_series.hpp an immutable captured event as a plot::data::Series
|
||||||
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
|
src/ui.* the GTK4 window: toolbar, trigger form, signal list, plot box, the dock
|
||||||
|
src/ui_slideover.cpp the slide-over panel over the plot's right edge, one page at a time
|
||||||
|
src/ui_badges.cpp the badge bar under the plot: the signal badges, the + badge, Trigger and Source
|
||||||
|
src/ui_signal_page.cpp the per-signal slide-over pages (`signal:<name>`)
|
||||||
```
|
```
|
||||||
|
|
||||||
Threading: one producer thread owns the `udps_client_t` and calls
|
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left
|
||||||
`udps_client_poll()`; the GTK main thread owns every widget and the GL contexts.
|
part is the **signals**: one badge per *enabled* signal (a dot in the signal's colour, filled while the
|
||||||
|
signal is plotted, and its name), then a `+` badge that opens the **Add a signal** page (`g` does the same). That page proposes only what can be
|
||||||
|
*added*: the streamed signals that are not acquired and the math channels that are not computed, each row with
|
||||||
|
the tick that acquires it (the row then leaves the page and the signal's badge appears), plus the `MATH`
|
||||||
|
divider's `+` for a new math channel; when every signal is acquired a dim "every signal is acquired" line
|
||||||
|
stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired
|
||||||
|
signals too, since their rows are then the only way to each signal's page. There is no signal lock: a
|
||||||
|
signal's page and the list always allow acquire, colour, calibration, the math editor and delete.
|
||||||
|
A signal badge opens that signal's own page in the slide-over -- the unit, gain and offset and the CONFIG's details directly under the name (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N*
|
||||||
|
buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another
|
||||||
|
badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their
|
||||||
|
place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit
|
||||||
|
otherwise. The signal badges sit in a scroller, so the bar's minimum width, and with it the window's,
|
||||||
|
never depends on how many signals there are. The badge order is kept **for the session only**; it is not
|
||||||
|
written to the session file. The right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered`
|
||||||
|
or `disarmed`) and **Source** (the kind's name only -- the link state is the status bar's). **Source is shown only when the source
|
||||||
|
has panels of its own** (the E-1608's); for UDPS, Flat UDP and a file there is nothing for it to open, so it is hidden
|
||||||
|
(the bar keeps its height; the kind's name is in the status bar). A badge opens the **slide-over**: a 360 px
|
||||||
|
panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per
|
||||||
|
page. The Trigger badge opens the trigger form; the Source badge (when shown) opens the source kind's own panels
|
||||||
|
(the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It
|
||||||
|
closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never
|
||||||
|
resizes the plot. The right-hand **dock** (the sidebar, **280 px**, fixed) holds the **readouts** and, under
|
||||||
|
them, the **Events** block (below): the readouts are a compact, read-only copy of the cursors table (a header row `A B delta`, then one line per cursor
|
||||||
|
line) and of the measurements (one line each: the signal's colour chip, the type, the value). It is
|
||||||
|
shown from the start and insensitive until the stream has signals. Each of the two blocks has an
|
||||||
|
**Edit** button, which opens the full panel as a slide-over page -- `cursors` or `measure` -- over the
|
||||||
|
plot; `m` toggles the `measure` page, and a click on a measurement's line in the dock opens that
|
||||||
|
measurement's dialog. The header's **Cursors** toggle only switches the cursors on and off (it
|
||||||
|
mirrors the switch inside the `cursors` page); with the cursors off the dock's cursor lines are
|
||||||
|
insensitive, not hidden. Both pages are insensitive until the stream has signals, like the dock. The dock's
|
||||||
|
cells are a fixed number of characters wide and ellipsized, so the dock's width and height do not
|
||||||
|
depend on what a value reads: the **who** cell of a cursor line is 5 characters (the full text is its
|
||||||
|
tooltip), a value cell 9 (its tooltip is the full text). The dock and the pages show **one string**:
|
||||||
|
`update_cursors_pane()` and `update_measures()` format each value once and hand that variable to the
|
||||||
|
page's label and to the dock's, so the two cannot disagree. **When the dock is not beside the plot** -- hidden
|
||||||
|
with the dock button, or the window narrow enough (< 900 px, "compact mode") for the dock to be a collapsed
|
||||||
|
overlay on the plot, open or not -- the same readouts appear on **one line under the plot**, above the badge bar (the
|
||||||
|
*readouts strip*): a **Cursors** button (opens the `cursors` page) and one chip per cursor line (its name and
|
||||||
|
its *delta* value), then a **Measures** button (opens the `measure` page) and one chip per measurement (the
|
||||||
|
signal's colour chip, the type, the value; a click opens its dialog). Each of the two groups is there only
|
||||||
|
with something to read -- the Cursors group with the cursors switched on, the Measures group with at least one
|
||||||
|
measurement, the separator with both -- and **with neither, the strip is not shown at all**.
|
||||||
|
It is a **fixed 26 px** high and its chips are as wide as the dock's cells (name 6 characters, type 10, value
|
||||||
|
9, the full text in the tooltip); what does not fit scrolls sideways with no bar taking height. With the
|
||||||
|
cursors off their chips are insensitive and read `--`, as the dock's cells do (the group is hidden then, so this is
|
||||||
|
not seen). It is the **third reader of the
|
||||||
|
same strings** (the setters that write the dock's labels write the strip's), and it is shown exactly when
|
||||||
|
`(!show-sidebar || collapsed)` **and** (the cursors are on **or** there is a measurement) -- a deliberate
|
||||||
|
exception to "disable, do not hide": an empty line under the plot is 26 px of nothing, and the dock, which keeps
|
||||||
|
both blocks on screen, is where the empty state is shown. In a wide window it is hidden while the dock is shown beside the plot (so the
|
||||||
|
plot never loses its 26 px to both), and in a collapsed window it stays on screen even with the dock overlaid,
|
||||||
|
so the measurements and cursors remain visible in compact mode. The **Events** block is a heading with a
|
||||||
|
**Manage** button over a short list of the cached events, newest first -- one compact row each, never a
|
||||||
|
picture -- and one click on a row replays that event (see "Captured events"). The full list, with the
|
||||||
|
pictures and the comparison actions, is the `events` slide-over page that **Manage** opens. There is no
|
||||||
|
events pane beside the plot and no Events button in the header: events never take any of the plot's width.
|
||||||
|
The rest of the redesign is listed under "Review findings not (yet) implemented".
|
||||||
|
|
||||||
|
Threading: one producer thread owns the data source (the UDPStreamer client is the built-in
|
||||||
|
one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts.
|
||||||
They share `ScopeModel` under one mutex, which the producer takes once per DATA
|
They share `ScopeModel` under one mutex, which the producer takes once per DATA
|
||||||
frame and the GUI thread takes once per frame around binding the traces.
|
frame and the GUI thread takes once per frame around binding the traces.
|
||||||
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||||||
single consumer), never through the lock.
|
single consumer), never through the lock.
|
||||||
|
|
||||||
|
## Data sources
|
||||||
|
|
||||||
|
What udpscope shows is fed by a *source kind*; the connection chooser's **Source type** row picks one and
|
||||||
|
swaps in that kind's own connection form. The built-in kind is **UDPS**, the UDPStreamer client this
|
||||||
|
document describes everywhere else, and it is the default: `--server`, `--port`, `--multicast` and
|
||||||
|
`--interface` populate its entries, and the same thing is spelled `--source udps --source-opt server=...`.
|
||||||
|
`--help` lists the registered kinds. A second kind, `probe` (a synthetic sine with one panel), is registered
|
||||||
|
only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-check.
|
||||||
|
|
||||||
|
The second built-in kind is the **MCC E-1608** Ethernet DAQ (8 single-ended or 4 differential 16-bit inputs, two
|
||||||
|
analog outputs, eight digital lines and a 32-bit counter). Its connection form takes an address, or a MAC address
|
||||||
|
to look up (optionally with the subnet to sweep). A **Scan network** row at the top of the form probes every
|
||||||
|
network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a
|
||||||
|
click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not
|
||||||
|
saved: only the filled field is. A MAC with no subnet is looked up by a broadcast sent on every up interface: a
|
||||||
|
single limited broadcast leaves through the default route only, and on 2026-11-02 it missed a real board on
|
||||||
|
`net0` while the default route was `wlan0`. The form also groups the acquisition settings (scan rate, external clock,
|
||||||
|
trigger, restart after an overrun, factory calibration, the digital poll rate, the generator update rate), a range per analog input, and the
|
||||||
|
direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input
|
||||||
|
and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out**
|
||||||
|
(per DAC: a static level, or a sine, square, triangle or ramp from 0.1 to 10 Hz) and **Digital I/O** (a live LED per
|
||||||
|
input bit, a switch per output bit, the counter with a reset); they are insensitive while no board is connected.
|
||||||
|
The waveform generator writes the DACs at the **generator update rate** (`gen_rate`, 1 to 200 Hz, default 50), which
|
||||||
|
is separate from the input poll rate (`poll_rate`, which only reads the digital inputs and the counter). Each update
|
||||||
|
is a synchronous command to the board, so a board that answers slowly achieves less than was asked: the **Analog out**
|
||||||
|
tab ends with an **Update rate** row that shows the rate the generator actually achieved (measured over about a
|
||||||
|
second) next to the requested one, or `off` when no generator runs. The waveform frequency itself is still limited
|
||||||
|
to 0.1 to 10 Hz.
|
||||||
|
Acquisition is hardware-paced only (the scan rate or an external clock), the channels times the rate must stay at
|
||||||
|
or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets
|
||||||
|
`ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone.
|
||||||
|
|
||||||
|
**This kind has been run against a real board once, on 2026-11-02, with one channel.** The board was at
|
||||||
|
169.254.88.75 on `net0`, `ai0=BIP10V` single-ended with the input grounded, `scan_rate=1000`, for 7 s: the connection
|
||||||
|
and handshake succeeded, the source configured one channel and streamed it at the declared 1000 S/s (about 1250
|
||||||
|
samples in the first 1.25 s, no gaps, no overflows, no `[e1608]` errors), and the readings sat within a few LSB of
|
||||||
|
0 V (about 0.9 to 1.8 mV on a +-10 V range). Nothing else has been run on hardware; everything else is verified only
|
||||||
|
against `tools/mock_e1608.py`, a protocol mock written from two independent implementations of the wire protocol.
|
||||||
|
The following rest on a single reference or the two disagree, and are the first thing to check with more of the board
|
||||||
|
exercised (the mock's header lists the same items):
|
||||||
|
the counter reset command (0x31, from one reference only; not run on the board); the pacer value (the source sends the *aggregate*
|
||||||
|
rate, `round(80e6 / (scan_rate * channels) - 1)`, as one reference does; the other sends the per-channel
|
||||||
|
frequency; the two differ only with two or more channels, so the one-channel run cannot settle it); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only);
|
||||||
|
whether the handshake may answer status 2 ("code ignored"; one reference accepts it, the other refuses it, and the
|
||||||
|
source accepts it); the first frame id; and the maximum frame size. Two further things
|
||||||
|
are worth a look on a board even though the mock's header does not flag them: the calibration memory layout and
|
||||||
|
its coefficient type, and the status word's bits, in particular whether the "scan running" bit stays set while the
|
||||||
|
board waits for a trigger (with auto-restart on, the source restarts a scan that reads as stopped only when no
|
||||||
|
trigger is configured, so as not to reset the wait for the edge; this is unverified on hardware). The DIFF input
|
||||||
|
numbering (channel + 8) and the filler byte after scan start are agreed by both references. The scan overrun count
|
||||||
|
is approximate: the board does not say how many scans it dropped, so the frame after a restart reports one lost.
|
||||||
|
Errors (no device, a rejected connection code, a timeout, the scan socket closing) are written to stderr as
|
||||||
|
`[e1608] ...` once per distinct message (an overrun is logged each time it happens); the window shows only that
|
||||||
|
the source is disconnected (the status popover reads `waiting`, and the panels go insensitive), not why.
|
||||||
|
|
||||||
|
The third built-in kind is **Flat UDP**, a receiver for the MARTe2 `UDPSender` datasource: one datagram per
|
||||||
|
cycle, the signals concatenated in declaration order with no header, no padding and no timestamp, in the
|
||||||
|
sender's byte order. The datagram describes nothing, so **the signal list in the connection form is the
|
||||||
|
whole contract and is your responsibility**: one row per signal (a name, a type from `uint8` to `float64`,
|
||||||
|
and an element count), in the sender's order, including the `Trigger` byte an IndependentThread `UDPSender`
|
||||||
|
puts first. A row with a count above 1 is a vector, and a "Vector as" drop-down on that row says how to
|
||||||
|
show it: as **Channels** (the default), where `Wave:float32[16]` becomes 16 scalar channels `Wave[0]` ..
|
||||||
|
`Wave[15]` of one sample per cycle each, or as a **Waveform**, one channel that receives a block of 16
|
||||||
|
consecutive samples per cycle (its declared rate is the cycle rate times the count). The waveform is written
|
||||||
|
`Wave:float32[16]w` in the `signals` text, and the same text is what a session file and `--source-opt
|
||||||
|
signals=` carry. A list with a waveform row may hold **only waveform rows, all of the same count** (a
|
||||||
|
scalar or a channels row beside one, or two lengths, would need zero padding or held values, so the form
|
||||||
|
refuses it and says so), and a list may make **at most 256 channels** (a scalar is one, a channels vector is
|
||||||
|
`count`, a waveform is one). The form's subtitle shows the channel total and the size the list implies
|
||||||
|
("6 channels, 13 bytes per datagram"), or the first rule the list breaks; a datagram of any other size is
|
||||||
|
dropped, counted as stale, and reported once per distinct size on stderr as `[flatudp] ...`. The form also
|
||||||
|
takes the port (44488, the sender's own default), an optional bind address, an optional multicast group with
|
||||||
|
the interface to join it on, the byte order, and the sender's **cycle rate in Hz, which is required**: it
|
||||||
|
sizes the ring and the ingest decimation and spaces the samples within a frame. Time is stamped by the
|
||||||
|
existing pipeline from arrival and that declared rate (and `--relative-time` gives a counted clock); there is
|
||||||
|
no timestamp in the stream to prefer. **Loss is never reported** -- with no counter in the datagram, a missed
|
||||||
|
cycle is indistinguishable from a slow one -- and neither is a changed sender: a list that does not match
|
||||||
|
reads as "everything dropped". Source-address filtering, detecting the list from the traffic and importing a
|
||||||
|
MARTe2 `.cfg` are not provided. A list with no waveform row is delivered in frames of about 10 ms
|
||||||
|
(`round(rate / 100)` cycles, at most 1024; a partial frame is flushed after 100 ms); a list of waveforms
|
||||||
|
delivers one frame per datagram. The source is connected on the first valid datagram and disconnected after
|
||||||
|
2 s without one. It is verified against `tools/mock_flatudp.py`, not against a MARTe2 application. Multicast
|
||||||
|
was verified on a loopback group (`239.255.42.99` joined on `127.0.0.1`, although this machine's `lo` does not
|
||||||
|
carry the `MULTICAST` flag); a real network, its switches and IGMP were not exercised.
|
||||||
|
|
||||||
|
A session file written before kinds existed has no `source_kind`, and is read as UDPS; a UDPS file is
|
||||||
|
written exactly as before. A kind's capability panels (for instance a source's own controls) appear as
|
||||||
|
pages of the side stack, and are insensitive rather than hidden while no source is connected.
|
||||||
|
|
||||||
|
Planned, as its own sub-project: ABScope. It has more than one
|
||||||
|
timebase, so it will change the frame and the data path together rather than fit the present single-timebase
|
||||||
|
frame.
|
||||||
|
|
||||||
## The x axis is relative to now
|
## The x axis is relative to now
|
||||||
|
|
||||||
**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench
|
**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench
|
||||||
@@ -612,8 +793,12 @@ The history cost of relative mode is measured rather than computed:
|
|||||||
| stream time (default) | **83.886 s** |
|
| stream time (default) | **83.886 s** |
|
||||||
| `--relative-time` | **41.943 s** |
|
| `--relative-time` | **41.943 s** |
|
||||||
|
|
||||||
Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`.
|
Conditions: the mock at 100 kS/s, 4 channels at stride 1, the old byte budget (`--buffer-mb 200`, since removed). **That
|
||||||
Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and
|
no longer happens**: the buffer is budgeted in samples per signal, so both modes retain the same span
|
||||||
|
(8 388 608 ticks at the default) and relative mode instead costs a third more *memory* (32 against 24
|
||||||
|
bytes a tick with four channels; the `planner:` dump line in `docs/claude/probe-readings.md` reads it); only a memory limit that
|
||||||
|
bites clips it first. The paragraph below is the measurement that motivated it.
|
||||||
|
Under the old byte budget relative mode retained **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
|
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
|
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
|
`8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is
|
||||||
@@ -674,20 +859,36 @@ time to look at is still there:
|
|||||||
20261009-123250-0001.bmp # a picture of it, drawn from the samples
|
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
|
Events are read in two places. The **dock's Events block** lists them compactly -- one row per event, newest
|
||||||
default, and its **divider can be dragged**: it starts at 240 pixels and remembers the width you last
|
first, at most **24** with a dim "... N more, see Manage" row after them: a marker (a filled circle on the
|
||||||
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
|
event on screen, a hollow one on each member of the comparison or accumulation on screen), the time and
|
||||||
screen: with the cache disabled and nothing being examined there is nothing to show, and a visible
|
duration, and the event's sequence number (`#7`). **One click on a row replays it.** The block's **Manage**
|
||||||
empty column beside the plot is what *off* must not look like. Recording is switched in the **trigger
|
button opens the **`events` page**, a slide-over page over the plot with the full list (below), the *Live*
|
||||||
panel**, with the trigger's other options rather than in the pane whose contents it decides, and it is
|
button and the two group actions. The page replaces the left pane that used to sit beside the plot behind a
|
||||||
**off by default** -- writing every latch to disk is the user's decision about their disk.
|
header button and a draggable divider; both are gone, and so is the toolbar's Events button -- opening the
|
||||||
|
list no longer resizes the plot at all.
|
||||||
|
|
||||||
|
The block and the page exist all the time and are **insensitive, not hidden**, when there is nothing to show:
|
||||||
|
they are available while **recording is on**, while an event is on screen (a replay or a group), or while
|
||||||
|
the cache already holds events. **Deviation from the redesign spec,** which has the block insensitive
|
||||||
|
"outside trigger mode": a replay or a group *leaves* trigger mode (a file has no edge to wait for), so a block
|
||||||
|
that went grey outside trigger mode would grey itself under the very click that started the replay. Trigger
|
||||||
|
mode is therefore not one of the conditions. Recording is switched in the **trigger panel**, with the
|
||||||
|
trigger's other options rather than in the list whose contents it decides, and it is **off by default** --
|
||||||
|
writing every latch to disk is the user's decision about their disk. (The session file keeps nothing about
|
||||||
|
this list: there is no remembered width, because there is no pane to size.)
|
||||||
|
|
||||||
|
The dock follows every latch; the page's list is refreshed per latch only while it is open, and rebuilt when
|
||||||
|
**Manage** opens it. **The dock list has no thumbnails** -- its rows are fixed-width, ellipsized cells, so the
|
||||||
|
dock's size does not depend on what an event is called -- the page has them, and its rows are replayed with a
|
||||||
|
**double** click, because on the page a single click builds the selection that *Compare* and *Accumulate* read.
|
||||||
|
|
||||||
Each row carries **its own picture** beside the numbers -- time, duration, signal count, rate -- 138
|
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
|
pixels wide whatever the page'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
|
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**,
|
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.
|
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
|
Double-clicking a row on the page (or clicking one in the dock) **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
|
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
|
it, because marking which event is on screen rewrites the rows' titles in place instead of rebuilding
|
||||||
the list. **The layout does
|
the list. **The layout does
|
||||||
@@ -698,7 +899,7 @@ rearrange the user's work under a click. What changes is the data, and the trigg
|
|||||||
file has no edge to wait for. Cursors, measurements, math channels and another export all work on 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.
|
stored event. **Live** takes you back to the stream.
|
||||||
|
|
||||||
Two buttons under the pane's header read the **selection**:
|
Two buttons under the page's header read the **selection**:
|
||||||
|
|
||||||
- *Compare* puts the two selected events in **two plots side by side**, each with **the layout you
|
- *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
|
already have** -- the same rows, the same signals in the same slots, the same colours -- one showing
|
||||||
@@ -716,8 +917,8 @@ model whose channel count no longer matches the stream), the trigger is left, an
|
|||||||
|
|
||||||
A replay leaves trigger mode, because a file has no edge to wait for -- an armed trigger keeps a
|
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
|
*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
|
held display draws that snapshot rather than the file that was just loaded. The dock's Events block and
|
||||||
open through it: it is available while a stored event is on screen.
|
the `events` page stay available through it: they are 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
|
**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
|
are not the user's choice -- the time window becomes the event's own span, because that is what
|
||||||
@@ -748,7 +949,7 @@ Three things worth knowing:
|
|||||||
- **The picture is decoded synchronously** (`gdk_texture_new_from_filename()` into
|
- **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
|
`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
|
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.
|
paint-able and an allocation, so a probe reports success while the page shows an empty rectangle.
|
||||||
It is then **scaled once to the row's size** and cached by file: a `GtkPicture`'s natural size is
|
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
|
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.
|
and squeezes the text out of it -- a size request on the picture is a floor, not a cap.
|
||||||
@@ -761,12 +962,12 @@ Three things worth knowing:
|
|||||||
replayed samples age out of the ring as the stream refills it.
|
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
|
- **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
|
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
|
allocates --- and so touches --- the whole of the planned rings; 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
|
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
|
enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of
|
||||||
allocating a new one.
|
allocating a new one.
|
||||||
- The switch in the pane's header stops the recording; the cache is only useful if it was already
|
- The switch in the trigger panel starts and stops the recording; the cache is only useful if it was
|
||||||
on when the event you wanted happened, which is why it defaults to on.
|
already on when the event you wanted happened, so switch it on before the event you are waiting for.
|
||||||
|
|
||||||
## Opening an exported waveform
|
## Opening an exported waveform
|
||||||
|
|
||||||
@@ -795,15 +996,15 @@ Only `.npy` is read: it is the format the item asked for and the one that carrie
|
|||||||
export is for other tools.
|
export is for other tools.
|
||||||
|
|
||||||
**A recording longer than the buffer can hold at its own rate is resampled, never cut short.** The
|
**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
|
file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-samples`: 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
|
channels at 500 kS/s is 10 M samples a channel, which the default 8.39 M cannot hold. The loader therefore reads
|
||||||
the decimation the plan chose and pushes one stored sample per group of file rows, carrying the
|
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
|
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
|
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
|
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
|
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
|
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.
|
larger `--buffer-samples` (the source's configuration carries one) to see such a file sample for sample.
|
||||||
|
|
||||||
## Named configurations
|
## Named configurations
|
||||||
|
|
||||||
@@ -825,18 +1026,18 @@ measurements — **and the settings that can only be decided at startup**, which
|
|||||||
here rather than only on the command line:
|
here rather than only on the command line:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty"
|
comment = "5 kHz sine, trigger on Ch1, 256 k samples is plenty"
|
||||||
buffer_mb = 8
|
buffer_samples = 262144
|
||||||
relative_time = false
|
relative_time = false
|
||||||
capture_packets = 0
|
capture_packets = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
The **buffer size** is the one that matters most: two sources on one machine rarely want the same
|
The **buffer size** (samples per signal) 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
|
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
|
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
|
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
|
`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`,
|
ring, and a flag the user actually typed wins over the file (`--buffer-samples`, `--relative-time`,
|
||||||
`--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running
|
`--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
|
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
|
so in the status line about the rest (buffer, clock, packet capture): they take effect on the next
|
||||||
@@ -876,7 +1077,11 @@ applies as it is made — there is no Apply button, and a **Reset to defaults**
|
|||||||
dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a
|
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
|
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
|
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
|
rather than an error. The **Sample buffer** group holds the *Memory limit (MB)* row (`memory_limit_mb`,
|
||||||
|
default 8192): the hard ceiling on the ring storage for the whole application, which the planner halves the
|
||||||
|
sample budget to fit. It is the one planner input that changes while the app runs, so changing it
|
||||||
|
re-plans the live buffer --- dropping history only if the storage actually changes --- except while a
|
||||||
|
recording or a replay is on screen, where it is stored for the next plan. 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
|
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
|
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
|
button beside it, which writes what it found into the same field -- the field stays the one value, and
|
||||||
@@ -900,6 +1105,9 @@ plots = 1
|
|||||||
measure_stats = false
|
measure_stats = false
|
||||||
|
|
||||||
[[plot]]
|
[[plot]]
|
||||||
|
row = 0
|
||||||
|
width = 1
|
||||||
|
weight = 1
|
||||||
signals = ["Ch1", "", "", ""]
|
signals = ["Ch1", "", "", ""]
|
||||||
|
|
||||||
[[calibration]]
|
[[calibration]]
|
||||||
@@ -908,8 +1116,18 @@ gain = 2
|
|||||||
offset = 0.5
|
offset = 0.5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each `[[plot]]` table is one **plot** (one cell of the layout), in left-to-right, top-to-bottom order.
|
||||||
|
`row` is the figure row the plot sits in (0-based) and `width` its share of that row's width; `weight` is
|
||||||
|
the *row's* share of the figure's height, written on every plot of the row and read from the row's first
|
||||||
|
one. A session with no `row` keys -- every file written before a row could be split -- loads as one plot per
|
||||||
|
row, and so does one whose `row` numbers cannot be a layout (a number that skips or goes back, more than 4
|
||||||
|
plots in a row or 8 in all): the file is never refused for it, and the signals stay where they were. See
|
||||||
|
"Splitting a row".
|
||||||
|
|
||||||
It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means
|
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
|
`false`) and `dock` (whether the dock is shown, a boolean, absent means `true`; it replaced the
|
||||||
|
sidebar's `side_tab`, and a file that still carries `side_tab` loads, the key being ignored). The
|
||||||
|
open slide-over page is not stored: a panel that opens by itself at start-up would be a surprise. 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
|
||||||
@@ -1054,18 +1272,29 @@ up, the same delay the first-run auto-start uses, the pending request starts the
|
|||||||
chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds
|
chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds
|
||||||
them, and this dialog binds none.
|
them, and this dialog binds none.
|
||||||
|
|
||||||
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot,
|
**The guided tour is six steps on a `GtkOverlay`** inserted above the window's content: the plot,
|
||||||
the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and
|
the badge bar, the trigger form, the dock, the signals and the status line. There is no popover anywhere in it and
|
||||||
therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below
|
therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below
|
||||||
and either side of the target -- that swallow the clicks that land on them. A stop declares the
|
and either side of the target -- that swallow the clicks that land on them. A stop declares the
|
||||||
**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named
|
**slide-over page it stands on** (the trigger step names `trigger`, in `TourStop::page`): the tour opens
|
||||||
in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after*
|
that page as the step is entered, and a stop with no page *closes* the panel, so the card never points
|
||||||
the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a
|
at the status line over an open panel. It does not spotlight the form until the panel has finished
|
||||||
widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()`
|
sliding in (about 120 ms, during which the surface is dimmed and the card waits in the corner), and the
|
||||||
decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page
|
hole is the target's bounds clipped by every scroller above it -- on the documented 756x470 window the
|
||||||
reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it
|
trigger form is taller than the panel, and the spotlight covers the part that is on screen, not the
|
||||||
(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all
|
badge bar under it. Skip, Escape and the last step close a panel the tour opened (an idle dismissal
|
||||||
`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's
|
leaves one the user opened alone). `gtk_widget_get_mapped()`
|
||||||
|
decides which steps exist at all: a stop on a slide-over page is always kept, because opening the page
|
||||||
|
reveals it, while one whose *pane is collapsed* (the dock, in a window too narrow to put it beside the
|
||||||
|
plot) is dropped, because it is unmapped with its pane (a hidden page keeps its own *visible* flag
|
||||||
|
true -- the widget's own flag, which is all `is_visible()` walks -- so the old test walked over
|
||||||
|
surfaces that were not on screen); a narrow window therefore walks five steps. The dock's stop
|
||||||
|
describes the readouts and their **Edit** buttons and the Events block under them (one click on a row
|
||||||
|
replays it, **Manage** opens the list); it names no slide-over page, so it closes the panel like the plot's.
|
||||||
|
There is no stop for the `events` page, so nothing in the tour opens it; the contextual help's
|
||||||
|
event-history hint is attached to the page's heading the way the cursors' and measurements' are. The cursors and measurements pages have no stop of their own, and their contextual
|
||||||
|
help "(i)" hints are attached to the pages' headings the way the trigger's and the signals' are: they
|
||||||
|
are drawn while the page is open, and the dock has none. The chooser's
|
||||||
tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and
|
tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and
|
||||||
stepping off it -- in either direction -- brings the *Sources* page back.
|
stepping off it -- in either direction -- brings the *Sources* page back.
|
||||||
|
|
||||||
@@ -1076,7 +1305,7 @@ same reason: a frame can arrive between two layout passes, and a hole that empti
|
|||||||
the spotlight and the card jump. The card goes under the target, over it, or in the column beside it
|
the spotlight and the card jump. The card goes under the target, over it, or in the column beside it
|
||||||
-- whichever has room -- and only takes the bottom corner when the target *is* the whole content,
|
-- whichever has room -- and only takes the bottom corner when the target *is* the whole content,
|
||||||
which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives
|
which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives
|
||||||
the window its 756x894 minimum, and at that width the split view overlays the side panel instead of
|
the window its 756x894 minimum, and at that width the split view overlays the dock instead of
|
||||||
putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than
|
putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than
|
||||||
asserting a placement, so that case reads as the geometry it is.
|
asserting a placement, so that case reads as the geometry it is.
|
||||||
|
|
||||||
@@ -1112,11 +1341,11 @@ allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value
|
|||||||
ring per channel (N enabled signals -- see the limitation on multi-element signals
|
ring per channel (N enabled signals -- see the limitation on multi-element signals
|
||||||
below). The spec's second buffer is the latched `EventSnapshot`, which is already
|
below). The spec's second buffer is the latched `EventSnapshot`, which is already
|
||||||
an immutable copy of the captured window (see "Trigger" below); a second live ring
|
an immutable copy of the captured window (see "Trigger" below); a second live ring
|
||||||
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
|
bought nothing and cost half the memory, so the planned bytes the status line quotes are
|
||||||
200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see
|
the whole of it. What it *would* buy is priced in the dump's `publish cost:` line — see
|
||||||
"There is one live buffer, not two" under the review findings.
|
"There is one live buffer, not two" under the review findings.
|
||||||
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
|
`plan_partition()` sizes the rings to the sample budget (rounded down to a power of two, halved
|
||||||
them with the rest of the budget. When a channel's declared sample rate is lower
|
until it fits the memory limit) and then decimates the shared clock until `--max-window` seconds fit. When a channel's declared sample rate is lower
|
||||||
than the fastest channel's, it is decimated by the integer ratio
|
than the fastest channel's, it is decimated by the integer ratio
|
||||||
(`Partition::stride`); when the requested window does not fit the budget, the shared
|
(`Partition::stride`); when the requested window does not fit the budget, the shared
|
||||||
clock is resampled too (`Partition::time_stride`, an averaging decimation — see
|
clock is resampled too (`Partition::time_stride`, an averaging decimation — see
|
||||||
@@ -1214,6 +1443,30 @@ back to the *previous* event, or to blank if this was the first. No event fired,
|
|||||||
latch counter does not move; a separate `ScopeModel::event_seq()` counts published
|
latch counter does not move; a separate `ScopeModel::event_seq()` counts published
|
||||||
snapshots, and that is what the frame tick keys its framing on.
|
snapshots, and that is what the frame tick keys its framing on.
|
||||||
|
|
||||||
|
**Force takes one acquisition now.** The trigger panel's **Force** button, beside
|
||||||
|
Arm/Disarm, is the scope's "capture this instant": for the condition that is not coming,
|
||||||
|
it opens a capture at the present moment with the configured pre/post window rather than
|
||||||
|
waiting for an edge. It is deliberately *not* a second publisher — `TriggerEngine::force()`
|
||||||
|
only puts the engine into the pending state an edge leaves it in, with the newest stored
|
||||||
|
stamp as the event's own time, and the ordinary latch does everything else: the cut, the
|
||||||
|
publish, the latch count, the marker at x = 0 and the framing. A forced acquisition is
|
||||||
|
therefore framed, pinned and recorded exactly like a latched one, down to the axis being
|
||||||
|
pinned from the snapshot's own stamps rather than from what was asked for (a request the
|
||||||
|
ring cannot reach back to — a 100 s window at 99 % pre — comes out seconds wide, which is
|
||||||
|
what the probe's second leg reads).
|
||||||
|
|
||||||
|
Two choices are worth naming. The engine's pre-window gate is **skipped**: the history
|
||||||
|
behind "now" is whatever the ring holds, and refusing to capture it because the ring is
|
||||||
|
young would refuse the one thing the button is for. And a capture already filling is **not**
|
||||||
|
restarted — it is the acquisition being asked for, and restarting it would throw away the
|
||||||
|
post window it has collected; the click is ignored (and said so on stderr) in that state
|
||||||
|
only. What the trigger *is* afterwards is still the mode's decision, made where it always
|
||||||
|
was: Normal leaves it armed for the next edge, Single disarms, because one acquisition is
|
||||||
|
what the mode means whether an edge produced it or the button. The button lives in the body
|
||||||
|
of the pane rather than in the locked pane's heading beside Arm/Disarm: the heading's one
|
||||||
|
control is the runtime *state* a locked pane exists to change, while a force is an action
|
||||||
|
whose subject — the condition, the level, the pre/post split and the mode — is in the body.
|
||||||
|
|
||||||
The frame that **frames** a snapshot is the one that consumes its sequence number, not
|
The frame that **frames** a snapshot is the one that consumes its sequence number, not
|
||||||
merely the first frame to see it. The tick reads `display_mode()` before it takes the
|
merely the first frame to see it. The tick reads `display_mode()` before it takes the
|
||||||
model's mutex, while the producer publishes the snapshot inside that mutex and only lowers
|
model's mutex, while the producer publishes the snapshot inside that mutex and only lowers
|
||||||
@@ -1233,6 +1486,62 @@ snapshot's own stamps. That re-basing machinery already existed (it was written
|
|||||||
frozen view and then never needed, since snapshots carry edge-relative stamps); this is
|
frozen view and then never needed, since snapshots carry edge-relative stamps); this is
|
||||||
what finally uses it.
|
what finally uses it.
|
||||||
|
|
||||||
|
**Multiple events: the last N acquisitions, dimmer with age.** Trigger mode can keep the last
|
||||||
|
`N` acquisitions on screen at once, each older one drawn at half the brightness of the one before
|
||||||
|
it (1, 0.5, 0.25, 0.125, …), which is what a phosphor display does with successive sweeps: they
|
||||||
|
are aligned on their own trigger edge, so the shape of the event repeats and whatever varies
|
||||||
|
between acquisitions — jitter, ringing, an occasional glitch — reads as a faint ghost behind the
|
||||||
|
current trace. <gui>Persistence</gui> in the trigger panel is the knob; `1`, the default, is
|
||||||
|
exactly the display without it, and the range stops at 8, where the eighth layer would be 0.8 %
|
||||||
|
ink that nothing can see. The older acquisitions are drawn *behind* the current one and are never
|
||||||
|
pinned: the axis, the framing and every measurement still describe the event on screen.
|
||||||
|
`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained
|
||||||
|
snapshots there and then, because a trigger that has stopped firing never reaches another publish.
|
||||||
|
|
||||||
|
**Persistence: one image per signal, every acquisition.** The trigger panel's `Advanced` block
|
||||||
|
carries the *other* display of the same acquisitions, and it is not a count at all: `persistence`
|
||||||
|
turns on a **phosphor density** — the plotter's intensity image (`Plot::set_density()`, the axes rect
|
||||||
|
in device pixels **per trace, in that trace's own signal colour**, bounded by the plotter's
|
||||||
|
`kDensityCellCap`: past **262 144 cells** — a mebibyte of counts — it scales the rect down uniformly
|
||||||
|
and magnifies the image back over it with a linear filter)
|
||||||
|
into which every triggered acquisition is deposited, the newest included, and which is drawn *in
|
||||||
|
place of* the traces. The current acquisition is therefore a sweep *inside* the image and fades with
|
||||||
|
the rest; no solid polyline is drawn over it, which is what a report of "the persistent image plus
|
||||||
|
the last acquisition, and the last one never fades in time mode" turned out to be. A cell's
|
||||||
|
brightness is how many acquisitions passed through it — one sweep's ink is the trace's own colour,
|
||||||
|
and where several agree it clamps there rather than going white — so the display brightens where the
|
||||||
|
signal is steady and dims where it only passed, **with no depth to choose and no memory that grows
|
||||||
|
with the acquisitions**: the cost is bounded, and the bound is the grid's — at most a mebibyte of
|
||||||
|
counts a signal, so the app's four signals hold 4 MiB between them however wide the window, and the
|
||||||
|
probe's four one-signal rows read `cells=1044714` (4 178 856 bytes, 1 044 714 of them uploaded on a
|
||||||
|
moved frame) — and a thousand acquisitions cost the same. Each signal is a separate image, so two
|
||||||
|
signals agreeing in a cell read as two colours and not one blended grey. `fades by` picks how it
|
||||||
|
decays: `acquisitions` (each acquisition keeps the factor of what was before it, 0.5 by default —
|
||||||
|
the phosphor afterglow schedule) or `time` (a time constant in stream seconds, so the display fades
|
||||||
|
while you watch whatever the trigger rate). It applies in **trigger mode only** — while a captured
|
||||||
|
acquisition is on screen — and it goes away with the trigger, on a pause and on an opened file,
|
||||||
|
because the image is an image *of* a stream of acquisitions. Like the invalid-span bands it reserves
|
||||||
|
no layout space and nothing on it can be picked. The `[density-probe]` run that measured it is in
|
||||||
|
`docs/claude/probe-readings.md`; what it costs is there and in the plotter's
|
||||||
|
`docs/data-and-performance.md`, where the density's own pass is priced headless at a fixed geometry —
|
||||||
|
the app's in-run A/B of it is a mean against a mean and the "before" side includes the run's own
|
||||||
|
startup frames. A 3040x1700 framebuffer with four rows of one trace and a 2 s window (the frozen
|
||||||
|
configuration: at 2x device scale that is a 1520x850 window) read **4.5 M cells a trace, 12.9 ms of
|
||||||
|
tick and 12.8 ms of draw a frame and 4.9 MB uploaded** on llvmpipe, and reads **1.0 M cells, 3.9 /
|
||||||
|
5.6 ms and 1.0 MB** at the cap.
|
||||||
|
|
||||||
|
**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
|
||||||
|
the retention is bounded by two things: the depth, and **the planned buffer** — persistence may hold at
|
||||||
|
most as many bytes again as the rings take (`persist_budget_bytes()`), which is the bound a long window on a fast stream reaches
|
||||||
|
long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot
|
||||||
|
in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app
|
||||||
|
starts with — has room for one older acquisition of each, while a row per signal has room for all
|
||||||
|
seven. A depth that the room or the budget cuts short is counted and said in a notice, rather than
|
||||||
|
quietly drawing fewer layers than were asked for. The layers are *not* signals: they hold no
|
||||||
|
channel, so the signal list, the colour chips, the calibration table, the layout file and every
|
||||||
|
measurement see one signal where the plot draws several traces of it — the same rule the event
|
||||||
|
*pane*'s accumulate view follows.
|
||||||
|
|
||||||
**The whole trigger path measures the window in stream seconds, and never in
|
**The whole trigger path measures the window in stream seconds, and never in
|
||||||
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
|
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
|
||||||
has advanced past the post window; `publish_event()` then finds the slice covering
|
has advanced past the post window; `publish_event()` then finds the slice covering
|
||||||
@@ -1387,9 +1696,10 @@ it holds more than the window the bound became the whole ring, and a zoom-out wa
|
|||||||
view past the window into data the window was never set to show. A partly filled window
|
view past the window into data the window was never set to show. A partly filled window
|
||||||
simply has an empty left-hand side.
|
simply has an empty left-hand side.
|
||||||
|
|
||||||
**The panel switches are on the toolbar, and the panes own their own controls.** Cursors,
|
**The panel switches are on the toolbar, and the panes own their own controls.** (Since the
|
||||||
Measurements and Math channels are icon toggles in the header bar rather than rows in the overflow
|
slide-over redesign the toolbar keeps only the **Cursors** toggle of the three icons named here, and it
|
||||||
menu, and **Reset cursors** sits in the cursors pane beside the mode it resets (it acts on that
|
only enables the cursors; the measurements are reached by `m` or the dock's Edit button, and the math
|
||||||
|
editor from the signal list.) **Reset cursors** sits in the cursors page beside the mode it resets (it acts on that
|
||||||
pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are
|
pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are
|
||||||
read against the span on screen, so they sit with the control that sets it — then, on the right,
|
read against the span on screen, so they sit with the control that sets it — then, on the right,
|
||||||
**cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the
|
**cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the
|
||||||
@@ -1439,7 +1749,7 @@ enabled only to feed a math channel.
|
|||||||
several, and there was no way to see which sources the app already knew about. The chooser lists
|
several, and there was no way to see which sources the app already knew about. The chooser lists
|
||||||
one row per stored session: the server and port it was named after, how many plots and signals it
|
one row per stored session: the server and port it was named after, how many plots and signals it
|
||||||
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
|
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
|
||||||
server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning
|
server, port, multicast group, interface — and nothing more: `--buffer-samples` and the rest are tuning
|
||||||
rather than "which source", and a form that asks for them is a form you have to read before using
|
rather than "which source", and a form that asks for them is a form you have to read before using
|
||||||
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
|
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
|
||||||
failure.
|
failure.
|
||||||
@@ -1638,6 +1948,35 @@ Measured on the same stream and window: tick mean **1.39 ms** with the pane hold
|
|||||||
measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every
|
measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every
|
||||||
400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this.
|
400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this.
|
||||||
|
|
||||||
|
**...and the same pane costs what the *window* costs, which a big time scale buys.** The three
|
||||||
|
fixes above bound *how often* and *how much per sample*; what is left is the arithmetic of the
|
||||||
|
scale: a pass is priced per measured **channel** (one fetch of the window's level-0 buckets, one
|
||||||
|
statistics scan) and per **distinct share** in the pane (one level accumulator, walked over those
|
||||||
|
buckets), and the window's own sample count multiplies both. Measured with the new
|
||||||
|
`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-samples 100M` (so `time_stride`
|
||||||
|
stays 1 and the window holds 11.8 M samples, 184 k buckets), four signals, one row, on llvmpipe
|
||||||
|
— `measure cost:`'s `each` per recompute, and the tick's mean:
|
||||||
|
|
||||||
|
| pane | `each` | `raw` | tick mean |
|
||||||
|
|---|---|---|---|
|
||||||
|
| empty | — | — | 1.34 ms |
|
||||||
|
| 1 measurement, 1 level, 1 channel | 6.58 ms | 0.1 % | 1.99 ms |
|
||||||
|
| 4 measurements, 4 shares, 1 channel | 10.28 ms | 0.4 % | 2.38 ms |
|
||||||
|
| 16 measurements, 16 shares, 1 channel | 24.55 ms | 1.2 % | 3.86 ms |
|
||||||
|
| 4 measurements, 1 share each, **4 channels** | 35.69 ms | 2.6 % | 4.58 ms |
|
||||||
|
|
||||||
|
So a machine's ten measured signals at a 15 s window is tens of milliseconds a pass, several
|
||||||
|
times a second, on the **tick** — the pane is a *frame* cost and not an ingest one (`gaps` and
|
||||||
|
`packets` stay clean even though each reader holds the model mutex, which is what "the plot is
|
||||||
|
slow with measurements on" actually is). One of those terms was a pure call-overhead defect:
|
||||||
|
pass two folds each bucket into each level through `measure_accumulate_bucket()`, the innermost
|
||||||
|
loop of a walk whose iteration count is the window's buckets *times* the pane's levels, and as an
|
||||||
|
out-of-line function in its own translation unit it cost **30 % of the pass** — sixteen shares on
|
||||||
|
one channel read `24.55 ms` a recompute with every value in `measurements:` unchanged at
|
||||||
|
**17.25 ms** once it was moved into `measure.hpp` as `inline`. The fetch's three out-of-line
|
||||||
|
pyramid accessors per bucket (3.3 ms of an 11.8 M-sample window's 11.6 ms pass) are the next
|
||||||
|
lever, and `raw` is the last one and belongs to the signal rather than to the pane.
|
||||||
|
|
||||||
**"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an
|
**"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an
|
||||||
`h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four
|
`h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four
|
||||||
measurements can put six lines over the data — that is the feature, and the lines move with the
|
measurements can put six lines over the data — that is the feature, and the lines move with the
|
||||||
@@ -1757,9 +2096,9 @@ a sample and runs out on a clock. Widening only the clock keeps the cost where i
|
|||||||
the rings are the memory budget, and the values are the overwhelming majority of them. The
|
the rings are the memory budget, and the values are the overwhelming majority of them. The
|
||||||
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
|
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
|
||||||
slots, because a time slot and a value sample are now different sizes — it weighs the
|
slots, because a time slot and a value sample are now different sizes — it weighs the
|
||||||
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
|
budget in **bytes** against the memory limit (`sizeof(f64)` for the shared clock plus `sizeof(f32)` per
|
||||||
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
|
channel); the sample budget itself is a count of ticks, so a three-channel stream at
|
||||||
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
|
`--buffer-samples 10M` retains the same 8.39 M ticks it did before. `EventSnapshot::stamps` is
|
||||||
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
|
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
|
||||||
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
|
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
|
||||||
stream time, which quantised at exactly the rate the live ring did.
|
stream time, which quantised at exactly the rate the live ring did.
|
||||||
@@ -2024,7 +2363,7 @@ exists; it is there rather than on the chip because a second gesture on a one-gl
|
|||||||
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
|
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
|
||||||
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
|
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
|
||||||
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
|
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
|
||||||
chip cannot become the start of a row drag.
|
chip cannot also activate the row (and open its page).
|
||||||
|
|
||||||
A `GtkSwitch` and two
|
A `GtkSwitch` and two
|
||||||
always-visible buttons made a row about twice as tall (46 px against 26 px now),
|
always-visible buttons made a row about twice as tall (46 px against 26 px now),
|
||||||
@@ -2033,7 +2372,7 @@ button and list-row minimums, not the widgets themselves, set that height, so th
|
|||||||
row carries a `.udpscope-compact` style class that relaxes them.
|
row carries a `.udpscope-compact` style class that relaxes them.
|
||||||
|
|
||||||
**Signals start disabled**, because enabling one is what starts acquiring it.
|
**Signals start disabled**, because enabling one is what starts acquiring it.
|
||||||
Enable a signal with its checkbox, or drag it onto a plot: the drop enables the
|
Enable a signal with its checkbox, or drag its badge from the badge bar onto a plot: the drop enables the
|
||||||
signal if needed, binds it to that row's first free trace slot, and rebuilds the
|
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
|
||||||
@@ -2240,7 +2579,9 @@ out of the plot **while a gesture is on that row**, because a drag writes the *p
|
|||||||
the only place it is visible. Outside a gesture the plot is not read at all: `cursor.x` is the
|
the only place it is visible. Outside a gesture the plot is not read at all: `cursor.x` is the
|
||||||
snapped value then, which is what the app wrote itself a frame earlier, and adopting that back
|
snapped value then, which is what the app wrote itself a frame earlier, and adopting that back
|
||||||
is what made a dragged cursor spring to where it had been. A ruler is per row: each row has its own y axis, so two rows' rulers are
|
is what made a dragged cursor spring to where it had been. A ruler is per row: each row has its own y axis, so two rows' rulers are
|
||||||
two different values, and the pane names the row.
|
two different values, and the pane names the row. In a split row (see "Splitting a row") the vertical pair is drawn in
|
||||||
|
every plot, and so is the horizontal pair: the plots of a split row share one y axis, so the pane lists one ruler
|
||||||
|
line for the whole row, and dragging a ruler in any plot moves it in all of them.
|
||||||
|
|
||||||
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
|
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
|
||||||
through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
|
through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
|
||||||
@@ -2402,7 +2743,7 @@ This reverses the earlier "the app does no decimation at all" decision, and keep
|
|||||||
reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a
|
reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a
|
||||||
full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data.
|
full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data.
|
||||||
The headless check is the same aliasing case with the filter in place: at
|
The headless check is the same aliasing case with the filter in place: at
|
||||||
`--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s
|
`--buffer-samples 65536 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s
|
||||||
stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly
|
stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly
|
||||||
the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost.
|
the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost.
|
||||||
|
|
||||||
@@ -2433,8 +2774,9 @@ full range — the snapshot's own span, which is legitimately *narrower* than th
|
|||||||
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
|
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
|
||||||
back onto the stream.
|
back onto the stream.
|
||||||
|
|
||||||
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
|
**Shared x axis.** The plots are the plots of **one `plot::Figure`** (one per row, or several side by
|
||||||
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
|
side in a split row, see "Splitting a row"), drawn by one `plot::gtk4::PlotArea` in figure mode, and they
|
||||||
|
all join a single x link group.
|
||||||
A gesture changes the row it landed on; `Figure::layout()` adopts that change
|
A gesture changes the row it landed on; `Figure::layout()` adopts that change
|
||||||
into the group and pushes it to the others, so panning or zooming any row moves
|
into the group and pushes it to the others, so panning or zooming any row moves
|
||||||
them all with nothing to propagate by hand. The live follow is the *group's*
|
them all with nothing to propagate by hand. The live follow is the *group's*
|
||||||
@@ -2448,8 +2790,8 @@ The self-check's layout line carries `x-desync N`, the number of frames in which
|
|||||||
any row's x range differed from row 0's by more than a pixel of data. **It must
|
any row's x range differed from row 0's by more than a pixel of data. **It must
|
||||||
read 0.**
|
read 0.**
|
||||||
|
|
||||||
`Figure`'s row count is fixed at construction, so there is one structural path:
|
`Figure`'s shape (its cells per row) is fixed at construction, so there is one structural path:
|
||||||
a changed row count, or a changed legend name (the one thing a `Plot` cannot
|
a changed row or cell count, or a changed legend name (the one thing a `Plot` cannot
|
||||||
express), builds a new figure and the area is handed it. That costs no GL state —
|
express), builds a new figure and the area is handed it. That costs no GL state —
|
||||||
the widget keeps its context. A displayed event survives the rebuild because the
|
the widget keeps its context. A displayed event survives the rebuild because the
|
||||||
pinned `[-pre, +post]` window is restated rather than re-derived from the live
|
pinned `[-pre, +post]` window is restated rather than re-derived from the live
|
||||||
@@ -2480,6 +2822,44 @@ above row 2" are the same insertion, and two buttons there would sit one on top
|
|||||||
other. The `rows + 1` boundary buttons — above the first row and below the last included —
|
other. The `rows + 1` boundary buttons — above the first row and below the last included —
|
||||||
reach every position the two-per-row reading offers.
|
reach every position the two-per-row reading offers.
|
||||||
|
|
||||||
|
### Splitting a row
|
||||||
|
|
||||||
|
A row can be split into **two to four plots side by side**, up to **eight plots in all**. A split is
|
||||||
|
only an arrangement of more plots on the screen: **every plot shares the one time window** -- one x
|
||||||
|
link group, so a pan or zoom in any plot moves them all; one crosshair, drawn at the same time in every
|
||||||
|
plot; and one pair of vertical cursors, at the same two times in every plot. The plots of a row
|
||||||
|
also share **one y range**, the way the rows share one x window: the row has one y axis (auto or fixed),
|
||||||
|
drawn with its labels at the left of the first plot only, one pair of horizontal rulers drawn at the same
|
||||||
|
value in every plot, and one fit. A wheel, a drag, a box zoom, **Fit all** and the Auto/Fixed dialog act on
|
||||||
|
the whole row whichever plot they land in, and an auto-scaled row fits the union of its plots' traces (a plot
|
||||||
|
with none is ignored). The **bottom** row's plots carry the time labels, and so does any row above it whose
|
||||||
|
columns differ from the row below's (the `t0 = ...` caption goes with the labels, on each plot that draws
|
||||||
|
them); a row whose columns match the next one's leaves its labels to it. The left margin
|
||||||
|
holding the y labels is one width for the whole figure, so the plots of a row line up with the rows
|
||||||
|
above and below it; a plot whose labels are narrower simply has more margin. Only the first plot has a left
|
||||||
|
margin and only the last a right one, so the plots of a row get equal plot areas at equal weights and
|
||||||
|
neighbouring plots are exactly one column gap apart.
|
||||||
|
|
||||||
|
In the **Layout editor** (`l`):
|
||||||
|
|
||||||
|
- A **`+` at a row's left and right edge, and one between each two plots**, splits the row there: a new,
|
||||||
|
empty plot is added at that position and the plots after it move right *with their traces*. A one-cell
|
||||||
|
row can be split from either side. The `+` is dim at 4 plots in the row or 8 plots in all (the row `+`,
|
||||||
|
which adds a whole row, dims at 8 plots).
|
||||||
|
- A **double-headed (left-right) arrow between two plots** resizes the two neighbours against each other; neither can be
|
||||||
|
dragged below about 96 px. The plotter keeps 64 px as an absolute floor, and when the window is too
|
||||||
|
narrow for that it shrinks every plot proportionally and drops the gap, so nothing has a negative size.
|
||||||
|
The widths are saved when the drag ends.
|
||||||
|
- The **`×`** at a plot's top left removes that plot (and its traces) from a split row, and removes the
|
||||||
|
*row* from a one-plot row, as it always did. It is dim when that would leave no plot.
|
||||||
|
|
||||||
|
Plots are named by their row and, in a split row, a letter: **`1`**, **`2a`**..**`2d`**. The names are
|
||||||
|
what the signals page's *Plot on* buttons (tooltip "row 2, cell 1 of 2"), the status notices ("plotting
|
||||||
|
'sine' on plot 2b") and the cursors pane (one ruler line per figure row, `row 2` counted from 1 for a split row; a plot of an
|
||||||
|
unsplit row stays `row N`, counted from 0 as before) show. A signal dragged from its badge lands on the plot under the
|
||||||
|
pointer, with the same free-slot rule as a row. The session file keeps the arrangement (`row` and `width`
|
||||||
|
on each `[[plot]]`, see "The session layout file").
|
||||||
|
|
||||||
The buttons are `GtkOverlay` children placed by their margins over the figure's own row
|
The buttons are `GtkOverlay` children placed by their margins over the figure's own row
|
||||||
rects (`FigureLayout::rows`, divided by the area's device scale), deliberately **not** one
|
rects (`FigureLayout::rows`, divided by the area's device scale), deliberately **not** one
|
||||||
transparent widget covering the plot: an overlay child is picked wherever its allocation
|
transparent widget covering the plot: an overlay child is picked wherever its allocation
|
||||||
@@ -2574,12 +2954,13 @@ reaches every widget). The full key table:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `a` | arm / disarm the trigger |
|
| `a` | arm / disarm the trigger |
|
||||||
| `Space` | pause / resume |
|
| `Space` | pause / resume |
|
||||||
| `t` | show / hide the trigger panel |
|
| `t` | enable / disable trigger mode |
|
||||||
| `c` | show / hide cursors |
|
| `c` | enable / disable the cursors |
|
||||||
| `m` | show / hide measurements |
|
| `m` | open or close the Measurements page (the `measure` slide-over page; the dock's Edit button for the cursors opens the `cursors` page) |
|
||||||
| `g` | show / hide math channels |
|
| `g` | go to Signals (open or close the signal list, the `signals` slide-over page, also reached by the `+` badge) |
|
||||||
| `l` | show / hide the layout editor |
|
| `l` | show / hide the layout editor |
|
||||||
| `s` | show / hide the side panel |
|
| `s` | show / hide the dock |
|
||||||
|
| `Esc` | close the slide-over panel (the guided tour takes it first) |
|
||||||
| `f` | fit to data (the plot's own key, not an application accelerator) |
|
| `f` | fit to data (the plot's own key, not an application accelerator) |
|
||||||
| `Ctrl+E` | export visible range |
|
| `Ctrl+E` | export visible range |
|
||||||
| `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer |
|
| `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer |
|
||||||
@@ -2667,7 +3048,7 @@ now — they are listed so they are not mistaken for anything else:
|
|||||||
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
|
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
|
||||||
what a second ring would buy is a smoother frame at a latch, not recovered packets.
|
what a second ring would buy is a smoother frame at a latch, not recovered packets.
|
||||||
- **The buffer is allocated at the first CONFIG, not at initialisation.**
|
- **The buffer is allocated at the first CONFIG, not at initialisation.**
|
||||||
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
|
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-samples` is the
|
||||||
*budget*, and the power-of-two ring rounding means the final
|
*budget*, and the power-of-two ring rounding means the final
|
||||||
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
|
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
|
||||||
enable/disable and calibration re-partitions (and drops the history) because the
|
enable/disable and calibration re-partitions (and drops the history) because the
|
||||||
@@ -2691,34 +3072,58 @@ 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.
|
||||||
- **The sidebar is two tabs, and a math channel is a row of the signal list.** `SPECS.md`
|
- **The sidebar is one dock, setup is a slide-over, and a math channel is a row of the signal list.**
|
||||||
names the panes as separate collapsible sections — trigger, cursors, measurements, math,
|
`SPECS.md` names the panes as separate collapsible sections -- trigger, cursors, measurements, math,
|
||||||
signal list — in one scrolling column; with four of them open in a 320 px sidebar every
|
signal list -- in one scrolling column; with four of them open in a 320 px sidebar every
|
||||||
section was short of room, and finding one meant scrolling past the others. The built
|
section was short of room, and finding one meant scrolling past the others. The sidebar was then two
|
||||||
sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals**
|
text tabs (a **Measure** tab and a **Signals** tab, with a source kind's own tabs beside them), and four
|
||||||
tab (the trigger and the channels), **each a pill with its own glyph and no text** — the
|
tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the **readouts** (a compact,
|
||||||
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
|
read-only copy of the cursors and of the measurements, each block with an **Edit** button), shown from
|
||||||
for Signals (the same picture the toolbar's trigger button carries), drawn as a
|
the start and insensitive until the stream has signals; the full cursors and measurements panels are the
|
||||||
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
|
`cursors` and `measure` slide-over pages (`m` toggles `measure`); the **signal list** is the `signals` slide-over page (`g`, or the `+` badge). The **trigger form** and a **source's own
|
||||||
accessible name. Both panes also have a **locked** mode, which is what a returning user gets: the
|
controls** are pages of the slide-over, reached from the badge bar. The trigger pane also has a **locked** mode, which is what a returning user gets: it
|
||||||
trigger pane shows one line saying what it is set to, the signal list shows only the signals that are
|
shows one line saying what it is set to and offers no control that changes the configuration -- its
|
||||||
on, and neither offers a control that changes the configuration — each heading has an `[edit]` button
|
heading has an `[edit]` button to switch to the full pane, and back. The signals have no lock: the
|
||||||
to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling
|
signals page only proposes what can be added, and the signal pages always allow editing. A signal's badge is dragged onto a plot, or onto another badge to
|
||||||
plots is what the list is for, not configuration — and a locked row can be dragged from anywhere on it,
|
reorder the badges (the order is kept for the session only); the slide-over closes for the length of the
|
||||||
the colour chip included. In a window too narrow to show both, where the sidebar is *overlaid on* the
|
drag so there is something under the pointer to drop onto. A **first run** (no session file for that source) comes up
|
||||||
plot, the sidebar steps aside for the length of the drag so there is something under the pointer to drop
|
|
||||||
onto. A **first run** (no session file for that source) comes up
|
|
||||||
unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the
|
unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the
|
||||||
header bar's button — clicking either moves both — because a pane that is *disabled* while its
|
header bar's button -- clicking either moves both -- because a pane that is *disabled* while its
|
||||||
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The
|
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. With the math
|
||||||
design spec had the switcher showing *titles* and neither page an
|
list folded into the signal list under a `MATH` divider whose `+` creates a channel -- a derived channel already *is* a channel in
|
||||||
`icon-name`; `narrow` alone does not do it (libadwaita still draws a label beside the icon,
|
the model, so the two lists were the same list drawn twice. No
|
||||||
measured), so the pages carry an empty title and the pills are named in code. With the math
|
|
||||||
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
|
|
||||||
the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no
|
|
||||||
toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and
|
toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and
|
||||||
trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is
|
trigger-off leaves the trigger form fully editable -- a trigger is configured *before* it is
|
||||||
switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes.
|
switched on, and the locked view says `stream (trigger off)` instead of the settings -- and the measurements' and math panes' toolbar toggles are gone with the panes.
|
||||||
|
- **The badge bar / slide-over / dock redesign is in progress; steps 1-3 are done, and they deviate
|
||||||
|
from `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` in these known ways.**
|
||||||
|
(1) The session file gains the dock's *shown* state only, not a width -- the dock's width is
|
||||||
|
fixed (**280 px**, as the spec says), so there is nothing to persist -- and does not store the open
|
||||||
|
slide-over page. (2) The **Trigger badge** shows `off`, `armed` and `triggered` as the spec says, and
|
||||||
|
two more words, `capturing` (a post-trigger window is filling) and `disarmed` (trigger on, not
|
||||||
|
armed), so the label never claims a state the engine is not in. (3) The **`cursors` and `measure`
|
||||||
|
pages hold the existing panels unchanged** (the cursors table with its mode, its Reset and its
|
||||||
|
switch; the measurement list with its `+`, statistics and per-row edit), so the pages carry the
|
||||||
|
panels' own controls, not a new form. (4) The **measurement editor stays a dialog**: the spec has the
|
||||||
|
measurement add/edit/remove inside the `measure` page, but the dialog (`open_measure_dialog()`) is
|
||||||
|
what the `+`, a row's edit button and a click on a dock line all open; the page lists the measurements
|
||||||
|
and hosts the dialog's entry points. (5) A **dock cursor line carries the full who / A / B / delta
|
||||||
|
cells** (not one value per line as the spec sketches), in a 5 + 3 x 9 character fixed-width grid: the
|
||||||
|
**who** cell ellipsizes at **5 characters** -- the width the 280 px pin allows; with 8 the readouts'
|
||||||
|
minimum width was 304 px and widened the sidebar -- and its tooltip carries the full text. (6) The dock and
|
||||||
|
the pages **share one string** for every value (see `docs/claude/invariants-readouts-and-cursors.md`).
|
||||||
|
(7) The **Events block and the `events` page are available** while recording is on, a replay is on screen, or
|
||||||
|
the cache holds events -- **not** "outside trigger mode" as the spec has it, because a replay or a group leaves
|
||||||
|
trigger mode and the block would grey itself under the click that started it. (8) The **dock's event rows carry no
|
||||||
|
thumbnails** (the page's do) and show a **hollow marker on the members of the group on screen**, which the page's
|
||||||
|
rows do not. (9) The dock lists at most 24 events; the page lists them all.
|
||||||
|
The signal badges, the signal pages and the drag (step 2) are done, and so are the events block in the
|
||||||
|
dock and the `events` page (the left events pane, its divider and the header's Events toggle are
|
||||||
|
deleted), the Mallard manual, `USER-GUIDE.md` and the two figures that show the window
|
||||||
|
(`main-window.png`, `trigger-capture.png`, regenerated with `tools/make-help-shots.sh`). The
|
||||||
|
manual's `math-editor.png` is the editor's own window and was not regenerated.
|
||||||
|
The main-window figure shows `'sine' is off: 1 trace left the plots` in the status bar and no sine
|
||||||
|
badge: the headless enable-all probe that drives the scene ends that way, and the figure records it.
|
||||||
- **A plot holds at most eight 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
|
||||||
@@ -2781,6 +3186,16 @@ silence timeout and then resumes, which is the EMI case end to end.
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
|
- **A remote display needs the software renderer, and the plot is empty until a signal is
|
||||||
|
enabled.** Over SSH with X11 forwarding there is no usable Vulkan device: GTK cannot create a
|
||||||
|
swapchain (`vkCreateSwapchainKHR … VK_ERROR_INITIALIZATION_FAILED`) and the window never appears
|
||||||
|
even though the stream connects and its CONFIG parses. `--renderer cairo` selects the software
|
||||||
|
preset for **both** halves — GTK's renderer and the plot's own GL context, which is a
|
||||||
|
`GtkGLArea` and asks for one regardless of what GSK draws with — and
|
||||||
|
`tools/udpscope-remote.sh` runs the app against a virtual X server with a VNC view carried over
|
||||||
|
an SSH tunnel, avoiding X11 forwarding altogether. If a window does appear but stays empty, check
|
||||||
|
that a **signal is enabled**: signals start disabled, so a session with none switched on plots
|
||||||
|
nothing and prints no `buffer allocated:` line, by design rather than as a fault.
|
||||||
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
||||||
`udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per
|
`udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per
|
||||||
DATA packet for such a signal, regardless of the publish mode, so it cannot be
|
DATA packet for such a signal, regardless of the publish mode, so it cannot be
|
||||||
@@ -2802,6 +3217,10 @@ silence timeout and then resumes, which is the EMI case end to end.
|
|||||||
rather than rebuilds, and why the frame tick reads the markers back: a parked
|
rather than rebuilds, and why the frame tick reads the markers back: a parked
|
||||||
cursor's snapped position is a real number, so nothing may be inferred from it. The
|
cursor's snapped position is a real number, so nothing may be inferred from it. The
|
||||||
trigger marker is separate: its own cursor, independent of the cursor toggle.
|
trigger marker is separate: its own cursor, independent of the cursor toggle.
|
||||||
|
- **The slide-over shows a form taller than the plot by scrolling it.** The panel is as tall as the
|
||||||
|
plot and never taller, and each page has a scroller of its own, so on the documented 756x470 window
|
||||||
|
the trigger form (about 474 px) shows 253 px of itself. The tour's spotlight is clipped to that, and at
|
||||||
|
that size the card of the trigger step overlaps the panel's edge by about 18 px (`clear=0`).
|
||||||
- **Verified headless only.** Acquisition, partitioning, the trigger state
|
- **Verified headless only.** Acquisition, partitioning, the trigger state
|
||||||
machine and its integration with the buffers, and rendering under Xvfb
|
machine and its integration with the buffers, and rendering under Xvfb
|
||||||
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
|
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
|
||||||
@@ -2813,10 +3232,33 @@ silence timeout and then resumes, which is the EMI case end to end.
|
|||||||
inner popover takes the grab, the menu closes under the click, and the bubble is
|
inner popover takes the grab, the menu closes under the click, and the bubble is
|
||||||
left parented to a row in a hidden popover. Export is described in the
|
left parented to a row in a hidden popover. Export is described in the
|
||||||
Documentation instead.
|
Documentation instead.
|
||||||
|
- **Over SSH with X11 forwarding the window may never paint or allocate, and the cure is
|
||||||
|
`--renderer cairo`.** There are two walls, in order.
|
||||||
|
GTK4 defaults to Vulkan and a forwarded display has no usable Vulkan device, so the
|
||||||
|
log shows `vkCreateSwapchainKHR(): Initialization of an object could not be completed
|
||||||
|
... (VK_ERROR_INITIALIZATION_FAILED)` and nothing is drawn. `GSK_RENDERER` alone gets
|
||||||
|
past that, but the plot is a `GtkGLArea` and asks GDK for a GL context of its own
|
||||||
|
whatever GSK draws with; over forwarding there is no hardware one, and the window then
|
||||||
|
stays unallocated — `header: window_button_w=0` with a zero `bar_w`, and no
|
||||||
|
`[width-probe]` line (it prints only once the plot has a viewport wider than 100 px) —
|
||||||
|
while the stream side is fine. Absence of `buffer allocated:` is *not* part of that
|
||||||
|
reading: signals arrive disabled, so a start with nothing enabled plans no buffer and
|
||||||
|
prints no line even on a healthy display, and the plot stays empty until a signal's
|
||||||
|
switch is turned on.
|
||||||
|
`--renderer cairo` is the preset for exactly this display: it exports
|
||||||
|
`GSK_RENDERER=cairo` **and** `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe answers the GL area),
|
||||||
|
the same pair `tools/make-help-shots.sh` uses. `--renderer gl` is the faster
|
||||||
|
alternative when indirect GL works. The variables are exported in `main()` **before**
|
||||||
|
`gtk_init()`/`adw_init()`, which is the whole trap: they are read when the renderer and
|
||||||
|
the GL context are created, so setting them later is a silent no-op. Without the option
|
||||||
|
nothing is set and GTK's own choice stands. No `GDK_BACKEND` companion exists on
|
||||||
|
purpose: an X11-forwarded session carries `DISPLAY` and no `WAYLAND_DISPLAY`, so GDK
|
||||||
|
already selects X11; forcing it would override a working local Wayland session for no
|
||||||
|
gain (if both are set and Wayland is broken, export `GDK_BACKEND=x11` yourself).
|
||||||
- **The tour card can cover its own target, and in the documented headless run it
|
- **The tour card can cover its own target, and in the documented headless run it
|
||||||
does.** The card is placed under, over or beside the hole whenever one of those has
|
does.** The card is placed under, over or beside the hole whenever one of those has
|
||||||
room; when the target covers the whole content -- which is what the plot does at the
|
room; when the target covers the whole content -- which is what the plot does at the
|
||||||
window's 756 px minimum, because the split view overlays the side panel rather than
|
window's 756 px minimum, because the split view overlays the dock rather than
|
||||||
putting it beside the plot -- no placement can avoid it, and the card takes the
|
putting it beside the plot -- no placement can avoid it, and the card takes the
|
||||||
bottom corner. The probe prints both rectangles instead of asserting a placement,
|
bottom corner. The probe prints both rectangles instead of asserting a placement,
|
||||||
so the case reads as a measurement.
|
so the case reads as a measurement.
|
||||||
|
|||||||
@@ -42,9 +42,18 @@
|
|||||||
The following were built differently from what was asked for, each for a reason `README.md` states
|
The following were built differently from what was asked for, each for a reason `README.md` states
|
||||||
in full:
|
in full:
|
||||||
|
|
||||||
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. The retained history
|
- **The buffer is budgeted in samples per signal, not megabytes** (line 1 asked for "default 200MB
|
||||||
in this mode is **41.943 s** against the default **83.886 s** (measured at 100 kS/s, 4 channels,
|
each, configurable as cli argument"). `--buffer-samples N` (default 10M, k/M/G suffixes) is the
|
||||||
`--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
|
number of samples kept for every acquired signal and for the shared time ring, rounded **down** to a
|
||||||
|
power of two ("up to"; 10M gives 8 388 608), and a separate application setting, `--memory-limit-mb`
|
||||||
|
(default 8192, also in Preferences), is the hard ceiling on the ring bytes, halving the sample budget
|
||||||
|
when the rings would not fit. The retained history therefore no longer depends on the channel count
|
||||||
|
or on relative time. There is no `--buffer-mb` and no compatibility shim; a stored `buffer_mb` is
|
||||||
|
ignored. The "2 identical buffers" were already a single ring plus latched snapshots (see below).
|
||||||
|
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. *As first built* (under
|
||||||
|
the old byte budget; superseded by the item above, where both modes keep the same span and only
|
||||||
|
the memory differs) the retained history in this mode was **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
|
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
|
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
|
plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same
|
||||||
|
|||||||
@@ -6,9 +6,23 @@ what was built and why, and `SPECS.md` is the requirement document.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*The main window with a source streaming: the signal panel on the right, the plot in the middle, the
|
*The main window with a source streaming: the header bar above (the window control — `2 s` — first on
|
||||||
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
|
the left), the plot, the badge bar under it with one badge per signal, the status line at the foot, and
|
||||||
on the left.*
|
the dock on the right with the cursor and measurement readouts and the events block.*
|
||||||
|
|
||||||
|
The window is a plot with three things around it:
|
||||||
|
|
||||||
|
- the **badge bar** under the plot: a badge for each signal you have switched on, a `+` to add one
|
||||||
|
(past six they fold into one `N signals` badge), and, on the right, **Trigger** and — only for a
|
||||||
|
source with controls of its own — **Source**;
|
||||||
|
- the **side panel**, which a badge opens over the plot's right edge, one page at a time: the trigger,
|
||||||
|
a signal, the list of signals to add, the cursors, the measurements, the events, or a source's own
|
||||||
|
controls. A second click on the badge, `Esc`, **Close** or a click on the plot closes it, and it
|
||||||
|
never resizes the plot;
|
||||||
|
- the **dock** on the right, which the dock button in the header bar (`s`) shows and hides: read-only
|
||||||
|
**Readouts** of the cursors and of the measurements, each with an **Edit** button that opens its
|
||||||
|
page, and an **Events** block. With the dock hidden, or in a narrow window where the dock slides over the plot, the readouts show in a
|
||||||
|
one-line strip under the plot instead (the cursors' deltas and each measurement's value; **Cursors** and **Measures** open the pages); the strip is there only while the cursors are on or there is a measurement.
|
||||||
|
|
||||||
## 1. Connecting
|
## 1. Connecting
|
||||||
|
|
||||||
@@ -30,7 +44,7 @@ Every launch is its own process, so running two scopes side by side is fine.
|
|||||||
|
|
||||||
## 2. The status line
|
## 2. The status line
|
||||||
|
|
||||||
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
|
At the foot of the window, under the badge bar, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
|
||||||
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
|
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
|
||||||
|
|
||||||
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
|
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
|
||||||
@@ -44,14 +58,43 @@ detail: packets, frames, gaps, per-channel sample counts, the buffer's span and
|
|||||||
|
|
||||||
## 3. Signals
|
## 3. Signals
|
||||||
|
|
||||||
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
|
The **`+` badge** (or `g`) opens the **Signals** page, which lists what can be added: the signals the
|
||||||
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
|
CONFIG described that are not acquired yet, each with a tick. **Signals start disabled — ticking one
|
||||||
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
|
is what starts acquiring it**, and a signal nobody enables costs nothing. Its badge then appears in
|
||||||
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
|
the badge bar. Drag the badge onto a plot row to add a trace; drag it again to another row to plot
|
||||||
appears.
|
the same signal twice, or onto another badge to reorder the badges. The colour is per *signal*, so
|
||||||
|
the same signal is the same colour wherever it appears.
|
||||||
|
|
||||||
The panel also holds the per-signal calibration: gain and offset, applied at ingest, so the
|
Clicking a signal's badge opens its own page. Directly under the name are its **Unit**, **Gain** and
|
||||||
measurements and the export carry the same numbers the plot shows.
|
**Offset** — each with a pencil button that edits that one value — and then what the stream declared about
|
||||||
|
it (type, size, range, sample rate, timing). Below come the colour and the **Plot on** buttons for the
|
||||||
|
rows. Gain and offset are applied at ingest, so the measurements and the export carry the same numbers the
|
||||||
|
plot shows; a renamed unit is used there too and is saved with the session.
|
||||||
|
|
||||||
|
### Splitting a row
|
||||||
|
|
||||||
|
Press `l` (or pick **Layout editor** from the overflow menu) to arrange the plots: a **`×`** at the top
|
||||||
|
left of each plot removes it, a **`+`** on each boundary between rows adds a row, and a **Done** button
|
||||||
|
in the plot's top-right corner leaves the editor. The plots can also be put **side by side**: a row holds
|
||||||
|
up to **4** plots, and the figure up to **8** in all.
|
||||||
|
|
||||||
|
- **To split a row**, click the `+` at its left or right edge, or the `+` between two of its plots. The
|
||||||
|
new plot is empty and the plots after it keep their traces. The `+` is dim at 4 plots in the row or 8
|
||||||
|
in the figure.
|
||||||
|
- **To resize**, drag the double-headed arrow between two plots left or right. The two neighbours trade width (each
|
||||||
|
stays at least about 96 px wide); the sizes are saved with the session.
|
||||||
|
- **To remove a plot**, click its `×`. In a split row it removes just that plot and its traces; in a row
|
||||||
|
of one plot it removes the row.
|
||||||
|
|
||||||
|
Every plot shares **one time window**: panning or zooming in any of them moves them all, the crosshair
|
||||||
|
sits at the same time in each, and the vertical cursors are the same two times in each. The plots of a split
|
||||||
|
row also share **one y range**: the y labels are drawn at the left of the first plot only, the
|
||||||
|
horizontal rulers are the same two values in every plot, and a y zoom, pan, fit or fixed range in any of
|
||||||
|
them applies to the whole row. The time labels sit under the bottom row, and
|
||||||
|
under any row above it whose columns differ from the row below. A plot of a split row is named by its row and a letter, `2a` to `2d`: that is what the signals
|
||||||
|
page's **Plot on** buttons and the status line call it, and a signal dragged
|
||||||
|
from its badge goes onto the plot under the pointer. The arrangement is saved per source, and a session
|
||||||
|
saved before rows could be split opens with one plot per row, as it was.
|
||||||
|
|
||||||
## 4. The window and navigation
|
## 4. The window and navigation
|
||||||
|
|
||||||
@@ -83,8 +126,9 @@ should look like.
|
|||||||
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
|
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
|
||||||
sits at the edge, and the status line reads `event held`.*
|
sits at the edge, and the status line reads `event held`.*
|
||||||
|
|
||||||
The trigger panel has the edge, the pre/post split (as a share of the event span), Normal or Single,
|
The trigger button in the header bar (`t`) turns trigger mode on. The **Trigger** badge opens the
|
||||||
and Arm/Disarm. Selecting a signal for the trigger enables it.
|
trigger page, which has the signal, the condition, the edge, the pre/post split (as a share of the
|
||||||
|
event span), Normal or Single, and Arm/Disarm. Selecting a signal for the trigger enables it.
|
||||||
|
|
||||||
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
|
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
|
||||||
fills in view; the axis does not move.
|
fills in view; the axis does not move.
|
||||||
@@ -94,6 +138,10 @@ and Arm/Disarm. Selecting a signal for the trigger enables it.
|
|||||||
when you disarm is the picture you keep.
|
when you disarm is the picture you keep.
|
||||||
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
|
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
|
||||||
jumps still gets the window it asked for.
|
jumps still gets the window it asked for.
|
||||||
|
- **Events.** With *record events* on (trigger page), each latch is stored. The dock's **Events** block
|
||||||
|
lists them newest first and **one click replays** one; **Manage** opens the full list as a page,
|
||||||
|
where a click selects, a double click replays, and **Compare**, **Accumulate** and **Live** act on
|
||||||
|
the selection or go back to the stream. The block is dimmed until something is recorded.
|
||||||
|
|
||||||
## 6. Pause
|
## 6. Pause
|
||||||
|
|
||||||
@@ -104,22 +152,24 @@ does not walk it off its snapshot.
|
|||||||
|
|
||||||
## 7. Cursors
|
## 7. Cursors
|
||||||
|
|
||||||
The cursors toolbar toggle switches on two instruments per row:
|
The cursors toggle in the header bar (`c`) switches on two instruments per row:
|
||||||
|
|
||||||
- **vertical** — two times, shared by every row;
|
- **vertical** — two times, shared by every row (and every plot of a split row);
|
||||||
- **horizontal** — two rulers per row;
|
- **horizontal** — two rulers per row (every plot of a split row shows the same two values);
|
||||||
- **both** — the two together;
|
- **both** — the two together;
|
||||||
- **mix** — the two times, read off each *trace* where they cross it.
|
- **mix** — the two times, read off each *trace* where they cross it.
|
||||||
|
|
||||||
The pane beside it reports each trace's values at the markers, and **Reset** puts them back at the
|
The dock reports each trace's values at the markers; **Edit** beside **Cursors** opens the page with
|
||||||
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
|
the mode and **Reset**, which puts them back at the window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
|
||||||
marker is over data.
|
marker is over data.
|
||||||
|
|
||||||
## 8. Measurements
|
## 8. Measurements
|
||||||
|
|
||||||
The measurements pane computes statistics of the **visible window** of a signal: average, top,
|
A measurement is a statistic of the **visible window** of a signal: average, top, bottom, min, max,
|
||||||
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
|
peak to peak, amplitude, pulse duration, period and frequency. `m`, or **Edit** beside
|
||||||
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
|
**Measurements** in the dock, opens the page; its `+` adds one, a click on a row (in the page or in
|
||||||
|
the dock) edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
|
||||||
|
The dock shows each measurement's value.
|
||||||
|
|
||||||
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
|
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
|
||||||
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
|
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
|
||||||
@@ -127,7 +177,7 @@ split: they count rising crossings of the mid level, one edge per cycle, and a s
|
|||||||
setting you can only get wrong.
|
setting you can only get wrong.
|
||||||
|
|
||||||
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
|
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
|
||||||
it stops, and once per mode change — so a live view cannot cost you frames. The pane's cost is
|
it stops, and once per mode change — so a live view cannot cost you frames. The cost is
|
||||||
reported in the exit dump with the app's self-check.
|
reported in the exit dump with the app's self-check.
|
||||||
|
|
||||||
## 9. Math channels
|
## 9. Math channels
|
||||||
@@ -139,13 +189,14 @@ the square on the right. The orange dot marks the node that carries the channel'
|
|||||||
|
|
||||||
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
||||||
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
||||||
as a source.
|
as a source. The **Signals** page lists them under a `MATH` heading whose `+` creates one; a math
|
||||||
|
channel's own page has **Edit graph** and **Delete**.
|
||||||
|
|
||||||
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
|
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
|
||||||
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
|
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
|
||||||
|
|
||||||
- **Click** a node to select it; its parameters appear in the form beside the canvas.
|
- **Click** a node to select it; its parameters appear in the form beside the canvas.
|
||||||
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
|
- **Drag from an output square to an input square** to wire them. An input is only offered the nodes
|
||||||
above it — the graph is a list walked in order, so a cycle cannot be drawn.
|
above it — the graph is a list walked in order, so a cycle cannot be drawn.
|
||||||
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
|
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
|
||||||
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
||||||
@@ -180,19 +231,24 @@ per sample, `time`, `stream_time`, then one column per signal.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `space` | pause / resume |
|
| `space` | pause / resume |
|
||||||
| `a` | arm / disarm the trigger |
|
| `a` | arm / disarm the trigger |
|
||||||
| `t` | the trigger panel |
|
| `t` | enable / disable trigger mode |
|
||||||
| `l` | the layout editor |
|
| `c` | enable / disable the cursors |
|
||||||
| `g` | go live |
|
| `m` | open or close the Measurements page |
|
||||||
| `f` | fit to data |
|
| `g` | open or close the Signals page (the `+` badge opens it too) |
|
||||||
| `d` | add the first free signal to the focused row |
|
| `l` | show / hide the layout editor |
|
||||||
| `c` | cursors on/off |
|
| `s` | show / hide the dock |
|
||||||
| `m` | measurements pane |
|
| `Esc` | close the slide-over page (the guided tour takes it first) |
|
||||||
| `e` | export |
|
| `f` | fit to data (the plot's own key) |
|
||||||
| `?` | shortcuts |
|
| `Ctrl+E` | export visible range |
|
||||||
|
| `F1` | open the manual |
|
||||||
|
| `Ctrl+/` | open the shortcut overview |
|
||||||
|
| `Ctrl+T` | start the guided tour |
|
||||||
|
| `Ctrl+H` | turn the contextual help on or off |
|
||||||
|
| `Ctrl+Q` | quit |
|
||||||
|
|
||||||
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
|
The plot also answers `+`/`-` (zoom both axes), `Ctrl+plus`/`Ctrl+minus` (zoom x only),
|
||||||
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
|
`Alt+plus`/`Alt+minus` (zoom y only) and the arrow keys (pan). A keystroke is ignored while a text
|
||||||
trigger.
|
field has focus, so typing in the window control never arms the trigger.
|
||||||
|
|
||||||
## 13. Troubleshooting
|
## 13. Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ concluding something is a bug.
|
|||||||
|
|
||||||
### Threads
|
### Threads
|
||||||
|
|
||||||
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
|
Exactly two: a producer thread in `Acquisition` owns a `DataSource` and calls
|
||||||
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
|
`source->poll(50)`; the GTK main thread owns every widget and GL context. They share
|
||||||
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
|
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
|
||||||
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
|
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
|
||||||
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
|
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
|
||||||
@@ -21,11 +21,169 @@ consumer) and never through the lock. Cross-thread scalars are atomics on `Scope
|
|||||||
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
|
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
|
||||||
capture open sees which edge it belongs to.
|
capture open sees which edge it belongs to.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
|
||||||
|
What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
|
||||||
|
|
||||||
|
- The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly --
|
||||||
|
there is no static-initialiser registration, which a linker may drop -- and a future `dlopen`
|
||||||
|
loader would only call `register_kind()`.
|
||||||
|
- A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is
|
||||||
|
source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept.
|
||||||
|
- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained
|
||||||
|
at the top of `poll()`. A panel never calls a source directly.
|
||||||
|
- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in
|
||||||
|
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
|
||||||
|
- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and
|
||||||
|
the data path together; do not bend one frame to carry two clocks.
|
||||||
|
- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-<id>`. A disconnected source
|
||||||
|
leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`).
|
||||||
|
- Session file naming for a non-UDPS kind: the pseudo-host is `<kind>-<identity>` (characters outside
|
||||||
|
`[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is
|
||||||
|
stored as `source_kind` and its settings as `source_opt_<key>`. A UDPS file keeps `<host>_<port>` and
|
||||||
|
has neither key, so a legacy session is read as UDPS and nothing existing moves.
|
||||||
|
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
|
||||||
|
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
|
||||||
|
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is
|
||||||
|
`Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main
|
||||||
|
thread, the source's lookup goes through `discover_broadcast`);
|
||||||
|
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
|
||||||
|
never reaches the UI files.
|
||||||
|
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
|
||||||
|
connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital
|
||||||
|
input/counter poll and the waveform tick (every `1 / gen_rate` s, 20 ms by default; one synchronous DAC write per running channel, so a slow board lowers the rate it achieves, which the Analog out panel reports). There is no second thread. A connect attempt (discovery,
|
||||||
|
handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each
|
||||||
|
step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end
|
||||||
|
the producer thread, and `drain_commands()` runs inside the same `try` for that reason.
|
||||||
|
- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not
|
||||||
|
whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the
|
||||||
|
first one's. Digital lines and the counter are held across a block (a step trace).
|
||||||
|
- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes
|
||||||
|
and a tick callback throttled to 100 ms reads; a panel never touches the source's internals, and writes
|
||||||
|
only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`,
|
||||||
|
so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per
|
||||||
|
retry); that line is where a user sees why a connection failed.
|
||||||
|
- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec,
|
||||||
|
the validity rules and the decoders, with no GTK and no sockets (the probe round-trips it);
|
||||||
|
`flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form, a signal-row
|
||||||
|
editor (a name, a type, a count and, while the count is above 1, a "Vector as" drop-down per row). The
|
||||||
|
datagram has no description, so a list that is wrong is invisible except as dropped datagrams: the size
|
||||||
|
check is the only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per
|
||||||
|
size.
|
||||||
|
- A vector row is shown one of two ways, chosen per row (`Row::wave`, the `w` suffix in the text). As
|
||||||
|
**channels** it is `count` scalar channels of one sample per cycle, so the list batches like an
|
||||||
|
all-scalar one. As a **waveform** it is one channel with `rows = count` and
|
||||||
|
`sampling_rate = rate * count`, one datagram per `Frame` (`num_samples = 1`). The two cannot share a
|
||||||
|
list because the model spans a frame by its largest element block and `Frame::value` is 0 past a scalar's
|
||||||
|
one value: a scalar beside a vector block would be zero-padded, so `problem()` demands that a list with a
|
||||||
|
waveform row be waveform rows of one count, and the form shows that message in the subtitle. The channel
|
||||||
|
total is capped at 256.
|
||||||
|
- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the
|
||||||
|
sample count varies): `round(rate / 100)` datagrams for a list with no waveform row, a partial block
|
||||||
|
flushed after 100 ms. `rate` is required because `sampling_rate = 0` makes the model size the ring and
|
||||||
|
the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping
|
||||||
|
path that ignores the declared cadence.
|
||||||
|
- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets
|
||||||
|
`Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the
|
||||||
|
history survives (`CONFIG` is sent once, from the first `poll()`).
|
||||||
|
- The form's row editor removes a row from inside its own button's `clicked` emission (GTK keeps the emitter
|
||||||
|
alive to the end of the emission, and the handler copies the form and the row out of its context first);
|
||||||
|
every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have
|
||||||
|
destroyed the sibling widgets the handlers reach by raw pointer.
|
||||||
|
|
||||||
|
### The window's chrome: plot, slide-over, badge bar, dock
|
||||||
|
|
||||||
|
The main window is a plot with three things around it, each a unit of `src/ui_*.cpp` and none of them
|
||||||
|
touching the data path.
|
||||||
|
|
||||||
|
- **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second
|
||||||
|
child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is
|
||||||
|
a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its
|
||||||
|
own and registered under its id (`trigger`, `source-<id>`, `signals` (the signal list) and
|
||||||
|
`signal:<name>` (one signal's page, `src/ui_signal_page.cpp`, created on demand by `signal_page_ensure()`
|
||||||
|
and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with), `cursors` (the cursors
|
||||||
|
panel, unchanged) and `measure` (the measurements panel, unchanged; `m` toggles it); `events` (the full events list, `events-pane.ui`'s `events_box`: the Live, Compare and Accumulate
|
||||||
|
controls and the multi-select list with its thumbnails; open it only through `open_events_page()`, which
|
||||||
|
refreshes the list first, because the slide-over has no on-open hook). They are registered whether or not
|
||||||
|
they are open (a closed page's widgets exist, unmapped); `cursors` and `measure` are insensitive until the
|
||||||
|
stream has signals, by the same `update_signal_availability()` call that sets the dock's sensitivity, and
|
||||||
|
`events` follows `sync_events_availability()` (below). One page is open at a time and `App::slideover.page`
|
||||||
|
is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the
|
||||||
|
badge and a click on the plot close it; the click-away is a capture-phase observer (see
|
||||||
|
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an
|
||||||
|
`AdwToggleGroup` under the header is the segmented switch between them.
|
||||||
|
- **`BadgeBar`** (`src/ui_badges.cpp`) is the fixed-height row between the plot and the status line
|
||||||
|
(`main-window.ui` `badge_bar`, two slots: signals, left, a scroller holding one badge per enabled signal
|
||||||
|
and the `+`, or a single "N signals" badge past six; system, right, holding the **Trigger** and
|
||||||
|
**Source** badges). `sync_signal_badges()` rebuilds the signal badges (from `rebuild_signal_panels()`);
|
||||||
|
their order is `App::badge_order`, in memory only. A badge is a flat button whose `page` is the slide-over page it
|
||||||
|
toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a
|
||||||
|
file) leaves it **hidden** (the user asked for hide here, not the usual disable): `sync_badge_bar()` sets its visibility,
|
||||||
|
pick and focus from `slideover.source_pages` being non-empty every tick, so anything that indexes the badge
|
||||||
|
(the tour's `badges` step targets the whole bar, `[badges]`) must tolerate it being absent.
|
||||||
|
- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`), **280 px**
|
||||||
|
and fixed. It holds two things, the **Readouts** box and, under a separator, the **Events** block (below). The Readouts box (`src/ui_dock.cpp`, `build_readouts_dock()`): a
|
||||||
|
**Cursors** block (heading, an **Edit** button that opens the `cursors` page, a `GtkGrid` of a header row
|
||||||
|
`A B delta` and one line per page line) and a **Measurements** block (heading, **Edit** opening the
|
||||||
|
`measure` page, one line per measurement: chip, type, value; a click on a line opens that measurement's
|
||||||
|
dialog; no measurements shows one dim "no measurements" line). It is read-only and **owns no formatting**:
|
||||||
|
`update_cursors_pane()` and `update_measures()` hand the string they wrote to the page's label to
|
||||||
|
`dock_cursor_cell()` / `dock_measure_value()` as well, and `rebuild_cursors_pane()` /
|
||||||
|
`rebuild_measures_pane()` end by rebuilding the dock's lines to match (`dock_rebuild_cursors()`,
|
||||||
|
`dock_rebuild_measures()`). It holds no tab switcher, no revealer and none of the full panels, nor a source
|
||||||
|
kind's panels (those are slide-over pages). The window's measurement editor is a dialog
|
||||||
|
(`open_measure_dialog()`), not part of the `measure` page.
|
||||||
|
|
||||||
|
**The readouts strip** (`readouts_strip` in `main-window.ui`, between `plot_overlay` and `badge_bar` in
|
||||||
|
`left_column`; filled by `ui_dock.cpp`) carries the same readouts when the dock is not beside the plot: a flat
|
||||||
|
**Cursors** button and one chip (`who` + delta) per cursor line, then a **Measures** button and one chip per
|
||||||
|
measurement. `sync_readouts_strip()` shows it exactly when `(!adw_overlay_split_view_get_show_sidebar() ||
|
||||||
|
adw_overlay_split_view_get_collapsed())` **and** (the cursors toggle is active **or** `app.measures` is
|
||||||
|
non-empty) -- the Dock button put the dock away, or the window is collapsed and the dock is an overlay on the
|
||||||
|
plot (the strip stays on screen even while that overlay is open), and there is something to read. Inside, the
|
||||||
|
Cursors group (`strip_cursor_group`) is shown with the cursors on, the Measures group (`strip_measure_group`)
|
||||||
|
with measurements, the separator (`strip_sep`) with both; with neither the strip is hidden. Besides the tick,
|
||||||
|
the cursors toggle handler and `rebuild_measures_pane()` call it, so a toggle or an add/delete shows at once. The tick
|
||||||
|
calls it right after re-asserting `sidebar_wanted` and *before* `set_collapsed()` (so the split view's own
|
||||||
|
collapse-time `show-sidebar` write is never read); that means the `collapsed` it reads is the previous
|
||||||
|
frame's, and the strip follows a collapse or a widening one tick late, never through a wrong state. Its labels (`strip_cursor_who`, `strip_cursor_deltas`, `strip_measure_labels`) are rebuilt by
|
||||||
|
`dock_rebuild_cursors()` / `dock_rebuild_measures()` and written by `dock_cursor_cell()` /
|
||||||
|
`dock_measure_value()` from the same argument as the dock's, so there is no second formatter;
|
||||||
|
`dock_set_cursors_sensitive()` is the one writer of the cursor readouts' sensitivity (the dock's grid and
|
||||||
|
the strip's chip box).
|
||||||
|
|
||||||
|
The **Events** block (`build_events_dock()`, also `ui_dock.cpp`) is a heading with a **Manage** button
|
||||||
|
(`open_events_page()`) over a short list (`dock_events_list`, 112 px, scrolled, no thumbnails): one compact
|
||||||
|
row per `EventRecord`, newest first, capped at `kDockEventsMax` (24) with a dim "... N more, see Manage"
|
||||||
|
row after them. A row's cells are a one-character marker (filled = the event on screen; hollow = a member
|
||||||
|
of the group on screen -- dock only, the page's rows do not mark members; blank otherwise), the title
|
||||||
|
(`event_row_title`, ellipsized, full text in the tooltip) and `#<seq>`; **one click replays** it
|
||||||
|
(`replay_event()`, the body of the page's double-click handler). Both views are rebuilt from one
|
||||||
|
`EventStore::list()` by `refresh_events_pane()`, but on different cadences: **the dock follows every
|
||||||
|
latch** (`record_event()` refreshes it unconditionally), while the page's list is refreshed per latch only
|
||||||
|
while the page is open and by `open_events_page()` otherwise. `mark_events_on_screen()` updates titles and
|
||||||
|
markers in place and never rebuilds either list. The block, and the page, are insensitive (never hidden)
|
||||||
|
unless `sync_events_availability()` says `event_recording || replaying || events_cached` -- a different
|
||||||
|
rule from the spec's (see [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)).
|
||||||
|
There is no events pane, divider or header toggle any more: the page is reached from Manage (or by a
|
||||||
|
caller of `open_events_page()`), and the plot is never squeezed by events.
|
||||||
|
|
||||||
|
**When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour
|
||||||
|
overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over
|
||||||
|
too); the trigger form is registered as
|
||||||
|
a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and
|
||||||
|
`build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page.
|
||||||
|
Per-tick state (the Trigger word, the Source name, the open marks) is written by
|
||||||
|
`sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label
|
||||||
|
only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer
|
||||||
|
thread.
|
||||||
|
|
||||||
### Data path
|
### Data path
|
||||||
|
|
||||||
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
||||||
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
|
value ring per channel, sized by `plan_partition()`: `--buffer-samples` ticks (rounded down to a power of two) per ring, halved
|
||||||
within the byte budget. The producer always writes it, whatever the trigger is doing —
|
until the real bytes fit `--memory-limit-mb` (`Partition::memory_limited` records a clip), and the shared
|
||||||
|
clock decimated until `--max-window` seconds fit. The producer always writes it, whatever the trigger is doing —
|
||||||
that is what guarantees a full pre-trigger history on the *first* latch after arming.
|
that is what guarantees a full pre-trigger history on the *first* latch after arming.
|
||||||
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
|
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
|
||||||
immutable copy of the captured window; see README for why two live rings were dropped.
|
immutable copy of the captured window; see README for why two live rings were dropped.
|
||||||
@@ -101,12 +259,15 @@ Layers to keep straight:
|
|||||||
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
|
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
|
||||||
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
|
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
|
||||||
to either a `ChannelBuffer` or an `EventSeries`.
|
to either a `ChannelBuffer` or an `EventSeries`.
|
||||||
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
|
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` **plots** (rows are added
|
||||||
and removed one at a time by the layout editor), each a
|
and removed one at a time by the layout editor, and a row can be split into up to
|
||||||
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
|
`kMaxCellsPerRow` cells side by side), each plot a
|
||||||
everything the rows have to agree on: the x window (one x link group every row joins),
|
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots; `row(i)` indexes the plots row-major,
|
||||||
|
`figure_row_count()` counts the rows. The figure owns
|
||||||
|
everything the plots have to agree on: the x window (one x link group every plot joins),
|
||||||
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
|
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
|
||||||
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
|
`Figure::link_x()` refuses a `Live` member), the y range of each figure row's cells (`set_share_row_y`: a split
|
||||||
|
row has one y axis, one ruler pair and one fit), the left gutter and the crosshair.
|
||||||
|
|
||||||
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
|
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
|
||||||
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
|
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ cmake --build build
|
|||||||
|
|
||||||
./build/udpscope --server 127.0.0.1 --port 8000
|
./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||||
|
./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--help` lists the registered source kinds.
|
||||||
|
|
||||||
The app can be launched from the shell rather than only from a terminal, but only on request:
|
The app can be launched from the shell rather than only from a terminal, but only on request:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -96,13 +99,70 @@ prefix-wide target *is* that rule, reached through the `Help` component
|
|||||||
(`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every
|
(`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every
|
||||||
component, so its behaviour is unchanged.
|
component, so its behaviour is unchanged.
|
||||||
|
|
||||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
Options: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings;
|
||||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
`--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same options),
|
||||||
|
`--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples` (samples
|
||||||
|
per acquired signal, default 10M, k/M/G suffixes, rounded down to a power of two),
|
||||||
|
`--memory-limit-mb` (hard ceiling on the ring bytes, default 8192; also a Preferences row),
|
||||||
|
`--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,
|
`--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 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
|
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.
|
belongs with `todo.md` item 3, alongside the relative-time toggle.
|
||||||
|
`--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** —
|
||||||
|
before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when
|
||||||
|
it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset
|
||||||
|
and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no
|
||||||
|
usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing
|
||||||
|
is exported when the option is absent, so GTK's own choice stands.
|
||||||
|
|
||||||
|
`--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional
|
||||||
|
(`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel,
|
||||||
|
`ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10,
|
||||||
|
`gen_rate` 50 (Hz, 1 to 200: the waveform generator's DAC update rate), `mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all
|
||||||
|
drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1
|
||||||
|
alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`,
|
||||||
|
`plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is
|
||||||
|
required; a MAC with no `address` is looked up by a broadcast sent on every interface (`Scan`), or by a sweep of
|
||||||
|
`subnet` (`a.b.c`).
|
||||||
|
The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol
|
||||||
|
mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine
|
||||||
|
per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S`
|
||||||
|
switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see
|
||||||
|
the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_e1608.py --port 54711 &
|
||||||
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
||||||
|
--source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals
|
||||||
|
concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the
|
||||||
|
settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local
|
||||||
|
interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest
|
||||||
|
decimation and spaces the samples within a frame), `endian` `little|big`, and `signals`
|
||||||
|
`name:type[count][w],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`, e.g.
|
||||||
|
`Trigger:uint8,Ia:float32,Wave:float32[16]`. A count above 1 makes a vector signal, shown one of two ways: by
|
||||||
|
default as `count` **channels** `Wave[0]`..`Wave[15]` of one sample per cycle each (batched like any scalar
|
||||||
|
list); with a trailing `w` (`Wave:float32[16]w`) as ONE **waveform** channel receiving `count` consecutive
|
||||||
|
samples per cycle at `rate * count`, as a UDPStreamer block signal. A waveform row needs a count above 1, a
|
||||||
|
list with one holds only waveform rows of one count (no zero-padding of scalars), and a list may make at most
|
||||||
|
256 channels (a scalar 1, a `channels` row `count`, a waveform 1). A datagram of any other size than
|
||||||
|
the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as
|
||||||
|
`[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for
|
||||||
|
a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`,
|
||||||
|
`--log FILE`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals 'Trigger:uint8,Ia:float32,Wave:int16[4]' &
|
||||||
|
./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \
|
||||||
|
--source-opt 'signals=Trigger:uint8,Ia:float32,Wave:int16[4]'
|
||||||
|
```
|
||||||
|
|
||||||
|
(Quote the `signals` value: zsh globs the `[4]`. The mock takes the same text with or without the trailing `w`;
|
||||||
|
the datagram is the same, `w` only says how the app shows it.)
|
||||||
|
|
||||||
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
||||||
|
|
||||||
@@ -209,6 +269,54 @@ adapter `PlotArea`'s **figure mode** —
|
|||||||
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
||||||
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
||||||
|
|
||||||
|
A figure row can hold **several plots side by side**, which the host's horizontal split needs and
|
||||||
|
the library had no concept of: `Figure(std::span<const u32> cells_per_row)` builds one, with
|
||||||
|
`plot_count()`, `cells_in_row()`, `first_plot_of_row()` and `row_of_plot()` to move between rows and the
|
||||||
|
flattened plot index, `set_cell_weights()` and `set_col_gap()` to size the cells, and `FigureLayout::cells`
|
||||||
|
(one rect per plot, flattened) and `FigureLayout::col_gap` to read the result back. `Figure(u32 rows)`
|
||||||
|
still means one cell per row, so every row-only caller and test is unchanged, and `row_count()` stays the
|
||||||
|
number of *figure rows* (the host's `RowSet::row_count()` is the plot count, `figure_row_count()` the
|
||||||
|
rows). `plot(i)`, `link_x()` and the crosshair's hovered index all take the **flattened** plot index.
|
||||||
|
Cell widths use the height scheme: a floor of **64 px logical** per cell, the weights share the rest,
|
||||||
|
the last cell closes the row exactly, and a figure too narrow for the floors shrinks the cells
|
||||||
|
proportionally and drops the column gap. The x labels and title follow the **column structure**: a row
|
||||||
|
hides its x strip (`XStrip::None`) only when the row directly below has the *same columns* (same cell
|
||||||
|
count, same normalised cell weights within 1e-4, empty weights = equal), so `[1][2]/[3][4]` has an axis
|
||||||
|
under 3 and 4 only while `[1][2]/[3]` has one under 1, 2 and 3; the rule is structural, transitive down a
|
||||||
|
chain and decided before the measurement pass (`same_columns()` in `figure.cpp`); `set_x_labels_only_on_bottom(false)`
|
||||||
|
labels every row. The left y gutter stays one maximum over the whole figure.
|
||||||
|
|
||||||
|
The cells of a figure row can **share one y range**, symmetric to the x link
|
||||||
|
(`docs/superpowers/specs/2026-11-03-split-shared-y-design.md`):
|
||||||
|
|
||||||
|
- `Figure::set_share_row_y(bool)`, default off so every existing figure and `tests/test_figure.cpp`
|
||||||
|
read as before; the host turns it on in `RowSet::rebuild_all()` before the cells are populated. When
|
||||||
|
on, the cells of each figure row form an implicit y link group, rebuilt with the cells.
|
||||||
|
- Link semantics: `layout()` runs a `sync_y_links()` that adopts a member's changed `y_range` as the
|
||||||
|
group's and pushes it to the others, so a wheel, pan, box zoom or axis drag (all `set_y_range`) needs
|
||||||
|
no controller change; a manual `set_y_range` on a member clears the group's auto flag. A member's own
|
||||||
|
autoscale is suppressed and the group owns the flag: it fits to the union of its members' visible
|
||||||
|
traces (cells with no trace are ignored; a log axis takes the union in axis space), and `fit_all` and
|
||||||
|
a double-click fit the group, not one cell. The group only follows on a laid-out frame, so a caller
|
||||||
|
that sets a range or a fit and reads it back needs one `layout()` in between.
|
||||||
|
- `YStrip {Both, None}` and `Plot::set_y_strip_mode`, mirroring `XStrip`: `None` forces the y label
|
||||||
|
width, title width and reserved floor to 0. `Figure::layout` sets `None` on every cell but the first
|
||||||
|
of its row, structurally from the column index and never from content, and skips `None` cells in the
|
||||||
|
common-gutter maximum. A y axis drag can therefore start only in the leftmost cell.
|
||||||
|
- **Cell placement is by plot width**: the row's y gutter comes off the row width first, then the cells
|
||||||
|
share the rest by the existing `floor + extra * w / total` rule, the floors applying to plot widths.
|
||||||
|
Equal weights give equal plot areas; the first cell's rect is gutter plus its plot width and the rects
|
||||||
|
still tile the row exactly.
|
||||||
|
- **The right gutter belongs to the last cell.** In a shared-y split row only the last cell gets
|
||||||
|
`set_right_gutter()`; it is taken off the row beside the y gutter (capped so both fit in half the row
|
||||||
|
and never beyond the figure's right gutter) and the last cell's rect grows right by it. The gap between
|
||||||
|
two plot frames is then exactly `col_gap` rather than `col_gap` plus a gutter; unsplit rows and rows
|
||||||
|
with sharing off lay out as before (`figure_split_row_gap_*`, `figure_row_gutter_without_sharing_is_the_old_layout`).
|
||||||
|
|
||||||
|
In the GTK4 and GLFW adapters the pointer is routed by **cell rect** (`row_in(rows, x, y)`: the
|
||||||
|
containing cell, else the nearest by squared distance), one `input::Controller` per plot. The tests are
|
||||||
|
the `figure_cell*` cases in `tests/test_figure.cpp` and `glfw_host_routes_by_cell`.
|
||||||
|
|
||||||
`plot::data::RingBuffer` carries two writes a plain SPSC ring has no use for and this app
|
`plot::data::RingBuffer` carries two writes a plain SPSC ring has no use for and this app
|
||||||
does: `amend(absolute, v)` rewrites a retained slot when a late packet's data finally
|
does: `amend(absolute, v)` rewrites a retained slot when a late packet's data finally
|
||||||
arrives, and `clear_to(absolute)` empties a ring and **resumes its count at a given
|
arrives, and `clear_to(absolute)` empties a ring and **resumes its count at a given
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples
|
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.
|
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
|
**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
|
value-initialised, so making a store touches every byte of the planned rings — about a second per
|
||||||
gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an
|
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
|
`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
|
is why it clears the time ring, the absolute-time ring, every value ring, and both the published
|
||||||
@@ -65,8 +65,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
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()`
|
- **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
|
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
|
comes back may well decimate: 20 s of 10 channels at 500 kS/s is 10 M samples a channel, and the budget is
|
||||||
`--buffer-mb`. The loader therefore reads `partition().time_stride` **after** the enables and pushes
|
`--buffer-samples` (rounded down to a power of two, 8.39 M by default). 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
|
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
|
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
|
overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event
|
||||||
|
|||||||
@@ -50,39 +50,25 @@ 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, Configurations) and the one editor that is not a pane (the layout editor): the signal list
|
(Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list
|
||||||
is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
|
is the `signals` slide-over page, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
|
||||||
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the
|
- **An overlay handle that follows what it drags reports gesture offsets in its own moving
|
||||||
|
coordinates; add its displacement back.** A `GtkGestureDrag` measures its offset from where the
|
||||||
|
press began *in the widget's own coordinates*. The layout editor's column handle sits on the
|
||||||
|
divider and `sync_layout_editor()` moves it every frame, so after a few motion events the
|
||||||
|
offset is the pointer's travel **minus** the handle's travel, and the divider converges on half
|
||||||
|
the pointer's travel (`D = P - D`). Record the handle's x in the overlay's coordinates at
|
||||||
|
begin (`gtk_widget_compute_point`) and add `handle_now - handle_begin` to the offset on every update
|
||||||
|
(`layout_col_total_offset()`). The same applies to any future handle that is repositioned
|
||||||
|
from the state its own drag changes.
|
||||||
|
- **Six 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,events,resize-rows,tab-measure,tab-signals}-symbolic.svg`
|
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,resize-cols}-symbolic.svg`
|
||||||
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
|
are the six action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
|
||||||
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
|
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
|
||||||
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
|
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
|
||||||
the Preferences window's icon, not the tab's.
|
the Preferences window's icon and the **Dock toggle's** (the ruler: the dock is the readouts).
|
||||||
|
|
||||||
The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above.
|
|
||||||
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two
|
|
||||||
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed
|
|
||||||
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and
|
|
||||||
`udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They
|
|
||||||
are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it
|
|
||||||
too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's
|
|
||||||
picture, so the pill and the button that turns the trigger on are recognisably about the same thing.
|
|
||||||
The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave
|
|
||||||
rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and
|
|
||||||
reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half
|
|
||||||
that matters.)
|
|
||||||
Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because
|
|
||||||
at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not
|
|
||||||
clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And
|
|
||||||
**GDK paints an icon into a square box whatever the document's aspect** (measured:
|
|
||||||
`gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is
|
|
||||||
letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`,
|
|
||||||
which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is
|
|
||||||
**inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a
|
|
||||||
pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The
|
|
||||||
two tab glyphs measure 29% and 30% ink, inside the range the icons above them set.
|
|
||||||
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
|
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
|
||||||
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
|
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
|
||||||
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
|
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
|
||||||
@@ -233,9 +219,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk
|
entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk
|
||||||
is what a first run meets first -- see the tour bullets in the help-affordance section below.
|
is what a first run meets first -- see the tour bullets in the help-affordance section below.
|
||||||
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
|
- **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
|
(The events pane that taught this is gone -- its list is a slide-over page now, with no divider -- but
|
||||||
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
|
the rule holds for any paned.) With the
|
||||||
while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that
|
position left alone, `shrink-start-child` or not, a closed side child's minimum is reserved as an empty
|
||||||
|
column, 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*
|
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
|
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.
|
still needed, or the minimum would clamp even a zero position.
|
||||||
@@ -243,46 +230,49 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
|
= 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
|
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
|
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
|
window. (That pane was a revealer beside the plot; it is a slide-over page now, so no revealer sits in
|
||||||
|
a horizontal box any more.) Every vertical revealer in the side panel hid this, because there the leftover goes down
|
||||||
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
|
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
|
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.
|
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
|
||||||
**The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher`
|
**The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow`
|
||||||
above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different
|
(`dock_scroller`) holding only the compact **readouts** (`src/ui_dock.cpp`). It
|
||||||
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
|
replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source
|
||||||
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
|
can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one
|
||||||
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
|
this trap teaches: full panels competing for one narrow column each left every one of them short of room.
|
||||||
**cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the
|
The full cursors and measurements panels are now the `cursors` and `measure` **slide-over pages**, reached
|
||||||
|
by the readouts' **Edit** buttons, and `m` toggles `measure` (it no longer scrolls the dock;
|
||||||
|
`dock_show_section()` is gone); the signal list is the `signals` slide-over page, which `g` and the `+` badge
|
||||||
|
open. The header's Cursors toggle no longer opens anything: it only enables the cursors. A *feature's* on/off is no longer a visibility at all -- the
|
||||||
|
**cursors'** toggle sets its pane's **body** insensitive (and the dock's cursor grid beside it) -- the readout has nothing to show with the
|
||||||
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
|
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
|
||||||
(see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured
|
(see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured
|
||||||
*before* it is switched on, so switching it off to change it, or to read what it is set to, is the
|
*before* it is switched on, so switching it off to change it, or to read what it is set to, is the
|
||||||
ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable
|
ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable
|
||||||
exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a
|
exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a
|
||||||
`0` there means the configuration has been locked out again. The
|
`0` there means the configuration has been locked out again. The
|
||||||
trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still
|
trap itself does not go away with the widget: a section scrolled out of view is still laid out, but
|
||||||
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
|
**ask about state, not about size** still holds for anything a tick reads.
|
||||||
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
|
- **The dock is shown but insensitive until the stream has signals ("disable, do not hide").**
|
||||||
current -- a change in what the reading means, not a defect, and no functional code may depend on
|
`update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish
|
||||||
those numbers.
|
alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays
|
||||||
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.**
|
sensitive throughout. The `cursors` and `measure` **pages follow the same condition** through
|
||||||
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a
|
`update_signal_availability()` (called from `update_side_visibility()` and the per-frame block), so a
|
||||||
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab
|
page opened by `m` before the stream has signals is insensitive, not refused. `[readouts] ... pages_sensitive=`
|
||||||
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to
|
reads it. The wish is persisted (`dock = true|false` in the session file) and a change of it
|
||||||
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick
|
schedules a save. `dock:` in the dump reads the result.
|
||||||
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and
|
- **The signal badge is the drag handle, and a drop handler only moves widgets.** Dragging a signal onto a plot
|
||||||
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's
|
starts on its badge in the badge bar (a `GtkDragSource` on the badge's button, payload the signal index as a
|
||||||
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
|
decimal string, taken by the plot area's existing drop target); the list's rows are not drag sources. A drag
|
||||||
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
|
starting with the slide-over open would have part of the plot hidden under the panel, so the source's
|
||||||
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
|
`drag-begin` closes it (which only restyles the badges: nothing is destroyed mid-drag). A click without movement
|
||||||
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
|
never reaches the drag threshold and still opens the page; past it the source claims the sequence and the
|
||||||
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
|
button's click is cancelled. Each badge is also a `GtkDropTarget` for reordering: `on_badge_drop` calls
|
||||||
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
|
`gtk_box_reorder_child_after` and rewrites `App::badge_order` from the widgets, and **never rebuilds the badges** --
|
||||||
the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the
|
the dragged badge and the target whose `drop` emission is running must both survive it. A foreign drag whose string is not a
|
||||||
plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar
|
signal index is refused. `[sigbadges]` reads the controllers, the payload and the reorder; `pane drag:` reads the close.
|
||||||
hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the
|
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The
|
||||||
sidebar probe forces it.
|
trigger pane's `[edit]` button is `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
|
||||||
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two
|
|
||||||
panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
|
|
||||||
(the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** —
|
(the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** —
|
||||||
so the button that leaves configuration mode was blank, which no other reading could see: the button
|
so the button that leaves configuration mode was blank, which no other reading could see: the button
|
||||||
worked, the layout was unchanged, and only `pane edits:` (the two names and
|
worked, the layout was unchanged, and only `pane edits:` (the two names and
|
||||||
@@ -294,13 +284,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking
|
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking
|
||||||
either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps:
|
either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps:
|
||||||
a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard
|
a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard
|
||||||
(`App::panel_switch_sync`) or the program's own write comes back as a click; and the switcher must
|
(`App::panel_switch_sync`) or the program's own write comes back as a click; and the switch must
|
||||||
stay *out* of what the pane disables, because a control inside an insensitive parent cannot be
|
stay *out* of what the pane disables, because a control inside an insensitive parent cannot be
|
||||||
clicked — a switch that greyed out with its own pane could never turn it back on. That is why the
|
clicked — a switch that greyed out with its own pane could never turn it back on. That is why the
|
||||||
sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane.
|
sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane.
|
||||||
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
|
- **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
|
choose.** Tearing the window down finalizes the events page's buttons, then its list box, and the
|
||||||
list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four
|
list's own `selected-rows-changed` then reaches a handler holding the freed pointers (`update_group_buttons()`
|
||||||
|
therefore keeps its `app.window == nullptr` guard): four
|
||||||
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
|
`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* 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
|
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
|
||||||
@@ -317,22 +308,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
|
- **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
|
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
|
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.
|
read as "the page is empty"). A rebuild has to null what it is about to destroy, not only rebuild it
|
||||||
|
(`rebuild_events_pane_list()`, the page half of `refresh_events_pane()`, nulls `events_thumb` before it removes the page list's children). A handler on a
|
||||||
|
row that rebuilds the list destroys the widget it was emitted from: `outlive_the_rebuild(widget)`. The dock's
|
||||||
|
rows are a second list built from the same records; the dock row's click handler is safe only because
|
||||||
|
`replay_event()` never rebuilds the dock list.
|
||||||
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
|
- **`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
|
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
|
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
|
`gtk_picture_get_paintable()` reads success while the page shows an empty rectangle (the event
|
||||||
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
|
page'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
|
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`
|
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.
|
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
|
- **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
|
it.** The events page's thumbnails (the dock's rows have none) come from an `EventThumbCache` hung off the page's list box with
|
||||||
`g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all.
|
`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
|
Nothing ever created it — so for as long as the list existed, **every** row came up without a
|
||||||
picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache
|
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
|
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
|
use. Owned by the **list**, because nothing else has the page'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.
|
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
|
- **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
|
the stored preview is 480: the small one says *which* event this is, and the zoom says what is in
|
||||||
@@ -610,12 +605,39 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
|
|||||||
`ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by
|
`ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by
|
||||||
`context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers,
|
`context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers,
|
||||||
so the two windows cannot drift into two affordances.
|
so the two windows cannot drift into two affordances.
|
||||||
|
- **A hint on a slide-over page's heading is attached all the time and drawn only while the page is open.**
|
||||||
|
The trigger's, signals', cursors' and measurements' headings (`trigger_head`, `signals_head`,
|
||||||
|
`cursors_head`, `measures_head`) live on pages that exist, unmapped, whether or not they are open, so
|
||||||
|
`context_help_sync()` attaches their "(i)" like any other and the count (`hints=12`) does not depend on
|
||||||
|
which page is up. There is **no second code path** and the dock's readouts carry no hint of their own: the
|
||||||
|
pages' hints say what the dock shows too (the cursors' and measurements' bodies mention the read-only copy
|
||||||
|
and the Edit button). A probe that must *see* one drawn needs its page open, like every other reading off a page.
|
||||||
|
- **The tour has one stop for the readouts and none for the pages behind them.** The dock's stop
|
||||||
|
(`side`, target `side_box`, no `page`, so it closes the panel) describes the readouts, the **Edit**
|
||||||
|
buttons and the click on a measurement; the `cursors` and `measure` pages are reached from there rather than
|
||||||
|
walked. A stop for either would be one more `TourStop::page` (see the next bullet), and the plan's
|
||||||
|
length is asserted (`steps=6`, `steps=5` with the dock put away) -- a new stop changes both.
|
||||||
- **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim
|
- **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim
|
||||||
rectangles around the hole are ordinary widgets that swallow the clicks that land on them and
|
rectangles around the hole are ordinary widgets that swallow the clicks that land on them and
|
||||||
nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no
|
nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no
|
||||||
grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the
|
grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the
|
||||||
tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a
|
tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a
|
||||||
later change can add back without noticing.
|
later change can add back without noticing.
|
||||||
|
- **Click-away coexists with the plot's gestures because the observer never claims the sequence.** The
|
||||||
|
slide-over closes on a press anywhere outside it, and the plot underneath already owns pan, zoom, drop and
|
||||||
|
right-click. A `GtkGestureClick` added to `plot_overlay` in `GTK_PHASE_CAPTURE` sees the press before the
|
||||||
|
plot's own controllers and **does not call `gtk_gesture_set_state()`**, so the sequence stays unclaimed and
|
||||||
|
the plot's gestures run exactly as before; claiming it (or putting a full-area target over the plot) would
|
||||||
|
swallow the very pan the click was part of. The observer decides inside/outside by
|
||||||
|
`gtk_widget_compute_point()` into the revealer plus `gtk_widget_contains()`, never by pick. The press
|
||||||
|
that closes the panel still reaches the plot, which is accepted: it is one click and not a drag the user
|
||||||
|
started on purpose. `[slideover]` reads `click_inside_closes=0 click_outside_closes=1`.
|
||||||
|
- **`AdwToggleGroup` is the segmented switch** (libadwaita >= 1.7; the build asks for 1.8). It replaces the
|
||||||
|
`AdwViewSwitcher` for a source with several panels (the E-1608: Analog out | Digital I/O). Its toggles are
|
||||||
|
named by the slide-over page ids, and the group's `notify::active-name` is the *input*; writing it
|
||||||
|
ourselves (`slideover_open` on a source page) goes through `SlideOver::syncing`, or the program's own
|
||||||
|
write comes back as a click and re-opens the page it just opened. It is hidden unless a source page is
|
||||||
|
open and there are two or more, which `[badges] switch_visible=` reads.
|
||||||
- **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children
|
- **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children
|
||||||
and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and
|
and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and
|
||||||
what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves,
|
what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves,
|
||||||
@@ -624,15 +646,26 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
|
|||||||
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
|
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
|
||||||
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
|
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
|
||||||
compiled once and the probe reads either window's state by the same fields.
|
compiled once and the probe reads either window's state by the same fields.
|
||||||
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A
|
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered --
|
||||||
stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops
|
by a hook in the main window and by a stack in the chooser.** The main window has no page stack any
|
||||||
on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page --
|
more: its pages are the slide-over's. A `TourStop::page` there is a slide-over page id (`trigger`), and
|
||||||
names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack`
|
`TourView::on_page(page_user, page)` opens it -- or, for a stop whose `page` is null, **closes** the
|
||||||
before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()`
|
panel, so the card never points at the status line over an open panel. `tour_end()` calls
|
||||||
selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the
|
`on_page(nullptr)` *only if a walk was running* (so Skip, Esc and the last Next close a panel the tour
|
||||||
frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the
|
opened, while dismissing an idle tour does not close one the user opened). The chooser has no hook and
|
||||||
|
keeps `TourView::page_stack` (an `AdwViewStack`, selected by name). `select_view_page()` is the single
|
||||||
|
entry for both, and `show_step()` calls it before anything is measured.
|
||||||
|
Two rules follow from a panel that *slides*. The page switch is **not** followed by a one-shot measure:
|
||||||
|
`tour_update()`, which already runs every frame, fills the spotlight in on a later one, and for a stop
|
||||||
|
with a page it waits for `TourView::page_settled` (the slide-over's `gtk_revealer_get_child_revealed()`)
|
||||||
|
-- until then the target is moving, so the surface is dimmed with an empty hole. And **the hole is the
|
||||||
|
target's bounds clipped by every `GtkScrolledWindow` above it** (`tour_target_bounds()`): a form taller
|
||||||
|
than the panel's scroller overruns it, and an unclipped hole spotlights the badge bar and the status
|
||||||
|
line under the panel. A target scrolled out entirely is a *measured, empty* rectangle (the function
|
||||||
|
returns true), not a failure -- `tour_update()` zeroes the hole on it, because taking "false" for "not
|
||||||
|
measured yet" keeps the previous frame's hole standing. A stack allocates a new page's widgets on the
|
||||||
frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation
|
frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation
|
||||||
bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- and reading the
|
bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- reading the
|
||||||
page *name* back is fine immediately, because that is state and not geometry.
|
page *name* back is fine immediately, because that is state and not geometry.
|
||||||
- **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong
|
- **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong
|
||||||
page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own
|
page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own
|
||||||
@@ -641,11 +674,15 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
|
|||||||
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
|
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
|
||||||
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
|
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
|
||||||
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
|
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
|
||||||
its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a
|
its target is **mapped**; a stop that names a page of a view with an `on_page` hook is **always**
|
||||||
wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed
|
kept (the hook opens the page whatever the dock is doing, since the slide-over does not depend on it);
|
||||||
pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter
|
and in a view with a `page_stack` a stop that names a page is kept when the stack is mapped -- a wrong
|
||||||
lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift
|
page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped
|
||||||
into two rules and because the rule needs the view's own `page_stack`.
|
too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and
|
||||||
|
the main window's tour is five steps there (`[toursurface] collapsed: ... steps=5`: the signals stop names
|
||||||
|
the `signals` slide-over page, which does not depend on the dock either). The filter
|
||||||
|
lives in `tour_begin()`, not in each window's `start_tour()`, so the two windows cannot drift
|
||||||
|
into two rules.
|
||||||
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
|
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
|
||||||
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
|
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
|
||||||
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
|
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
|
||||||
@@ -679,8 +716,9 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
|
|||||||
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
|
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
|
||||||
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
|
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
|
||||||
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
|
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
|
||||||
dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger
|
dump" rule again. `is_visible()` *and* `get_mapped()`, not `get_visible()`: a widget under a hidden parent -- the trigger
|
||||||
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop
|
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0 (and a widget on a closed slide-over page, `trigger_*`
|
||||||
|
while the panel is shut, would read a bogus `630,46,0,0` without the mapped test), and a crop
|
||||||
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
|
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
|
||||||
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
|
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
|
||||||
larger screen gives different rectangles for the same panels.
|
larger screen gives different rectangles for the same panels.
|
||||||
@@ -307,7 +307,7 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b
|
|||||||
non-activatable -- a heading that highlighted under the pointer would read as a row you can plot --
|
non-activatable -- a heading that highlighted under the pointer would read as a row you can plot --
|
||||||
and it carries the `+` that creates a channel, appended even when there are none, because a `+` that
|
and it carries the `+` that creates a channel, appended even when there are none, because a `+` that
|
||||||
waited for a channel to exist could never create the first one. A math row's menu is *Edit graph…*,
|
waited for a channel to exist could never create the first one. A math row's menu is *Edit graph…*,
|
||||||
*Default colour*, *Delete*: no Information and no Calibrate, because calibration is applied at
|
*Default colour*, *Delete*: no Information and no Gain/Offset rows, because calibration is applied at
|
||||||
ingest and a derived channel has no stream to describe, and no rename, because the node editor owns
|
ingest and a derived channel has no stream to describe, and no rename, because the node editor owns
|
||||||
a channel's name and unit and a second place to change them would be a second answer to the same
|
a channel's name and unit and a second place to change them would be a second answer to the same
|
||||||
question. A row's tick is the channel's own switch and must reach `App::math` -- the copy the
|
question. A row's tick is the channel's own switch and must reach `App::math` -- the copy the
|
||||||
@@ -334,7 +334,7 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b
|
|||||||
before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads
|
before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads
|
||||||
as intermittent and uptime-dependent. The dump's `math '<name>': N samples vs M time slots` is the
|
as intermittent and uptime-dependent. The dump's `math '<name>': N samples vs M time slots` is the
|
||||||
reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose
|
reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose
|
||||||
ring has wrapped: give it a small `--buffer-mb`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
|
ring has wrapped: give it a small `--buffer-samples`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
|
||||||
arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state:
|
arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state:
|
||||||
every edit that probe makes follows a structural change, which replans the buffer and restarts every
|
every edit that probe makes follows a structural change, which replans the buffer and restarts every
|
||||||
ring at absolute 0, where both halves of this are identities.
|
ring at absolute 0, where both halves of this are identities.
|
||||||
|
|||||||
@@ -118,6 +118,47 @@ 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.
|
||||||
|
- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct
|
||||||
|
levels*, and the window's own size multiplies all of it.** The cadence above bounds *how
|
||||||
|
often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at
|
||||||
|
the user's scale -- a **15 s** window, `--buffer-samples 100M` (so `time_stride` stays 1 and the
|
||||||
|
window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with
|
||||||
|
`[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading
|
||||||
|
`recomputes`/`each`/`covered`/`buckets`/`raw`:
|
||||||
|
|
||||||
|
| pane | `each` | `raw` | tick mean |
|
||||||
|
|---|---|---|---|
|
||||||
|
| empty | -- | -- | 1.34 ms |
|
||||||
|
| 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms |
|
||||||
|
| 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms |
|
||||||
|
| 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms |
|
||||||
|
| 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms |
|
||||||
|
|
||||||
|
So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan)
|
||||||
|
and per **distinct share** (one accumulator), and the window's samples are the multiplier on
|
||||||
|
both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's
|
||||||
|
budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a
|
||||||
|
pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside
|
||||||
|
it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and
|
||||||
|
it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each
|
||||||
|
`MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer
|
||||||
|
absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the
|
||||||
|
producer.
|
||||||
|
Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms
|
||||||
|
(three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket),
|
||||||
|
`measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket,
|
||||||
|
level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it
|
||||||
|
is the innermost loop of a walk whose iteration count is the window's buckets *times* the
|
||||||
|
pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the
|
||||||
|
pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a
|
||||||
|
per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a
|
||||||
|
call per bucket either.
|
||||||
|
The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's
|
||||||
|
`[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal
|
||||||
|
whose local range covers the level everywhere -- white noise, a noisy machine channel --
|
||||||
|
degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel
|
||||||
|
against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the
|
||||||
|
pane for a slow run, and no cheaper walk fixes it.
|
||||||
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
|
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
|
||||||
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
|
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
|
||||||
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
|
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Invariants: the plot, the figure and the layout
|
# Invariants: the plot, the figure and the layout
|
||||||
|
|
||||||
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content.
|
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's and the slide-over's overlay children, and the rule that layout must not depend on content.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
from the paned's own width, re-parenting the column, the property form of its children, size requests):
|
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
|
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` =
|
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
|
`max-sidebar-width` = 280 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
|
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
|
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=280` under a 2x scale is exactly
|
||||||
the case that used to be zero.
|
the case that used to be zero.
|
||||||
- **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split
|
- **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
|
view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading
|
||||||
@@ -23,12 +23,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own
|
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`
|
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
|
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"
|
`set_active`), `update_side_visibility()` is the single place that turns that wish into the panel's visibility
|
||||||
into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees —
|
and "are there signals" into the dock's *sensitivity* (the button stays sensitive), and `tick()` re-asserts both when the widget disagrees —
|
||||||
which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The
|
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.
|
no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed.
|
||||||
|
The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save.
|
||||||
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
|
- **`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
|
`sidebar-width-unit` says.** Setting a pixel count there (280) 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
|
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`.
|
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
|
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
|
||||||
@@ -39,11 +40,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
|
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
|
||||||
taller figure.
|
taller figure.
|
||||||
|
|
||||||
|
- **Events never take the plot's width.** The events list is a slide-over page (an overlay child over the plot) and
|
||||||
|
the dock's Events block lives in the fixed 280 px column, so there is no events divider, revealer or pane beside the
|
||||||
|
plot to squeeze it; the first latch must not move the plot area (`[events] ... plot_w_stable=1`, which compares the
|
||||||
|
allocated width before and after). A dock row is a fixed-width ellipsized label (title at 18 characters, `#seq`), the
|
||||||
|
list is a fixed 112 px scroller capped at `kDockEventsMax` rows plus one "N more" row, and it has no thumbnails: the
|
||||||
|
dock's width and height do not depend on how many events there are or what they say ("layout must not depend on
|
||||||
|
content"). The thumbnails are the *page's* (138 px wide).
|
||||||
|
|
||||||
- **`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
|
||||||
removed.
|
removed.
|
||||||
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
|
- **A `Figure`'s shape (its cells per row) is fixed at construction**, so `RowSet::rebuild_all()` builds
|
||||||
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
|
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
|
||||||
state. That makes one path for "a row's traces changed" and "the row count changed",
|
state. That makes one path for "a row's traces changed" and "the row count changed",
|
||||||
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
|
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
|
||||||
@@ -96,6 +105,153 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
an equal division. `sync_layout_editor()` runs every frame on purpose:
|
an equal division. `sync_layout_editor()` runs every frame on purpose:
|
||||||
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
|
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
|
||||||
the comparisons and nothing else, and a resize needs no notification.
|
the comparisons and nothing else, and a resize needs no notification.
|
||||||
|
- **A figure row can be split into cells, and every plot index in the host is the flattened one.**
|
||||||
|
`RowSet::row_count()` is the number of **plots** and `row(i)`, `Row::plot`, the cursors, the
|
||||||
|
measurements, `bind()`, the readouts and the persistence layers all index plots row-major (row 0's cells
|
||||||
|
left to right, then row 1's); `figure_row_count()` is the number of figure rows, and
|
||||||
|
`cells_in_row()`/`first_plot_of_row()`/`figure_row_of()`/`cell_of()` translate. **Code that means "the
|
||||||
|
rows of the figure" -- the editor's row `+`, the row drag, the row weights, `fl.rows` -- must say
|
||||||
|
`figure_row_count()`**: with no split the two are equal and a slip passes every unsplit probe, which
|
||||||
|
is how a row weight once drifted off its row (see the `row-weights` reading). The caps are
|
||||||
|
`kMaxCellsPerRow` = 4 per row and `kMaxRows` = 8 **plots** in all (the constant bounds plots now, not
|
||||||
|
figure rows), so every per-plot structure sized for 8 is unchanged. `plot_label()` is what the UI
|
||||||
|
prints for a plot: the row's number (`2`) for a one-cell row, the number and a letter (`2a`..`2d`) for
|
||||||
|
a split one -- never a flat index, which is 0-based and says nothing about where the plot is.
|
||||||
|
What the cells share and what each keeps:
|
||||||
|
- **One time window.** Every cell of every row is a member of the one x link group, so a pan or zoom
|
||||||
|
in any cell moves them all, the crosshair is one x drawn in every cell (the hovered cell shows its
|
||||||
|
y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`).
|
||||||
|
A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes
|
||||||
|
(see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)).
|
||||||
|
- **One y per figure row.** The cells of a figure row share one y range (the plotter's
|
||||||
|
`Figure::set_share_row_y(true)`, which `rebuild_all()` sets before the cells are populated): one y
|
||||||
|
axis (auto or fixed), one pair of horizontal rulers, one fit. A wheel or axis drag in any cell moves
|
||||||
|
the row, an x gesture on any cell that carries an x strip moves the shared window. The host therefore acts on the
|
||||||
|
**figure row** of a plot it is given (`set_y_auto`, `set_y_range`, `fit_all`, the Auto/Fixed dialog,
|
||||||
|
`row_unit`, which joins the units of every cell and reads "" if they differ). The rulers are stored on
|
||||||
|
the row's first plot index, copied to the other cells by `sync_cursors()` / `insert_cell()` /
|
||||||
|
`rebuild_all()` and placed in every cell by `place_cursors()`. The cursors pane lists **one ruler line
|
||||||
|
per figure row**: `row N` with N the 1-based figure row for a split row, and the 0-based plot index
|
||||||
|
for an unsplit row (the pane read that before splitting existed). Readouts and measurements stay per
|
||||||
|
trace and per plot index; only the axis is shared.
|
||||||
|
- **The x axis is the bottom row's for every run of rows with the same columns.** A figure row gets
|
||||||
|
`XStrip::None` only if the row directly below has the same columns (same cell count and same
|
||||||
|
normalised cell weights); a row whose columns differ keeps its own strip under each of its cells
|
||||||
|
(`[1][2]/[3]` has axes on 1, 2 and 3), because the axis below is at another width and scale. The
|
||||||
|
rule is structural and transitive, never a function of content. Every cell that carries a strip is
|
||||||
|
a place a middle-drag on the axis works. The `t0` caption belongs on every cell that carries a strip,
|
||||||
|
not only the last figure row's.
|
||||||
|
- **The right gutter is the last cell's.** In a split row with a shared y the plotter gives
|
||||||
|
`right_gutter` to the last cell alone and takes it off the row like the y gutter, so the visible gap
|
||||||
|
between two plots is `col_gap` and equal weights give equal axes widths.
|
||||||
|
- **One left gutter, on the first cell only.** The y gutter is one maximum for the whole figure with the
|
||||||
|
same reserved floor as before (`Figure::set_min_y_gutter()`). It is carried by the first cell of each
|
||||||
|
row alone: `Figure::layout` gives every other cell `YStrip::None` from its column index (structurally,
|
||||||
|
never from what it holds), which forces its y label width, title width and reserved floor to 0. A
|
||||||
|
`None` cell contributes 0 to the common maximum, so a split row's non-first cells cannot widen the
|
||||||
|
gutter and a row's gutter does not depend on how many cells it has. A per-column gutter sized by the
|
||||||
|
labels would make a cell's width depend on its content, which "layout must not depend on content"
|
||||||
|
forbids. A row whose labels are narrower than the gutter simply has a wider margin. The weights apply
|
||||||
|
to **plot widths**, not cell rects (below).
|
||||||
|
- **Widths are weights with a floor.** The plotter keeps 64 px logical per plot and a column gap
|
||||||
|
(`kColGap`, 6 px); the editor's drag is stricter (below). The floors and the weights act on **plot widths**: the row's y gutter (carried by the first
|
||||||
|
cell) is taken off the row width first, then the cells share the rest, so equal weights give equal plot
|
||||||
|
areas and the first cell's rect is its gutter plus its plot width. A weight is a share of what is
|
||||||
|
left *after* the gutter and the floors, so cells of `{3, 1}` read 524:229 (rects) in a 756 px
|
||||||
|
figure, not 3:1, and the editor's 96 px drag floor is a floor on the plot *width*, not on the share. Because a cell's width is
|
||||||
|
`64 + extra * weight / total` and not proportional to its weight, `layout_col_apply()` does not scale
|
||||||
|
the pair's weights by the width ratio: it keeps the pair's weight sum `S` from the drag's *start*
|
||||||
|
(`layout_col_drag_start`) and gives the left cell `S * (want_left - 64) / (pair - 128)`, clamped to
|
||||||
|
`[0.001, 0.999]` (the plotter refuses non-positive weights; `0.5` if the pair is not wider than 128).
|
||||||
|
`want_left` and the pair are plot widths (`layout_col_drag_begin` reads each plot's `layout().axes.w`, after a `layout()` on the current figure, so the gutter and the inter-axes gap are never counted as weight); `want_left` is the pointer's width clamped to `[96, pair - 96]` logical px. The divider therefore
|
||||||
|
follows the pointer, the shrinking cell reads 96 px at the extreme and the pair keeps its total width
|
||||||
|
(the split probe's `col-drag` line asserts all three). `kPlotterMinCellPx` (64) mirrors the plotter's
|
||||||
|
`kMinCellWidthLogical`; both are logical px (the plotter scales it by `ui_scale`, which is
|
||||||
|
`PlotArea::device_scale()`, the divisor used to turn layout rects into logical px), so the two
|
||||||
|
must change together.
|
||||||
|
- **A cell is removed with its traces, and a new cell is empty.** `insert_cell(fr, at)` pushes the
|
||||||
|
cells at and after `at` right *with their contents* (the same rule as `insert_row()`), and
|
||||||
|
`remove_cell(plot)` on a one-cell row removes the row. Both return false at a cap or when they would
|
||||||
|
leave no plot, and never grow past 4 / 8.
|
||||||
|
**The editor's controls** (`rebuild_layout_editor()`): a cell `+` (`kLayoutCellKey`, tooltip "split
|
||||||
|
here") at a row's left edge, at its right edge and at every internal boundary -- `n + 1` of them for a
|
||||||
|
row of `n` cells, so a one-cell row can be split from either side -- inserting at that position; a drag
|
||||||
|
double-headed arrow (`kLayoutColKey`, `udpscope-resize-cols-symbolic`) at each internal boundary that moves the boundary between
|
||||||
|
its two neighbours, **each floored at `kMinCellPx` = 96 px logical** (or half the pair when the pair is
|
||||||
|
narrower) and measured from the two widths at drag start, so the drag does not compound. **The handle follows the divider it
|
||||||
|
drags, so the gesture's offset shrinks by the handle's own displacement**: `on_layout_col_update` adds it back
|
||||||
|
(`layout_col_total_offset(offset, handle_x_now, handle_x_at_begin)`, the handle's x in overlay coordinates, stored by
|
||||||
|
`on_layout_col_begin` in `layout_col_drag_handle_x0`) before multiplying by the scale; without it the divider settles at half the pointer's travel. The per-plot
|
||||||
|
`×`, which removes the cell in a split row ("remove this plot") and the row in a one-cell row ("remove
|
||||||
|
this row"), insensitive when it would leave no plot. Cell `+` is insensitive at 4 cells in its row or 8
|
||||||
|
plots in all; the row `+` is insensitive at 8 plots. **The cell `+` is keyed by `kLayoutCellKey` (figure row * 8 + position + 1) and
|
||||||
|
never by `set_row_key()`**: `row_key()` is a plot or boundary index, `layout_button()` finds a button by it,
|
||||||
|
and a cell `+` that also carried one would be found as a row button; `sync_layout_editor()` tells the kinds
|
||||||
|
apart by which key it finds. Like every editor control the
|
||||||
|
cell buttons are placed from `FigureLayout::cells`, divided by `device_scale()`, and the editor does
|
||||||
|
not draw while the figure's rects are not yet one per plot.
|
||||||
|
- **The slide-over is the second thing placed over the plot, and it is one opaque widget, not a
|
||||||
|
transparent area.** The same pick rule as the editor's buttons applies, harder: the panel is a
|
||||||
|
`GtkRevealer` aligned to the end edge (`halign=END`, `valign=FILL`) at `kSlideOverWidth` = 360 px, so
|
||||||
|
its allocation *is* the part of the plot it covers and everything to its left is still the plot's --
|
||||||
|
pan, zoom, drop and hover are untouched. A full-area child with a panel drawn in it would swallow all
|
||||||
|
of them. Closed, the revealer is `can_target(FALSE)` (and `gtk_overlay_set_clip_overlay()` keeps its
|
||||||
|
slide-out inside the plot), so the frames of a slide-out do not eat input either. `[slideover]` reads
|
||||||
|
it back: `outside_hits_plot=1 inside_hits_panel=1`. Three consequences:
|
||||||
|
the panel **never resizes the plot** (it is an overlay child; `plot_unchanged=1`), its height is the
|
||||||
|
plot's and never the page's (each page has a `GtkScrolledWindow` with a `NEVER` horizontal policy that
|
||||||
|
does not propagate its child's height), and content that is taller than the plot -- the trigger form
|
||||||
|
on a 470 px window -- scrolls inside the panel instead of growing it. The widgets that go in a page
|
||||||
|
are reparented into it; their widths must fit 360 minus the border (the trigger form measures 267
|
||||||
|
minimum, the E-1608's panels 197). **One page wider than that widens the panel
|
||||||
|
for all of them** (the stack measures every page): the signals page's hint line once did, unwrapped, and
|
||||||
|
`[slideover] panel=` read 376 instead of 360; the label now wraps.
|
||||||
|
- **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px
|
||||||
|
(`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a
|
||||||
|
badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on
|
||||||
|
content" for the new row: the Trigger badge's state is a word from a short fixed set and the Source badge
|
||||||
|
(shown only when the kind has source pages; hidden otherwise) is a kind's display name only (no connection state: that is the status bar's), so what changes with the stream is the row's *width* (the bar has room to
|
||||||
|
spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to
|
||||||
|
need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear
|
||||||
|
inside the slide-over's scroller, never in the bar.
|
||||||
|
- **The signal badges live in a scroller, so the bar's minimum width never depends on the signal count.**
|
||||||
|
The bar's signals slot holds a `GtkScrolledWindow` (horizontal `EXTERNAL`, vertical `NEVER`) around one
|
||||||
|
row of badges and the `+`, because a button row's minimum width is the sum of its children: nine badges
|
||||||
|
(the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen.
|
||||||
|
Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`.
|
||||||
|
The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and
|
||||||
|
`bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 124 on UDPS:
|
||||||
|
the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has
|
||||||
|
taken the scroller out of the measurement.
|
||||||
|
- **The dock's width is fixed too, and it is the readouts' width that holds it there** (`side_column`
|
||||||
|
`width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts and the Events block (a fixed-height list, below), so a
|
||||||
|
section's visibility is never what makes it taller; the scroller absorbs the rest. **The readouts' minimum
|
||||||
|
width is the sum of their cells' character widths, and it must stay under 280** (the Readouts box is 268:
|
||||||
|
280 less its 6 px margins): a cursor line is the `who` cell (5 characters) plus three value cells (9 each),
|
||||||
|
every one ellipsized at that width (`width-chars` = `max-width-chars`), so a long value is cut and its
|
||||||
|
tooltip carries the text and the layout never depends on what a value reads. The `who` cell was 8
|
||||||
|
characters, which made the readouts' minimum width 304 px; that pushed the sidebar to 304 px
|
||||||
|
(`dock_w=304`) and defeated the 280 pin. Widening any cell is the same
|
||||||
|
trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`, and for the Events block `dock events: ... dock_w=` (268).
|
||||||
|
- **The readouts strip is a fixed 26 px, scrolls sideways, and is shown only when the dock is not beside the plot and there is something to read.**
|
||||||
|
`readouts_strip` (a `GtkScrolledWindow`, `height-request` 26, horizontal `external`, vertical `never`) sits
|
||||||
|
in `left_column` between `plot_overlay` and `badge_bar`. Its height is a request and not a measurement of
|
||||||
|
its chips (the buttons are trimmed by `button.udpscope-strip-button` in `ui_widgets.cpp`, or Adwaita's
|
||||||
|
34 px flat button would push it past 26), the chips' cells are fixed-width ellipsized labels like the
|
||||||
|
dock's, and an overflow scrolls with no bar taking height: "layout must not depend on content" for the
|
||||||
|
strip, the same as for the badge bar. It is *visible* exactly when (`show-sidebar` is false **or** the split view is `collapsed`)
|
||||||
|
**and** (the cursors are on **or** there is a measurement)
|
||||||
|
(`sync_readouts_strip()`, from the tick after the wish is re-asserted and before `set_collapsed()` -- so
|
||||||
|
`show-sidebar` is the wish and not the split view's collapse-time write, and `collapsed` is the previous
|
||||||
|
frame's value). Hidden with a wide window and the dock beside the plot, so the plot's 26 px go to one
|
||||||
|
pane of readouts and never both, and hidden with nothing to read (cursors off, no measurement). That last
|
||||||
|
is a deliberate, user-requested exception to "disable, do not hide": an empty strip is 26 px of nothing, and
|
||||||
|
the dock keeps both blocks on screen so the empty state is never invisible; the strip's two groups and the
|
||||||
|
separator follow the same rule (`strip_cursor_group` with the cursors, `strip_measure_group` with
|
||||||
|
measurements, `strip_sep` with both). In a collapsed window the dock is an overlay on the plot and the strip
|
||||||
|
stays, overlay open or not. The plot is 26 px shorter while it shows; that is the dock's and the window's
|
||||||
|
state moving the layout, not content. Reading `h=26` with `visible=1` (and `h=0` with `visible=0`) in
|
||||||
|
`strip:` is the assertion, `visible_ok=1` that `visible` equals `expected` and `groups_ok=1` that the groups agree.
|
||||||
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
|
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
|
||||||
or a new CONFIG renumbers the channel list, so an index would silently re-point a
|
or a new CONFIG renumbers the channel list, so an index would silently re-point a
|
||||||
trace at another signal.
|
trace at another signal.
|
||||||
@@ -118,6 +274,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
||||||
letting the counter grow: each is a status field whose *width* would otherwise depend
|
letting the counter grow: each is a status field whose *width* would otherwise depend
|
||||||
on the stream, and the status row is directly above the plot.
|
on the stream, and the status row is directly above the plot.
|
||||||
|
A split row keeps this rule: cell widths come from the weights and the 64 px floor and never from what
|
||||||
|
a cell's labels need, and the gutter stays one figure-wide maximum.
|
||||||
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
|
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
|
||||||
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
||||||
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
||||||
|
|||||||
@@ -41,6 +41,38 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
window's **first sample** and never from history before the window, so a measurement never depends on data
|
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
|
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
|
||||||
from earlier samples.
|
from earlier samples.
|
||||||
|
- **The dock's readouts and the pages' panels are one string, never two.** The dock (`src/ui_dock.cpp`)
|
||||||
|
shows a read-only copy of the cursors table and of the measurement values, and it must not format
|
||||||
|
anything: `update_cursors_pane()` and `update_measures()` format each value **once**, into a local, and
|
||||||
|
hand that same variable to the page's label **and** to `dock_cursor_cell()` / `dock_measure_value()` --
|
||||||
|
on every branch, including the `"--"` blanking one (cursors off, no sample). A second formatter in the
|
||||||
|
dock would be two spellings of one reading that drift the first time `format_measure()` changes, and
|
||||||
|
the dock exists so that the two can be read side by side. The `who` cell is copied from the page's label
|
||||||
|
verbatim (markup chip and name), and the dock's lines are rebuilt from the page's whenever the page
|
||||||
|
rebuilds (`dock_rebuild_cursors()`, `dock_rebuild_measures()`), so the line counts agree by
|
||||||
|
construction. A cell is **ellipsized at a fixed character count** (who 5, values 9, measurement type 12) with
|
||||||
|
the full text in its tooltip: the dock is 280 px wide and its layout must not depend on what a value
|
||||||
|
reads, so a long value is cut and quoted whole in the tooltip rather than widening the column (the
|
||||||
|
`who` cell was 8 characters, which made the readouts 304 px wide and pushed the sidebar past its
|
||||||
|
pin). `[cursors-probe] ... dock cursors:` and `[measures-probe] ... dock measures:` assert it: `same_text=1`
|
||||||
|
compares `gtk_label_get_text()` (the untruncated text) of every dock label with the page's, and
|
||||||
|
`fits=1` that the Readouts box is no wider than the dock.
|
||||||
|
- **The readouts strip is a third reader of the same string, never a second formatter.** When the dock is
|
||||||
|
not beside the plot (hidden, or the window collapsed so the dock is an overlay) the readouts are shown in the strip under the plot (`readouts_strip`, `src/ui_dock.cpp`): the
|
||||||
|
cursor chips' `who` and *delta* labels and the measurement chips' value labels. They are written **in the
|
||||||
|
same call** as the dock's -- `dock_cursor_cell()` and `dock_measure_value()` set the strip's label from the
|
||||||
|
argument they were handed, and `dock_rebuild_cursors()` / `dock_rebuild_measures()` rebuild its chips
|
||||||
|
beside the dock's lines -- so a value is still formatted once, into one local, and a feature that writes a
|
||||||
|
dock cell without going through those two setters leaves the strip stale. The strip keeps the dock's
|
||||||
|
rules: the same fixed-width ellipsized cells (who 6, type 10, value 9 characters, full text in the
|
||||||
|
tooltip), the delta cell reads `--` on the same branches, and the cursor chips are *insensitive* with the
|
||||||
|
cursors off -- through `dock_set_cursors_sensitive()`, which writes the dock's grid and the
|
||||||
|
strip's chip box together; a second `gtk_widget_set_sensitive()` on either is two spellings of one state.
|
||||||
|
The one place the strip hides what the dock only disables: with the cursors off the strip's Cursors group is
|
||||||
|
not shown, with no measurement its Measures group is not, and with neither the strip is not (the dock keeps
|
||||||
|
both blocks, `no measurements` included; the strip has no placeholder). That is
|
||||||
|
`sync_readouts_strip()`, not a sensitivity.
|
||||||
|
`[cursors-probe]` / `[measures-probe]` `strip: ... same_text=1` asserts it.
|
||||||
- **`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
|
||||||
@@ -55,7 +87,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
|
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
|
||||||
that range falls back to `%.3g`.
|
that range falls back to `%.3g`.
|
||||||
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
|
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
|
||||||
pane *insensitive* rather than hiding it -- a panel you can still see is a panel you can read, and the
|
pane *insensitive* (the page's body, and the dock's cursor grid beside it) rather than hiding it -- a panel you can still see is a panel you can read, and the
|
||||||
settings you are about to switch back on must not vanish while you look for them -- and every cell in
|
settings you are about to switch back on must not vanish while you look for them -- and every cell in
|
||||||
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
|
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
|
||||||
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
|
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
|
||||||
|
|||||||
@@ -13,24 +13,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
(`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()`
|
(`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()`
|
||||||
resolves it itself when they are already there) — a name that resolved to nothing would silently move
|
resolves it itself when they are already there) — a name that resolved to nothing would silently move
|
||||||
the trigger to channel 0.
|
the trigger to channel 0.
|
||||||
- **A session that loaded is what locks the two panes.** Locked is the returning user's mode: the
|
- **A session that loaded is what locks the trigger pane.** Locked is the returning user's mode: the
|
||||||
trigger pane shows one compact line, the signal list only the signals that are on, and neither offers
|
trigger pane shows one compact line and offers no control that changes the configuration (the signals
|
||||||
a control that changes the configuration. A *first run* — no session file for that source — never
|
have no lock). A *first run* — no session file for that source — never
|
||||||
reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode
|
reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode
|
||||||
is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts
|
is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts
|
||||||
where a returning user expects to start. **Dragging a row onto a plot is not configuration and survives
|
where a returning user expects to start. **Dragging a signal onto a plot is not configuration and survives
|
||||||
the lock**: filling plots is what the list is *for*, so a locked row keeps its `GtkDragSource` — from
|
the lock**: it is the signal *badge's* drag (the list's rows are no longer drag sources, and the badges do
|
||||||
anywhere on it, the colour chip included, which in configuration mode has a click gesture that claims
|
not follow the lock). `[sigbadges] drag_sources` is the reading, and `drag_sources` on the `signals panel:`
|
||||||
the sequence precisely so a drag cannot start there. `drag_sources` on the `signals panel:` line is the
|
line must be 0.
|
||||||
reading, and it must be `rows - 1`.
|
|
||||||
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
||||||
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
|
one that was closed: the window, the rows and their slot signals, the calibration, **the unit renames**
|
||||||
|
(`[[unit]]`, `name` + `unit`, only the overrides; `ScopeModel::set_signal_unit` re-applies them to every CONFIG,
|
||||||
|
and `set_signals()` takes them off the incoming units before the "same stream" comparison so a reconnect still
|
||||||
|
keeps its history), **the enabled
|
||||||
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**, **the measurements pane**, and **`measure_stats`** — the measurements statistics
|
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`
|
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
|
||||||
when absent. Signal identity
|
when absent, and **`dock`** -- `LayoutState::dock_shown`, the Dock button's state (the user's last word on it,
|
||||||
|
`App::sidebar_wanted`): a bare top-level boolean written where `side_tab` used to be, default `true`.
|
||||||
|
It is the **only** thing the dock persists -- its 280 px width is fixed, so there is nothing to store --
|
||||||
|
and **the open slide-over page is deliberately not persisted** (a panel that opens by itself at start-up
|
||||||
|
is a surprise, and a page id from another source kind may not exist). The parser ignores unknown keys
|
||||||
|
(its key chain has no erroring final `else`), so a file written before the tabs became a dock
|
||||||
|
(`side_tab = "measure"`, or `side_tab = "source-ao"`) still loads and opens with the dock shown;
|
||||||
|
nothing reads the old key, and `[dock-session]` asserts it. 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
|
||||||
@@ -42,6 +51,27 @@ 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".
|
||||||
|
- **A `[[plot]]` table is one *plot* (one cell), and carries `row` and `width` for the horizontal split.**
|
||||||
|
`plots = N` counts plots, as it always did. `row` is the 0-based **figure row** the plot sits in and
|
||||||
|
`width` the cell's share of that row's width (`LayoutState::Row::row`, `::width`); the tables run in
|
||||||
|
the flattened order, so a row's cells are consecutive and left to right. The row's height share
|
||||||
|
`weight` stays per *figure row* but is **written on every cell of it** (so a table read alone is
|
||||||
|
complete) and **read from the row's first cell**; `collect_layout()` takes it from
|
||||||
|
`row_weights()[figure_row_of(plot)]`, not from `row_weights()[plot]` -- the latter is a plot index into
|
||||||
|
a figure-row vector, right until the first split. The file is written `row`, `width`, `weight`,
|
||||||
|
`signals`; a state built with `row < 0` (nothing in the app does) omits `row` and `width`.
|
||||||
|
Three loading rules, all in `apply_layout()` and none of them a refusal, because a session is the
|
||||||
|
user's arrangement and not worth losing to a malformed key:
|
||||||
|
(1) **a file with no `row` loads as one cell per row** -- every session written before the split --
|
||||||
|
with the per-plot `weight`s as the row heights, exactly as it opened before;
|
||||||
|
(2) **a `row` sequence that cannot be a layout loads the same way**: some plot without a `row`, a row
|
||||||
|
number that goes backwards or skips one, a first row that is not 0, more than 4 cells in a row, or
|
||||||
|
more than 8 plots; the signals stay on the plots they were on (`invalid-rows` is the reading);
|
||||||
|
(3) **cell widths are applied after the arrangement**, because `set_cells_per_row()` resets them, and
|
||||||
|
only for rows of two or more cells -- a one-cell row has no widths and its `width` is ignored.
|
||||||
|
Both new keys are parsed with the file's other forgiving rules: `row` must be an integer >= 0 and
|
||||||
|
`width` a number > 0, anything else leaves the default. A save after loading a pre-split file writes
|
||||||
|
the `row`/`width` keys, which is how an old session is upgraded without anyone asking.
|
||||||
- **Measurement options persist under *named* keys -- `threshold`, `threshold2`, `noise` and `hf` -- so a
|
- **Measurement options persist under *named* keys -- `threshold`, `threshold2`, `noise` and `hf` -- so a
|
||||||
file written before them loads at the defaults**, which are exactly the behaviour it was written against:
|
file written before them loads at the defaults**, which are exactly the behaviour it was written against:
|
||||||
`noise = 10` is the
|
`noise = 10` is the
|
||||||
@@ -139,7 +169,7 @@ expression of the scheme, and the name is `sanitize()`d exactly as the host is,
|
|||||||
with a slash cannot escape the directory.
|
with a slash cannot escape the directory.
|
||||||
|
|
||||||
A configuration carries the whole session *and* the settings that can only be decided before the
|
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
|
first socket: `buffer_samples`, `relative_time`, `capture_packets`. The startup chooser applies them
|
||||||
(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed
|
(`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
|
— 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
|
app applies what can change live and says in the status line that the rest takes effect on the
|
||||||
@@ -150,9 +180,9 @@ A configuration's *startup* settings reach the model from two directions, and bo
|
|||||||
named with `--server`, which never reaches the chooser at all. Without the second, a saved host's
|
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
|
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
|
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`,
|
one that never took effect. **A flag the user typed wins over the file** (`--buffer-samples`,
|
||||||
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
|
`--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".
|
`Config::buffer_samples_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
|
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
|
I on" survives a restart. A session written before configurations existed is **imported once** as
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
||||||
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
||||||
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
||||||
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
|
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only a plot
|
||||||
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
|
whose x strip draws labels (`plot::x_strip_has_labels(x_strip_mode())`: the bottom row, and any row above it whose
|
||||||
|
columns differ from the row below's) carries one, every other plot being set to `""`; and the renderer measures it
|
||||||
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
||||||
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
||||||
those labels were measured into. The guards are the library's
|
those labels were measured into. The guards are the library's
|
||||||
@@ -86,7 +87,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
|
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
|
||||||
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
|
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
|
||||||
than the requested window. It must write the per-row **member** axes as well as the link group:
|
than the requested window. It must write the per-row **member** axes as well as the link group:
|
||||||
`hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it
|
`hold_pinned_window()` prefers a member that differs from `pinned_stated_` (it scans **every**
|
||||||
|
row's member, in order, since a labelled strip on a row other than the first takes x gestures too), which is how it
|
||||||
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.
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
|
already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
|
||||||
button is the one on screen — **two arm buttons at once would be two controls for one state**, which
|
button is the one on screen — **two arm buttons at once would be two controls for one state**, which
|
||||||
is why the probe reads the body's allocation as `-1` while the compact one is up.
|
is why the probe reads the body's allocation as `-1` while the compact one is up.
|
||||||
|
**The same function states the Force button beside the arm one, and the Force button is in the body
|
||||||
|
only.** Its sensitivity is the same `enabled` (a trigger that is off has no engine to force) and it
|
||||||
|
is stated in `sync_arm_buttons()` for the same reason the pair above is stated there — one place, so
|
||||||
|
nothing can drift — but it is *not* armed-gated: in Single mode the previous acquisition leaves the
|
||||||
|
engine disarmed, which is exactly when a user reaches for Force again, so a button that greyed out
|
||||||
|
with the engine's armed state would be unavailable for the one thing it is for. It stays in the body
|
||||||
|
because the heading's single control is the runtime *state* a locked pane exists to change, while a
|
||||||
|
force is an action whose subject — the condition, the level, the pre/post split and the mode that
|
||||||
|
decides whether one acquisition is all of it — is in the body; a second Force in the heading would
|
||||||
|
be a second control for one action whose settings are off screen.
|
||||||
- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
|
- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
|
||||||
created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
|
created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
|
||||||
that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
|
that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
|
||||||
@@ -36,7 +46,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
has a `compact_arm_step()` of its own; and the control to compare against must be measured with
|
has a `compact_arm_step()` of its own; and the control to compare against must be measured with
|
||||||
`is_visible()` or not at all.
|
`is_visible()` or not at all.
|
||||||
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||||
signal nobody acquires can never fire, so selecting/arming enables it.
|
signal nobody acquires can never fire, so selecting/arming enables it. **And the signal is resolved
|
||||||
|
*through* the channel, never taken as one.** `TriggerConfig::channel` indexes `channels()` (the
|
||||||
|
acquired ones) while `ScopeModel::set_signal_enabled()` indexes the stream's `signals()`, and the
|
||||||
|
two are not the same index: with one signal acquired, channel 0 *is* that signal while signal 0 is
|
||||||
|
the stream's own clock. Passing one for the other switched on the time base, left the signal the
|
||||||
|
trigger watches exactly as it was — and, because a signal the panel was not showing *is* a change,
|
||||||
|
rebuilt the buffer under the click and dropped its history, which is precisely the pre-trigger
|
||||||
|
window an arming, and now a force, was about to capture. `trigger_signal_index()` is the one
|
||||||
|
resolution and both buttons go through it.
|
||||||
- **The trigger's event window is separate from the display window**, and
|
- **The trigger's event window is separate from the display window**, and
|
||||||
`pretrigger_pct` is a share of the *event* span, not of the ring.
|
`pretrigger_pct` is a share of the *event* span, not of the ring.
|
||||||
- **The event window is counted in stream seconds end to end; nothing converts it to
|
- **The event window is counted in stream seconds end to end; nothing converts it to
|
||||||
@@ -201,6 +219,42 @@ 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()`.
|
||||||
|
- **Force is a synthetic edge, and the latch is still the only publisher.** The trigger panel's
|
||||||
|
Force button opens one capture at the *present* moment through `TriggerEngine::force()`: the
|
||||||
|
newest stored stamp becomes the event's own time and the engine enters the same pending state an
|
||||||
|
edge leaves it in, so the ordinary latch cuts, publishes, counts and frames the acquisition. A
|
||||||
|
forced acquisition therefore obeys every rule a latched one does — the axis pinned from the
|
||||||
|
snapshot's own stamps, a `pre_s` shorter than the request when the ring does not reach back that
|
||||||
|
far, and the frame that frames it being the one that consumes its `event_seq()` — and the button
|
||||||
|
owns no window, no publish and no framing of its own: **the event it publishes is cut by the same
|
||||||
|
code as a latched one.** Two things it deliberately does *not* do, both of them choices:
|
||||||
|
- **It skips the pre-window gate.** `force()` does not test `event_t - oldest_s_ >= pre_seconds_`:
|
||||||
|
"capture now" is a request for the history that *exists*, and refusing it because the ring is
|
||||||
|
young would refuse the one thing the button is for. The snapshot reports its own `pre_s`, and the
|
||||||
|
pin follows it — which is also how the pin gets *read*: with a request the ring cannot satisfy (a
|
||||||
|
100 s window at 99 % pre) the axis must come out a few seconds wide, where a pin stated from the
|
||||||
|
request would come out a hundred.
|
||||||
|
- **It does not restart a capture already filling.** That capture *is* the acquisition being asked
|
||||||
|
for: the display is already showing it fill (`live(capturing)`), and restarting it would throw
|
||||||
|
away the post window it has collected and move the marker to the press. The click is refused, and
|
||||||
|
named on stderr, rather than silently reinterpreted.
|
||||||
|
What the trigger *is* afterwards is still the mode's decision, and it is made where it always was —
|
||||||
|
in the latch: Normal leaves the engine armed with the latch's hold-off, Single disarms it. One
|
||||||
|
acquisition, whether an edge produced it or the button.
|
||||||
|
- **The Force request is written under the model lock and consumed at the end of a packet.** It is
|
||||||
|
one atomic flag on `Acquisition` (`request_force()`), read with `exchange` after the channel loop
|
||||||
|
and before the `trigger_pending()`/`capture_edge()` publish — after the loop because the cut
|
||||||
|
`publish_event()` is about to make clamps to every value ring's `produced()`, so the newest sample
|
||||||
|
of every channel has to be in by then, and before the publish so the capture the click opened is
|
||||||
|
reported by the same packet that opened it. The *write* belongs under `ScopeModel::mutex()` and in
|
||||||
|
the same critical section as the `armed` flag the click also sets: the producer holds that lock for
|
||||||
|
a whole packet, reading the panel's `armed` at the top of one and the request at the bottom, so a
|
||||||
|
request written in between would be read by a packet whose engine was still disarmed and then
|
||||||
|
dropped — and dropped is right, because a momentary request must not be replayed later on a trigger
|
||||||
|
the user has stopped watching. With **no channel resolved** (no signal acquired) the click is
|
||||||
|
refused in the handler and not left to the producer: `on_data()` returns at once for a model with
|
||||||
|
no *streamed* channel, so a request parked behind that return would fire on the first signal
|
||||||
|
switched on afterwards.
|
||||||
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
|
- **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:
|
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
|
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
|
||||||
@@ -242,17 +296,168 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
moved under the stream; a frame accepted into a model with no streamed channel) and for why the
|
moved under the stream; a frame accepted into a model with no streamed channel) and for why the
|
||||||
relative clock is accumulated in seconds.
|
relative clock is accumulated in seconds.
|
||||||
|
|
||||||
|
- **Persistence is the older acquisitions drawn *over* the current one, and "a layer is not a
|
||||||
|
signal" is what keeps them from becoming one.** Trigger mode may keep the last `N`
|
||||||
|
acquisitions -- `TriggerConfig::persist_depth`, the panel's *persistence* spin, 1 = the event
|
||||||
|
on screen alone -- and draw the older ones dimmer, which is the whole feature. The machinery is
|
||||||
|
the layer machinery the accumulate path already has: one **layer** per (signal, age), in the
|
||||||
|
same trace slot the accumulate path puts its own extra events in, named with the same suffix
|
||||||
|
vocabulary (` (A)`, ` (B)`, ` #4`), so `layer_base_name()` gives the base signal back and the
|
||||||
|
signal list, the colour resolver, the measurements and the layout writer all keep seeing **one**
|
||||||
|
signal where the plot draws several traces of it. Four consequences, each of which is a rule:
|
||||||
|
* **A layer slot holds no channel.** `RowSet::bind()` resolves a layer's *base* signal (it must
|
||||||
|
still be acquired -- switching a signal off takes its traces off every plot, layers included)
|
||||||
|
and then finds the channel **by name inside the snapshot** (`EventSnapshot::channel_names`),
|
||||||
|
because the channel list is renumbered by any structural change since that acquisition was
|
||||||
|
cut -- the rule `TriggerEngine` already follows, for the same reason. It leaves
|
||||||
|
`Row::channel[]` at `-1`, which is what makes every reader that asks "is this slot a trace of
|
||||||
|
a signal" skip it: the trace counts, the cursors pane's `mix` mask, the measurement marks, the
|
||||||
|
dump's per-slot lines, `collect_layout()`. A persistence layer is therefore invisible to the
|
||||||
|
signal list, the calibration table and the measurements by construction and not by a check;
|
||||||
|
the *measurements* go on describing the current frame because they resolve a channel from the
|
||||||
|
signal name the user configured, and `sine (A)` is not a signal anybody can configure.
|
||||||
|
* **Its brightness is a property of the slot, and it is applied where the trace is created.**
|
||||||
|
`Row::slot_alpha` is `RowSet::persist_alpha(age)` -- **half per acquisition**, so the schedule
|
||||||
|
is 1, 0.5, 0.25, 0.125, ... and the seventh layer is 0.8 %, i.e. gone; it does *not* depend on
|
||||||
|
the depth, so raising N adds dimmer acquisitions instead of restating the ones already on
|
||||||
|
screen. `install()` mixes `slot_alpha * trace_alpha_` into the trace's colour, because a
|
||||||
|
`Plot` fixes a trace's colour **at `add_trace()`** and a brightness nobody can set later is a
|
||||||
|
brightness that has to be a rebuild. Two things follow: a per-layer alpha is impossible
|
||||||
|
without a per-slot one, and **`set_trace_alpha()` is finally read** -- it was set by the
|
||||||
|
accumulate path and read by nothing, so an accumulation's "translucent, so agreement is
|
||||||
|
brightness" was opaque in fact while the docs claimed otherwise.
|
||||||
|
* **The dimmest trace is created first.** A `Plot` draws its traces in the order they were
|
||||||
|
created and cannot reorder them, so `install()` creates them sorted by brightness and the
|
||||||
|
*newest* acquisition is the ink on top. A layer drawn over the event would take its colour out
|
||||||
|
of the event's own trace wherever the two agree -- the opposite of what a persistence display
|
||||||
|
shows.
|
||||||
|
* **Every slot the plan does not fill is freed, and the plan does not depend on the data.**
|
||||||
|
`RowSet::sync_layers()` (called from the tick, before the frame's `bind()`) re-plans one layer
|
||||||
|
per (signal, age) into the row's free slots, *layer by layer* -- age 1 for every signal first,
|
||||||
|
then age 2, as the accumulate path does -- so a row that runs out of room draws the sharper
|
||||||
|
layer for each of its signals and stops. The plan is a function of the rows, the depth and
|
||||||
|
nothing else, so a **latch costs no rebuild**: the slot names and ages stay put and only the
|
||||||
|
snapshot behind them changes.
|
||||||
|
- **An older acquisition is *drawn*, and it may not move anything.** The current event is the one
|
||||||
|
the axis is pinned to, and persistence must not disturb that, so `RowSet::event_data_window()`
|
||||||
|
-- the union the pin is re-stated from every frame while an event is held -- **skips layer
|
||||||
|
slots**: a wider older acquisition would otherwise re-pin the axis, and with it the window a
|
||||||
|
pinned measurement reads, which is a *measurement* moving because a display option was turned
|
||||||
|
up. The layers are bound only in `Held` and `Capturing` (a live view has no current event to be
|
||||||
|
the newest of them, a `Waiting` display is blank by design, and a pause view's stamps are on the
|
||||||
|
live window's clock, not on an edge): in `Waiting` and `Live` the layer slots are planned but
|
||||||
|
bound to nothing, and a trace with no series is not drawn and not listed in the legend. The one
|
||||||
|
thing they do share with the signal is the **row's y axis**, so the y auto-fit sees them -- a
|
||||||
|
y axis that ignored the traces it draws would clip the excursion the display exists to show.
|
||||||
|
- **A retained acquisition is a whole window copy, so the depth is capped by bytes and by what a
|
||||||
|
row can draw.** `publish_event()` moves the outgoing `event()` into the retention before it
|
||||||
|
publishes the new one: `ScopeModel::persist_event(age)` is the event `age` latches back, one
|
||||||
|
atomic `shared_ptr` slot per age, read by the tick without a lock exactly as `event()` is.
|
||||||
|
`snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples
|
||||||
|
and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of
|
||||||
|
clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and
|
||||||
|
`persist_budget_bytes()`, which is the planned ring bytes (`Partition::bytes()`), because persistence is a copy
|
||||||
|
*of* what the rings hold and holding as much again is the honest ceiling for a display of it.
|
||||||
|
`kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already
|
||||||
|
invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only
|
||||||
|
thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows
|
||||||
|
`floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition
|
||||||
|
and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller
|
||||||
|
that conflated them would report a shrunken display as complete: a *slot* shortfall is
|
||||||
|
`placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a
|
||||||
|
*budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in
|
||||||
|
that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say
|
||||||
|
nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A
|
||||||
|
older acquisitions retained` -- because a user who asked for eight layers and sees two has no other
|
||||||
|
way to tell "the display is deliberately shallower" from "the feature is broken".
|
||||||
|
- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at
|
||||||
|
all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once**
|
||||||
|
rather than at the next latch, because lowering the depth is a user asking to stop paying, and a
|
||||||
|
trigger that has stopped firing never reaches another publish. A depth change is therefore also
|
||||||
|
how the memory comes back.
|
||||||
|
|
||||||
|
- **The phosphor density is the *trace*, and that is a different display from the layers above, not a
|
||||||
|
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
|
||||||
|
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
|
||||||
|
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, an intensity grid
|
||||||
|
**per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades
|
||||||
|
either by a time constant or by a per-acquisition factor. There is **no depth** on the density and
|
||||||
|
no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, and both factors are
|
||||||
|
bounded -- the grid is the row's axes rect in device pixels **up to the plotter's
|
||||||
|
`kDensityCellCap`**, and there is one grid a trace the row draws. Four traces in a row
|
||||||
|
therefore hold at most 4 MiB between them, whoever dragged the window where (the probe's
|
||||||
|
one-signal-per-row run reads `cells=1044714`, 4 178 856 bytes), so a thousand acquisitions cost
|
||||||
|
what one costs and *neither* a wide window nor a long run can make the image grow. A cell's value
|
||||||
|
is a number of *sweeps*: the library
|
||||||
|
deposits a cell once per trace per sweep however many of that trace's own samples crossed it
|
||||||
|
(`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is
|
||||||
|
`Style::intensity_grading`'s question, not this one's. The colour is the trace's own
|
||||||
|
(`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a
|
||||||
|
saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two
|
||||||
|
quads of two colours rather than a blend.
|
||||||
|
Five things hold it together, each of which is a rule:
|
||||||
|
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
|
||||||
|
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
|
||||||
|
the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule
|
||||||
|
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
|
||||||
|
empty image rather than a stale one at another scale.
|
||||||
|
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
|
||||||
|
deposits what the plot currently holds through the transforms of its **last** layout, because the
|
||||||
|
frame's own layout is computed inside the paint that follows the tick: `density_pending` is set
|
||||||
|
where a snapshot is *framed* -- beside `record_pending` -- and consumed by the next tick, so the
|
||||||
|
deposit is of the new acquisition drawn at the axis that acquisition is pinned to. Doing it in the
|
||||||
|
same tick was right on the common Held-to-Held transition and wrong on the first latch, whose axis
|
||||||
|
was still the live ring's.
|
||||||
|
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
|
||||||
|
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
|
||||||
|
latch that `single` mode may never send.
|
||||||
|
* **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()`
|
||||||
|
returns at once while a density is on: the trace ink *is* the image, so the acquisition just
|
||||||
|
deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid,
|
||||||
|
unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition",
|
||||||
|
where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one:
|
||||||
|
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one
|
||||||
|
time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green),
|
||||||
|
which a stroke over the density could not do.
|
||||||
|
* **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The
|
||||||
|
app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at
|
||||||
|
1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing
|
||||||
|
the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than
|
||||||
|
a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)`
|
||||||
|
for the acquisition fade -- is the gain offset a user reported between the latest acquisition and
|
||||||
|
the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed
|
||||||
|
here.
|
||||||
|
* **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and
|
||||||
|
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
|
||||||
|
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
|
||||||
|
and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot
|
||||||
|
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
|
||||||
|
be picked, exactly as `set_invalid_spans()` does.
|
||||||
|
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
|
||||||
|
`trigger_density_factor` in the layout file, written whether or not it is on -- a display set up
|
||||||
|
once and switched off is still the display you come back to. The decay is the *word* the panel's
|
||||||
|
dropdown shows ("acquisitions"/"time"), for the reason the condition's shapes are text.
|
||||||
|
|
||||||
## A latch is also a file
|
## 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
|
The cache is listed twice, from one `EventStore::list()`: the dock's compact **Events block** and the full list on the
|
||||||
collapsed until it is asked for, because a column of stale events beside a live plot is what a user
|
`events` slide-over page (the left pane, its divider and the header toggle are gone, so events no longer take
|
||||||
who has stopped looking at events does not want. What makes it *available* -- (recording is on) or
|
any of the plot's width). What makes it *available* -- recording is on, **or** a replay is on screen, **or** the
|
||||||
(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and
|
cache already holds something (`event_recording || replaying || events_cached`) -- is decided in one place,
|
||||||
the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and*
|
`sync_events_availability()`, and it sets the block's sensitivity (`dock_events_set_sensitive()`) and the page's
|
||||||
collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is
|
(`events_box`) from the one `available`, so they cannot disagree. Unavailable means **insensitive, never hidden**
|
||||||
deliberately **not** one of the conditions: a stored event being examined is a file, which has no
|
("disable, do not hide"): the block stays on screen greyed, and turning recording off with the page open leaves it up
|
||||||
trigger, and tying the pane to the trigger would close the list under the click that opened an event.
|
and greyed rather than stale and live.
|
||||||
The recording switch lives in the trigger panel, not in the pane it empties.
|
|
||||||
|
**This deliberately differs from the redesign spec**, which has the block insensitive "outside trigger mode". Trigger
|
||||||
|
mode is *not* one of the conditions: a stored event being examined is a file, which has no trigger, and a replay or a
|
||||||
|
group *leaves* trigger mode, so a block that went grey outside trigger mode would grey itself under the very click
|
||||||
|
that started the replay -- the one-click replay in the dock would disable the dock it was clicked in. Do not "fix" this
|
||||||
|
to match the spec. The recording switch lives in the trigger panel, not in the block it empties.
|
||||||
|
|
||||||
|
The two views follow the latch on different cadences: the dock is rebuilt on **every** latch (`record_event()`), the
|
||||||
|
page's list only while the page is open, and `open_events_page()` refreshes it when it was closed; a caller that
|
||||||
|
opens the page any other way shows a list that is as stale as the last time it was open.
|
||||||
|
|
||||||
## Opening a file is a detour, so *Go live* is a restore
|
## Opening a file is a detour, so *Go live* is a restore
|
||||||
|
|
||||||
|
|||||||
@@ -13,29 +13,49 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
|||||||
./build/udpscope --server 127.0.0.1 --port 8000
|
./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Two baselines every run is read against. A clean build emits **three** warnings and no more: two
|
Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**:
|
||||||
`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons'
|
a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it.
|
||||||
generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with
|
For the badge bar / slide-over / dock redesign the same diff is taken **against the commit before that
|
||||||
the seven action icons and the two app icons that remain (a note about a generated file, which GCC
|
series (`c572ec2`)**, from a fresh `XDG_CONFIG_HOME` on each side (a stored mock session changes the
|
||||||
itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is
|
trigger lines), and it is "unchanged except" exactly these lines: `panels:` loses `switcher=` (the
|
||||||
now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that
|
trigger form is read through the slide-over, so its width and height change, and the `compact arm
|
||||||
bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by
|
probe` width with them); `switcher:` becomes `dock: shown= sensitive= w= scroller_pages=`;
|
||||||
touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers`
|
`panel controls:` prints `dock=` for `switcher=`; `help figures:` loses `side_switcher=`, replaces
|
||||||
or any other warning is a finding. (The fifth warning that used to appear here was an unused
|
`side_stack=` with `badge_bar=` and `dock=`, moves the `trigger_*` rectangles into the slide-over and
|
||||||
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
|
shortens `plot=` by the 36 px bar; and the `tour started: steps=` count. A self-check taken **before**
|
||||||
`output_after_add` already printed on the same line — which dropped the baseline from five to
|
`c572ec2` is no longer the baseline for those lines. The rest is the mock's own jitter (the sign of a
|
||||||
four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's
|
`square y=[...]` or a `trig engine:` figure, the position of one `trigger: edge` line), which the
|
||||||
|
normalisation does not remove -- anything else is a finding.
|
||||||
|
For the horizontal plot split the same diff is taken against the commit before it (`18b91c8`, with
|
||||||
|
`deps/plotter` at its pre-split pin `f79a97b`), and it is "unchanged except" exactly two things on the
|
||||||
|
layout lines: **`buttons=`** grows (the editor now carries the cell `+` buttons and the column arrows:
|
||||||
|
`3P + 2F + 2` for `P` plots in `F` figure rows, so `5 -> 7` for one row and `8 -> 12` for two), and the
|
||||||
|
layout probe line and the exit dump's layout line gain the **`cells=[..] colweights=[..]`** fields.
|
||||||
|
Everything else -- cursors, measurements, readouts, signal pages, math, trigger, help figures, the
|
||||||
|
E-1608 -- reads as before; a difference there is a finding, not the feature. Take a second baseline run
|
||||||
|
as a jitter control before believing one (a few mock readings, such as the sine's membership of the
|
||||||
|
cursors mix list or a V/mV flip, move between two runs of the *same* build).
|
||||||
|
A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons'
|
||||||
|
generated GResource `.c`, whose blob is **10146** against C99's 4095-byte string-literal limit with
|
||||||
|
the six action icons and the two app icons that remain (a note about a generated file, which GCC
|
||||||
|
itself supports). History: the help pages' generated `.c` (**516044** bytes) went when the manual
|
||||||
|
became a Mallard set installed for Yelp; an unused `output_built` in `math_probe()` was deleted as
|
||||||
|
dead code; and the last two, `-Wmissing-field-initializers` on `MathNode::source` in `math_probe()`,
|
||||||
|
went when the probe's `MathNode{MathOp::Gain, {}}` / `MathNode{MathOp::LowPass, {}}` named the
|
||||||
|
field (a partial brace-initialiser on `MathNode` re-adds one: `source` has no default member
|
||||||
|
initialiser). Re-measure by touching `src/icons/udpscope.gresource.xml` and rebuilding; any
|
||||||
|
second warning is a finding. And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's
|
||||||
`gtk warnings:` line counts GTK's own log (through a writer function — GTK is built with structured
|
`gtk warnings:` line counts GTK's own log (through a writer function — GTK is built with structured
|
||||||
logging, so the handler API never sees its messages) and must read `0`; see
|
logging, so the handler API never sees its messages) and must read `0`; see
|
||||||
[probe-readings.md](probe-readings.md).
|
[probe-readings.md](probe-readings.md).
|
||||||
|
|
||||||
An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled
|
An *incremental* build reads **zero**: the generated GResource `.c` is not recompiled
|
||||||
when only `src/` changed, so its `-Woverlength-strings` does not reappear.
|
when only `src/` changed, so its `-Woverlength-strings` does not reappear.
|
||||||
|
|
||||||
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
|
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
|
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
|
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;
|
planned buffer. A normal run prints two or three. Fifteen at startup is the defect it was added for;
|
||||||
see [probe-readings.md](probe-readings.md).
|
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`,
|
||||||
@@ -57,6 +77,115 @@ 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_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
|
||||||
|
moment, with the pre/post window the panel is set to, without waiting for the condition. It is a
|
||||||
|
chain, not an instant — `MS` is when it *starts*: it forces trigger mode **off** through the toggle
|
||||||
|
(a session file remembers the trigger, so the off state is stated rather than assumed) and reads the
|
||||||
|
button's stated state 300 ms later; it then sets a level no mock signal reaches (9 V), a 2 s window
|
||||||
|
at 50 % pre, acquires the mock's `sine` and turns the trigger on; it presses Force 2.5 s after that
|
||||||
|
(the window write re-plans the buffer and clears the history, so the ring needs the time to fill
|
||||||
|
past the 1 s leg A asks for); it presses a second time on a 100 s window at 99 % pre — a request no
|
||||||
|
run this short can satisfy — 400 ms after the first latch. **Run it alone, against the plain mock**,
|
||||||
|
with time for both legs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 &
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_FORCE_MS=3000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | \
|
||||||
|
grep -E 'force-probe|trigger:'
|
||||||
|
```
|
||||||
|
|
||||||
|
Being alone is load-bearing twice: the level is chosen so the engine *cannot* latch on its own, so
|
||||||
|
a latch count that moves is the force's and nothing else's — any other probe that arms a reachable
|
||||||
|
condition adds latches of its own — and `UDPSCOPE_ENABLE_ALL_MS` before it would acquire four
|
||||||
|
signals, which changes the level's default range and the plan. Two lines per pass are in
|
||||||
|
[probe-readings.md](probe-readings.md); **`latches=P->Q` with `Q == P + 1`, `display=event(frozen)`,
|
||||||
|
`matches_request=1` in leg A, `pin_is_snapshot=1` in both** are the assertions, and leg B's
|
||||||
|
`matches_request=0` is *expected* — a ring that cannot reach back 99 s is the only state where
|
||||||
|
"pinned to the snapshot, not to the request" is a reading rather than a coincidence.
|
||||||
|
- `UDPSCOPE_PROBE_PERSIST_MS` (+ `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, 1..8) — the **persistence
|
||||||
|
display**: trigger mode keeps the last N acquisitions on screen, each older one drawn at half the
|
||||||
|
previous one's brightness. It enables the signals itself (one call, as a restored session does) and
|
||||||
|
arranges one signal per row — the arrangement the app gives an opened file, and the one with room
|
||||||
|
for every age — sets the depth **through the panel's own spin**, arms the trigger on the mock's
|
||||||
|
`square` and polls until the retention is full: an event on screen and as many older ones kept as
|
||||||
|
the depth asks for. The depth is *left* where it put it, so the run's own tail is the display at
|
||||||
|
that depth:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 9541 --rate 500000 --batch 1000 &
|
||||||
|
PY=$!; sleep 0.5
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 \
|
||||||
|
UDPSCOPE_PROBE_PERSIST_MS=2500 UDPSCOPE_PROBE_PERSIST_DEPTH=4 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 9541 2>&1 | grep -E 'persist-probe|persistence:|layout:'
|
||||||
|
kill $PY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not with `UDPSCOPE_ENABLE_ALL_MS`**: that probe ends by switching a signal off two seconds later,
|
||||||
|
which takes the row that plotted it — and its layers with it — out of the middle of the measurement.
|
||||||
|
|
||||||
|
**Read it as a pair of runs**: the same command at `..._PERSIST_DEPTH=1` is the display *without*
|
||||||
|
the feature, so its `kept=0`, `layers=0`, `bytes=0` and its `layout:` line holding only the signals
|
||||||
|
is the "N = 1 is exactly the old behaviour" reading, and it is also the run the exit dump's
|
||||||
|
`frame cost:` line is compared against — the tick/draw means are means over the whole run, and the
|
||||||
|
depth is raised at `MS`, so the difference is a *lower bound* on what the layers cost. **`ev_is_snap`
|
||||||
|
and `pin_is_snap` must be 1 in both runs**: the older acquisitions are drawn and never pinned, so
|
||||||
|
the axis, the framing and the measurements still describe the current event. `alphas=` is the decay
|
||||||
|
schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump
|
||||||
|
lines are in [probe-readings.md](probe-readings.md).
|
||||||
|
- `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor
|
||||||
|
density**: an intensity image **per trace**, in that trace's own colour, the axes rect in device
|
||||||
|
pixels up to the plotter's `kDensityCellCap` (past which it scales the rect down uniformly and
|
||||||
|
magnifies the image back over it with a linear filter), every
|
||||||
|
triggered acquisition accumulated into it (the newest included), drawn *in place of* the traces,
|
||||||
|
fading by a per-acquisition factor or by a time constant. It states the display
|
||||||
|
**off** through the panel's own switch first, so the `off` leg is the run without it, then arms the
|
||||||
|
trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses
|
||||||
|
— and only then switches it on, so the acquisition already on screen is the first image. Four legs,
|
||||||
|
each a separate `g_timeout`, so whole frames happen between them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 9551 --rate 500000 --batch 1000 &
|
||||||
|
PY=$!; sleep 0.5
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 \
|
||||||
|
UDPSCOPE_PROBE_DENSITY_MS=2500 ./build/udpscope --server 127.0.0.1 --port 9551 2>&1 | \
|
||||||
|
grep -E 'density-probe|density:'
|
||||||
|
kill $PY
|
||||||
|
```
|
||||||
|
|
||||||
|
**The assertions, in order:** `off` reads `rows=0 traces=0 cells=0 ink=0.0 peak=0.0000` (there is no
|
||||||
|
grid at all); `on` reads `rows=4` — every row has its own image, one per trace in its own colour,
|
||||||
|
so `traces=4` with one signal a row — and **`peak=1.0000`**, one sweep's ink
|
||||||
|
being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's
|
||||||
|
0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads
|
||||||
|
**`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in
|
||||||
|
the tens for the `on` leg is the *per-hit* deposit -- one sweep counted once per sample that
|
||||||
|
crossed a cell -- which saturates a single acquisition and hides the sweeps behind it. The line
|
||||||
|
also carries the app's own A/B of the frame cost (`tick`/`draw` before and after the switch, in
|
||||||
|
one run), and the exit dump's `density:` line says what the last frame it was on held. Both lines
|
||||||
|
and the dump line are in [probe-readings.md](probe-readings.md).
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_NOTRACE` (no value; **not** a scheduled probe, and off unless it is set) — the
|
||||||
|
per-paint detail behind the exit dump's `traceless paints:` counter: one `[notrace]` line for each
|
||||||
|
of the first 40 paints after the first bind that staged no trace, naming the mode, the axis the
|
||||||
|
frame was asked for and every slot's bounds. It is behind a variable because a state that draws
|
||||||
|
nothing lasts as long as it lasts, so an ungated line is one per frame for the whole time -- and it
|
||||||
|
is *not* printed for a frame a phosphor density is drawing, which stages no trace by construction
|
||||||
|
(the frame's ink is the image) and would otherwise be counted as well. The counter itself is in
|
||||||
|
every build; see [probe-readings.md](probe-readings.md).
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_HEADER` (no value; **not** a scheduled probe) — the once-a-second
|
||||||
|
`[udpscope] header: window_button_w=… bar_w=…` line. The widths are sampled either way (the
|
||||||
|
exit dump reads them); the line is behind this variable because it is detail behind that
|
||||||
|
reading, not a line every run should print. An unallocated window reads `0` for both.
|
||||||
|
- `UDPSCOPE_PROBE_RENDERER` (no value; **not** a scheduled probe) — the renderer pair
|
||||||
|
`--renderer` should have exported, printed from `main()` before GTK initialises
|
||||||
|
(`[renderer] option=… GSK_RENDERER=… LIBGL_ALWAYS_SOFTWARE=…`), then the renderer GTK
|
||||||
|
built (`[renderer] realized=…`) once the window has one. Run it with `--renderer cairo` to
|
||||||
|
check the option reaches the environment before `gtk_init()`/`adw_init()`; run it without
|
||||||
|
the option to check the default is untouched.
|
||||||
|
|
||||||
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
|
- `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,
|
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
|
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
|
||||||
@@ -76,6 +205,35 @@ precedent, not an exception.
|
|||||||
with, so a synchronous sequence would read every position one action stale. It clicks by
|
with, so a synchronous sequence would read every position one action stale. It clicks by
|
||||||
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
|
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
|
||||||
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
|
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
|
||||||
|
- `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 28 steps 250 ms apart (so a run needs
|
||||||
|
`UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=12000` at least; it enables and assigns the
|
||||||
|
signals itself, so `UDPSCOPE_ENABLE_ALL_MS` is not needed). Each step restructures the cells,
|
||||||
|
re-points the area at the new figure and lets the *next* step read a frame that was actually laid out
|
||||||
|
with them, which is why every report line describes the previous step's change. It splits and
|
||||||
|
weights a row, fills the caps, round-trips a session through `collect_layout()` / `save_layout()` /
|
||||||
|
`load_layout()` / `apply_layout()` (and a copy with its `row`/`width` lines stripped), clicks the layout
|
||||||
|
editor's real cell `+` button, drives the column drag handler, and checks the signals page, the drop's
|
||||||
|
`row_at()`, the cursors pane's ruler labels and the shared y axis of a split row (`shared-y`). Run it once, serially, on its own mock port, from a
|
||||||
|
fresh `XDG_CONFIG_HOME` (the probe deletes the temp files `udpscope-split-probe.toml` and `.legacy` it
|
||||||
|
writes under `g_get_tmp_dir()`, but the editor's clicks schedule a real session save). Read it against
|
||||||
|
the `[split-probe]` section of [probe-readings.md](probe-readings.md); the run must still end with
|
||||||
|
`gtk warnings: 0`.
|
||||||
|
- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level
|
||||||
|
walk(s)` (stderr, `UDPSCOPE_PROBE_LEVELS_MS`) — **prices a measurement pane**, which is what a
|
||||||
|
report of "it is slow with many measurements" has to become before it is a number. It adds one
|
||||||
|
`above` per share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`), spread in turn
|
||||||
|
over the first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and reports the two
|
||||||
|
things a count does not name: how many **windows** (one `MeasureWindowReader`, one fetch, one
|
||||||
|
scan per measured channel) and how many **level walks** (one accumulator per *distinct* share)
|
||||||
|
the pass then had to take. `50,50,50,50` on one channel is four pane rows and **one** walk;
|
||||||
|
`5,10,15,20` is four rows and **four**; the same four shares over four channels is four
|
||||||
|
windows *and* four walks. Read the `measure cost:` line it leaves in the exit dump and the
|
||||||
|
sweep in [invariants-measurements.md](invariants-measurements.md) — and read it against a run
|
||||||
|
with `UDPSCOPE_PROBE_LEVELS_MS` unset, because the pane's cost is a *difference* between two
|
||||||
|
tick means. **Run it at the scale being asked about**: the cost is the window's, so a 2 s
|
||||||
|
window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
|
||||||
|
recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
|
||||||
|
the same window).
|
||||||
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
|
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
|
||||||
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
|
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
|
||||||
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
|
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
|
||||||
@@ -206,7 +364,7 @@ precedent, not an exception.
|
|||||||
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
|
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
|
||||||
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
|
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
|
||||||
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
|
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
|
||||||
a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
|
a ring that holds *more* than the window (`--buffer-samples 2M` past the window's own seconds) —
|
||||||
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
|
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
|
||||||
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
|
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
|
||||||
|
|
||||||
@@ -287,12 +445,139 @@ precedent, not an exception.
|
|||||||
pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three
|
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
|
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_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first.
|
||||||
- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements on the
|
- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements while the
|
||||||
Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4`
|
`measure` page is **closed**: it adds four measurements, opens the page for a forced pass (the baseline),
|
||||||
is the assertion** (every accumulator gained passes while its page was off screen); `advanced=0` is the
|
closes it, and reads the accumulators again two seconds later. **`advanced=<rows>` is the assertion**
|
||||||
defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for
|
(every accumulator gained passes while the page was off screen); `advanced=0` is the defect where the
|
||||||
exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other
|
accumulation is gated on what is visible, which would leave a gap in min/max/avg for exactly as long as the
|
||||||
probe ahead of it. The reading's own entry is in `probe-readings.md`.
|
page was shut. It enables *and plots* its own signal, so it needs no other probe ahead of it, and it
|
||||||
|
re-arms once after 400 ms (a buffer rebuild between baseline and report reads as a gated pass). It needs a
|
||||||
|
config that has seen the first-run tour, which would close the page it opens (see the next-but-one bullet).
|
||||||
|
The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the variable and the
|
||||||
|
`[side-tab-probe]` tag keep their old names so existing scripts keep working. The measurements used to be a
|
||||||
|
dock section the probe scrolled out of view, with padding; the dock now holds the readouts and the Events block, so there is
|
||||||
|
no scroll and no padding.)
|
||||||
|
- `UDPSCOPE_PROBE_CURSORS_MS` and `UDPSCOPE_PROBE_MEASURES_MS` also print the dock's copy of what they read:
|
||||||
|
a `dock cursors:` line after every `[cursors-probe]` report and a `dock measures:` line at the
|
||||||
|
measures probe's added / settled / final stops, each with a `strip:` line beside it (the readouts strip,
|
||||||
|
shown when `(!dock_shown || collapsed)` and the cursors are on or there is a measurement; `expected=` is that, `visible_ok=1` that the strip agrees, `groups=`/`groups_ok=1` the same for its Cursors / Measures groups and separator; the measures probe's `start` line, before it adds anything, is the hidden "neither" state -- run it from a fresh config); each probe
|
||||||
|
ends by hiding the dock for 0.6 s and reading the strip (`dock hidden`, `dock restored`: under xvfb the
|
||||||
|
window is collapsed, so `visible=1 expected=1 h=26 same_text=1` on both, given something to read). **`same_text=1` on every one** is the assertion that the
|
||||||
|
dock, the strip and the page show one string; `fits=1` that the Readouts box is no wider than the dock. Entries in
|
||||||
|
`probe-readings.md`. The exit chain also prints one `[readouts]` line (pages registered, the dock page's three
|
||||||
|
children, the dock's width, the pages' sensitivity, whether the stream has signals) on every exit-dump run.
|
||||||
|
- `UDPSCOPE_PROBE_DOCK_SESSION_MS=2500` with `UDPSCOPE_EXIT_AFTER_MS=8000` -- the session file's `dock` key,
|
||||||
|
end to end: the dock hidden, saved and reloaded; shown again; and a file from the previous version
|
||||||
|
(`side_tab = "measure"`, no `dock`) loaded without an error. Three `[dock-session]` lines, each `ok=1`;
|
||||||
|
entries in `probe-readings.md`.
|
||||||
|
- `UDPSCOPE_PROBE_SLIDEOVER_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` (and `UDPSCOPE_ENABLE_ALL_MS=500`) -- the
|
||||||
|
slide-over panel over the plot's right edge, opened on a throw-away `probe` page, read 0.5 s later. Two
|
||||||
|
`[slideover]` lines. Line 1 must read `panel=360x<overlay height> ... edge_flush=1 full_height=1
|
||||||
|
outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`: a 360 px opaque panel flush to the overlay's
|
||||||
|
right edge and as tall as it, a pick outside it landing in the plot and one inside it in the panel, and the
|
||||||
|
plot's width unchanged by the panel being open (`plot_w before=` equal to `open=`).
|
||||||
|
`outside_hits_plot=0` means the panel has an allocation wider than 360 or a transparent parent that
|
||||||
|
swallows the plot's pan, drop and hover; `plot_unchanged=0` means the panel resized the plot. Line 2 must
|
||||||
|
read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot
|
||||||
|
add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo`
|
||||||
|
and `import -window root`): the panel must be drawn over the traces, opaque.
|
||||||
|
- **Slide-over and page probes (`SLIDEOVER`, `BADGES`, `SIGNAL_PAGES`, `SIGNAL_BADGES`, `SIDE_TAB`, `KEYS`) need a config that has already seen
|
||||||
|
the first-run tour**: a fresh `XDG_CONFIG_HOME` auto-starts the tour at about 1.5 s, and a tour stop with no page closes the
|
||||||
|
slide-over under the probe. Seed it with `mkdir -p $D/udpscope && printf 'tour_seen = true\n' > $D/udpscope/settings.toml`
|
||||||
|
(or one short throw-away run, which writes the key when the tour starts) and run with `XDG_CONFIG_HOME=$D`. Delete the
|
||||||
|
per-source subdirectory (`$D/udpscope/<host>_<port>`) between runs that must start from a first-run layout.
|
||||||
|
- `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the
|
||||||
|
per-signal `signal:<name>` slide-over pages. **Against a config that has already seen the tour** (a first run's
|
||||||
|
auto-tour closes the slide-over at about 1.5 s, which is when this probe opens its page). One `[signalpage]` line;
|
||||||
|
see [probe-readings.md](probe-readings.md).
|
||||||
|
- `UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the
|
||||||
|
signal badges in the badge bar. **Against a config that has already seen the tour**, as above (the probe clicks
|
||||||
|
the badges, and the auto-tour would close the page). One `[sigbadges]` line; see
|
||||||
|
[probe-readings.md](probe-readings.md).
|
||||||
|
- `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge
|
||||||
|
bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1
|
||||||
|
closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe
|
||||||
|
gets wrong; `fixed=1` is the bar's height being the same (and at least 36 px) before and after the
|
||||||
|
`trigger` page was opened and closed -- `0` means the bar's height depends on content and the plot above it
|
||||||
|
moves; `open_marked`/`closed_unmarked` are the `udpscope-badge-open` class following the slide-over's page.
|
||||||
|
`trigger_state=` is the state word as read then (`off` with the trigger toggle off). **The trigger form is
|
||||||
|
a slide-over page**, so a probe that reads one of its widgets' *allocation* (a width, a height) must
|
||||||
|
`slideover_open(*a, "trigger")` and read after the 120 ms reveal, as `exit_probe_step1` and `force_probe` do;
|
||||||
|
a sensitivity, a visibility or a value needs no open page.
|
||||||
|
**The source kind's panels are slide-over pages too** (`source-<id>`: the E-1608's `source-ao` and
|
||||||
|
`source-dio`, Flat UDP's, the probe kind's), so the same rule holds for them. The probe appends a second
|
||||||
|
`[badges]` line for the Source badge: `[badges] source name=<kind> pages=<n>
|
||||||
|
visible=<0|1> switch_visible=<0|1> can_target=<0|1> second_page=<id|-> second_ok=<0|1> open_marked=<0|1|-1>
|
||||||
|
closed_unmarked=1`. `pages` is the number of source pages, `switch_visible` is the segmented switch
|
||||||
|
being on screen after the first source page was opened (`1` for the E-1608's two pages, `0` for one page
|
||||||
|
or none), `second_page` is what `slideover_page()` read after the switch selected the second page
|
||||||
|
(`second_ok=1` when it is that page), `visible=0` with `pages=0` is the UDPS badge being **hidden** (a kind with no source page
|
||||||
|
has nothing for it to open; `can_target=0` goes with it) and `open_marked=-1` is that no-page case (there is
|
||||||
|
nothing to open); `visible=1` with `pages>0`.
|
||||||
|
- `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500`
|
||||||
|
-- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a
|
||||||
|
synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms.
|
||||||
|
Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`); the
|
||||||
|
reading's own entry is in `probe-readings.md`.
|
||||||
|
- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against
|
||||||
|
`tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a
|
||||||
|
digital-output toggle and a counter reset each reaching the mock, the stream's channels and rate, the
|
||||||
|
factory calibration applied, the capture button disabled, and a hand-computed decode. It enables the
|
||||||
|
signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS` (that probe switches the first signal off
|
||||||
|
after two seconds). Three runs, each short and serial, with the mock killed afterwards:
|
||||||
|
- **no board:** panels `sensitive=0`, `connected=0`, no retry flood on stderr (the one
|
||||||
|
`[e1608] handshake with ...: Connection refused` line is printed once, not per retry);
|
||||||
|
- **the mock:** `--drop-after 3 --log /tmp/e1608-mock.log`, options `scan_rate=1000 ai0=BIP10V
|
||||||
|
ai1=BIP5V plot0=1 plot_counter=1 dir1=o ao0_init=1.5`, with `UDPSCOPE_PROBE_E1608=1500
|
||||||
|
UDPSCOPE_PROBE_E1608_RECONNECT=8000 UDPSCOPE_EXIT_AFTER_MS=10500`; then read the mock's log: the AO
|
||||||
|
levels `1.5, 2.5, 1.5` (the log prints the quantised voltage, so `1.4999, 2.4999, 1.4999` is the
|
||||||
|
expected text), the DOUT bytes `0x00, 0x02, 0x00`, one `COUNTER_RESET`, one `DROP`;
|
||||||
|
- **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop.
|
||||||
|
- `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt
|
||||||
|
address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against
|
||||||
|
`tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine
|
||||||
|
on DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on
|
||||||
|
its frame clock, which does not tick for a page that is off screen; the page is closed again at the end),
|
||||||
|
waits 2.5 s, reads the achieved rate and the panel's "Update rate" row, stops the generator and reads both
|
||||||
|
again 400 ms later. Two lines, in `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with
|
||||||
|
`gen_rate=100` (the generator ran for about 2.5 s) and about 125 for a run without the option (default
|
||||||
|
50 Hz). Short and serial, the mock killed afterwards. It enables nothing else, so it can run alone.
|
||||||
|
- `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms
|
||||||
|
later checks the frame count moved on. The reading's own entry is in `probe-readings.md`.
|
||||||
|
- `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt
|
||||||
|
address=127.0.0.1`. Start `tools/mock_e1608.py` first on its default port 54211 (the form scans that port; do
|
||||||
|
not pass `--port`) and kill it afterwards. The value is the delay of phase 1; phase 2 follows 2 s after it and
|
||||||
|
phase 3 (the line) 2 s after that, so phase 3 runs 4 s after phase 1 and the delay must stay below about
|
||||||
|
2500 ms for the line to print before the exit at 6500 ms. It builds
|
||||||
|
the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address
|
||||||
|
filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks
|
||||||
|
the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the
|
||||||
|
mock's `00:80:2f:00:16:08`. One `[e1608] scan:` line. It needs a non-loopback up interface with a broadcast
|
||||||
|
address; when the board did not answer a second line says so, and the run proves nothing about the scan.
|
||||||
|
- `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against
|
||||||
|
`tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both
|
||||||
|
need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the
|
||||||
|
float64/uint16/int32/uint32/int64 types in both byte orders), the stream's channel names (`Wave[0]`..
|
||||||
|
for a `channels` vector) and declared rates (`rate * count` for a waveform), the channel count, value
|
||||||
|
continuity per row (and the offset between a vector's elements, or a waveform's block phase), the
|
||||||
|
wrong-size accounting and the capture button. It enables the signals itself, so do **not** add
|
||||||
|
`UDPSCOPE_ENABLE_ALL_MS`. Runs, each short and serial, with the mock killed afterwards (quote the `signals`
|
||||||
|
value: zsh globs the `[4]`):
|
||||||
|
- **mixed list, gap and bad datagrams:** mock `--rate 1000 --signals 'Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]'
|
||||||
|
--gap 8,4 --bad-every 1` started 1 s before the app, the same `signals` as a `--source-opt`, with
|
||||||
|
`UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=1500 UDPSCOPE_EXIT_AFTER_MS=14500`; stderr carries
|
||||||
|
exactly one `datagram of 22 bytes, expected 21` line. The silence probe is a 100 ms poll that waits for the
|
||||||
|
stream, then for the 2 s of silence to take the source down, then for it to come back, so it does not
|
||||||
|
depend on when `xvfb-run` finished starting; the only requirement is that the mock's gap begins after
|
||||||
|
the app connected (the mock's clock starts about 4 s before the app's) and ends in time to read the return
|
||||||
|
(`--gap 8,4` with a 14.5 s run does both with several seconds of margin);
|
||||||
|
- **waveform-only list:** mock `--signals 'Wave:int16[4]w'`, app `--source-opt 'signals=Wave:int16[4]w'`,
|
||||||
|
`UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`: one channel at `rate=4000`;
|
||||||
|
- **big endian (decode of every width through the stream):** mock `--big-endian` on the mixed list, app
|
||||||
|
`--source-opt endian=big`, `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`;
|
||||||
|
- **no sender:** `connected=0` throughout, a reading and not a failure.
|
||||||
|
- `UDPSCOPE_PROBE_FLATUDP_GAP=1500` -- starts the silence poll described above (2 s after the source's last
|
||||||
|
datagram it must be disconnected; then it must reconnect and the frame count move on).
|
||||||
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
|
- `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
|
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=`.
|
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
|
||||||
@@ -366,7 +651,7 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
`Gain` graph over the mock's sine, waits for the ring to **saturate**, and only then edits the gain,
|
`Gain` graph over the mock's sine, waits for the ring to **saturate**, and only then edits the gain,
|
||||||
which is the one state `UDPSCOPE_PROBE_MATH_MS` cannot reach (each of its edits follows a structural
|
which is the one state `UDPSCOPE_PROBE_MATH_MS` cannot reach (each of its edits follows a structural
|
||||||
change, and a replan restarts every ring at absolute 0). It therefore needs a **small buffer and a long
|
change, and a replan restarts every ring at absolute 0). It therefore needs a **small buffer and a long
|
||||||
run**: `--buffer-mb 1` against `--rate 20000 --batch 200`, with
|
run**: `--buffer-samples 65536` against `--rate 20000 --batch 200`, with
|
||||||
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`. `base > 0` on its second line is the
|
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`. `base > 0` on its second line is the
|
||||||
precondition — **`base=0` means the run tested nothing** — and the third line's `produced == slots` and
|
precondition — **`base=0` means the run tested nothing** — and the third line's `produced == slots` and
|
||||||
`y=±0.5` are the two assertions; both are in [probe-readings.md](probe-readings.md).
|
`y=±0.5` are the two assertions; both are in [probe-readings.md](probe-readings.md).
|
||||||
@@ -374,7 +659,7 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
note, through `save_configuration_as()` (the same entry point the dialog's button uses), then
|
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
|
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 —
|
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
|
`comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0`. A `buffer_samples=0` there
|
||||||
is the per-source buffer size not being written, which is the one setting a user notices only
|
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
|
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=…`).
|
deletes its own configuration at the end (`after forget names=…`).
|
||||||
@@ -412,12 +697,14 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
bullet) — so a probe run against a fresh config directory will have it on screen for the first
|
bullet) — so a probe run against a fresh config directory will have it on screen for the first
|
||||||
second and a half, and that start *writes* `tour_seen` to whichever config directory the run is
|
second and a half, and that start *writes* `tour_seen` to whichever config directory the run is
|
||||||
using, which is why the second run against the same one reads `auto=0`.
|
using, which is why the second run against the same one reads `auto=0`.
|
||||||
- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**,
|
- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it opens a slide-over page (`trigger`),
|
||||||
starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's
|
starts the window's tour from that state and walks all six stops a stop per tick, reading the
|
||||||
page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the
|
slide-over's open page and the spotlight/card at each. The trigger stop must read `slideover=trigger` and
|
||||||
tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch
|
the next `slideover=none`; the first stop closes the panel the probe left open, and the end of the walk
|
||||||
could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and
|
(and a dismissal on the trigger stop) leave none. It then puts the dock *away* and starts the tour again:
|
||||||
`[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its
|
the dock's stops are dropped while the trigger stop, which does not depend on the dock, stays.
|
||||||
|
`[toursurface] stop=3/6 … slideover=trigger … contains=1` and
|
||||||
|
`[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1` are the assertions; its
|
||||||
lines are in [probe-readings.md](probe-readings.md). Run it alone with
|
lines are in [probe-readings.md](probe-readings.md). Run it alone with
|
||||||
`UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source.
|
`UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source.
|
||||||
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
||||||
@@ -428,9 +715,9 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
`[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored
|
`[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.
|
calibration**, and the two halves must match.
|
||||||
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
|
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
|
||||||
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
|
entry per configuration (`host:port/name … samples= relative= note='…'`), and
|
||||||
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
|
`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
|
`[sources] picked host:port … samples=2097152 relative=0 capture=0 config=… note='…'` — the settings the
|
||||||
configuration carried, into the `Config` the model is built from. **With no `--server` and no probe
|
configuration carried, into the `Config` the model is built from. **With no `--server` and no probe
|
||||||
the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is
|
the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is
|
||||||
always this probe. It also prints the dialog's frame *and its own help layer*:
|
always this probe. It also prints the dialog's frame *and its own help layer*:
|
||||||
@@ -459,41 +746,50 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
leg of it (`title='Opening <basename>' detail='reading the recording'`, before `waveform loaded:`).
|
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
|
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.
|
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 event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` needs a
|
||||||
the pane's availability first, in the state the app starts in -- `[events] live: button_visible=0
|
config that has seen the tour (it opens a slide-over page) and a throwaway `XDG_CACHE_HOME`. It reads the
|
||||||
pane=0 recording=1`, **no pane and no button without trigger mode** -- then turns trigger mode on
|
availability first, in the state the app starts in -- `[events] live, recording off: available=0
|
||||||
through the real toggle (`[events] after trigger on: button_visible=1 pane=0`: the button appears,
|
dock_sensitive=0 page_sensitive=0 page=none` (the dock block and the page agree, both insensitive and neither
|
||||||
the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays
|
hidden) -- then switches recording on (`available=1 dock_sensitive=1 page_sensitive=1`; **trigger mode is not
|
||||||
the newest event through the pane's own row:
|
a condition**, and the line after the trigger is turned on reads the same), opens the `events` page with
|
||||||
|
`open_events_page()` (`page=events`, `[events] open alone: page=events page_box=347`), waits for a latch, then
|
||||||
|
reads the cache and replays the newest event through the page's own row:
|
||||||
`[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001
|
`[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
|
rate=10000 seq=1 thumb_bytes=230454 recorded=1 page=1 plot_w_stable=1 plot_w=756/756` (**`plot_w_stable=1`**: the
|
||||||
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
|
first latch did not move the plot, which the old pane did), then
|
||||||
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
|
`[events] activated row0: replaying=1 page=events … thumbnail=1 thumb_size=138x46 thumb_visible=1` (the size is
|
||||||
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
|
what says the picture is *on screen*: a paint-able alone is not), the dock's reading of the same cache --
|
||||||
the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and
|
`[events] dock events: rows=N pane_rows=N same_titles=1 sensitive=1 dock_w=268 on_screen=1 … at=after pane
|
||||||
`[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written
|
replay` -- then **one click on a dock row**, `[events] dock replay: clicked=1 replaying=1 waveform_changed=1`,
|
||||||
some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the
|
and the dock read again (`marked=1`, `expected_marked=1`: exactly one filled marker, on the replayed record),
|
||||||
cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read
|
`[events] replayed: channels=4 samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0
|
||||||
`button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the
|
waveform=0`. It ends on the availability, through the trigger panel's switch: `[events] recording off,
|
||||||
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
|
trigger off` and `[events] recording off, trigger on` -- once the cache holds events (the probe's own run has
|
||||||
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
|
written some by this point) both read `available=1 dock_sensitive=1 page_sensitive=1`, because a block that
|
||||||
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
|
shows the cache must not go away with the switch that wrote it; against an *empty* cache (and on the E-1608,
|
||||||
|
which never latches) the same two lines read `0 0 0` with the page still open -- insensitive, never hidden.
|
||||||
|
**`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
|
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
|
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
|
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
|
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**
|
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
|
-- `[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
|
recording=1 page=events` (`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:
|
running pushes its own four signals into the group's eight channels), the dock line `members=2 … at=comparison
|
||||||
|
up` (the hollow markers on the group's members, which only the dock shows), `[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
|
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
|
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:
|
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
|
`[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
|
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
|
holds plus one layer each for the second event, **in the same row**, which is what an accumulation
|
||||||
means). Run it with a throwaway
|
means). The exit dump adds `events pane: compare=1 accumulate=1 list=1 nested=1` and the readouts line's
|
||||||
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s).
|
`dock_children=3`. Run it with a throwaway
|
||||||
|
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s). The dock row cap
|
||||||
|
(24) is only exercised by a cache that has filled past it: `max_ok=1`.
|
||||||
- **A waveform round-trips.** Export from a live run (`UDPSCOPE_EXPORT_MS` with
|
- **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
|
`UDPSCOPE_EXPORT_PATH`, against the mock at 10 kS/s), then open that file with `--open` and an
|
||||||
exit dump. The load must print
|
exit dump. The load must print
|
||||||
@@ -511,7 +807,7 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \
|
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=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)"
|
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
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --open /tmp/long.npy --buffer-samples 32768
|
||||||
```
|
```
|
||||||
must read `samples=31250 rows=1000000 decim=32 span=19.999980s time=[-4.000000..15.999980]` and
|
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**.
|
`stored=31250 … published=31250` per channel — **the whole span, at a thirty-second of the rate**.
|
||||||
@@ -544,21 +840,21 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
|
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
|
||||||
load line above, with no socket involved.
|
load line above, with no socket involved.
|
||||||
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
|
- **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`,
|
holding `127.0.0.1_8101/bench.toml` (`buffer_samples = 2097152`, `relative_time = true`,
|
||||||
`capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101`
|
`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
|
must print `[sources] settings from configuration 'bench': samples=2097152 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
|
`relative time:` line, and a `span=` of about 209 s (2 097 152 ticks at 10 kS/s) — **not** the 10M default's ~839 s. The same command with
|
||||||
default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`:
|
`--buffer-samples 262144` must read `samples=262144` and `span=26.214s`:
|
||||||
**an explicit flag wins over the file**. Without this, the settings a host was edited to have were
|
**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.
|
ignored whenever the address came from the command line.
|
||||||
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
|
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
|
||||||
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
|
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
|
||||||
`[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then
|
`[sources] form: name='probe-host' note='…' samples_m=0.5 relative=1 connect_sensitive=1`, then
|
||||||
`[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a
|
`[sources] configuration created: host:port/probe-host … samples=500000 relative=1` and a
|
||||||
`picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it
|
`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
|
(`samples=500000 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
|
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'`
|
configuration and prints `[sources] edit form: name='…' note='…' samples_m=… relative=… button='Save'`
|
||||||
— **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then
|
— **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
|
saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one
|
||||||
`known:` row afterwards, with the old file gone.
|
`known:` row afterwards, with the old file gone.
|
||||||
@@ -783,8 +1079,9 @@ stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s
|
|||||||
immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator.
|
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
|
**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
|
under this recipe -- which opens the front channel's signal page and clicks its "Acquire this signal" tick (an
|
||||||
that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then
|
acquired signal is not in the Add-a-signal list, so the list cannot be walked). A probe
|
||||||
|
that installs a math graph at that same instant rebuilds the signal panels under it, and the off-step then
|
||||||
emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class
|
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
|
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
|
and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1,681 @@
|
|||||||
|
# E-1608 Network Scan Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The E-1608 connection form gets a "Scan network" row that finds boards on every local interface and fills the address or the MAC from a click; the MAC lookup in `E1608Source` stops depending on the default route.
|
||||||
|
|
||||||
|
**Architecture:** A non-blocking `Scan` class in `e1608_link` opens one UDP socket per up IPv4 broadcast interface (bound to the device, or to its address), sends `D` on each and drains replies on `poll()`. The form drives it with a 50 ms GLib timer on the GTK main thread (the project has exactly two threads, so no worker). `discover_broadcast()` is `Scan` plus a blocking wait and replaces the limited-broadcast `discover()` call in the source's MAC lookup.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita (`AdwExpanderRow`, `AdwActionRow`), POSIX sockets (`getifaddrs`, `SO_BINDTODEVICE`), `tools/mock_e1608.py`, the in-app headless probe under `xvfb-run`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Spec: `docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md` (approved 2026-11-02).
|
||||||
|
- Exactly two threads: no worker thread for the scan; the form polls with `g_timeout_add*`.
|
||||||
|
- Every source file carries the SPDX MIT header (no new source files in this plan).
|
||||||
|
- Text buttons only (no theme icons) on the new controls.
|
||||||
|
- A `subnet` that is given still uses `sweep_targets` + `discover` unchanged.
|
||||||
|
- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty result.
|
||||||
|
- Scan results are never persisted: the session reads and writes only the filled fields.
|
||||||
|
- Baselines: a clean build has exactly 3 warnings (an incremental build shows the 2 `ui_probe.cpp` MathNode ones); `gtk warnings: 0`; no `Gtk-CRITICAL`.
|
||||||
|
- GTK traps (`docs/claude/invariants-gtk-and-chrome.md`): handler signatures are notify `(GObject*, GParamSpec*, gpointer)`, clicked `(GtkButton*, gpointer)`, changed `(GtkEditable*, gpointer)`; handlers behind `Form::alive`; probes use `g_object_ref_sink` + `g_object_unref` on never-parented rows.
|
||||||
|
- Probe code must avoid `x({...}).empty() || y({...}).empty()` (GCC `-Wdangling-pointer`).
|
||||||
|
- Verification runs are short and serial; kill every mock afterwards. Do not push or merge.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/source/e1608/e1608_link.hpp` / `.cpp`: `class Scan`, `discover_broadcast()`, a shared reply parser used by `discover()` too.
|
||||||
|
- Modify `src/source/e1608/e1608_source.cpp`: the MAC lookup with an empty subnet uses `discover_broadcast()`.
|
||||||
|
- Modify `src/source/e1608/e1608_kind.cpp`: the scan row, result rows, fills, timer.
|
||||||
|
- Modify `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp`: the `UDPSCOPE_PROBE_E1608_SCAN` probe.
|
||||||
|
- Modify docs: `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`.
|
||||||
|
|
||||||
|
One design note the spec leaves open: `AdwExpanderRow` can only append rows, so "results right after the scan row" is done by making the scan row itself an `AdwExpanderRow` ("Scan network", a text "Scan" suffix button) at the top of the Connection expander, with the results as its child rows. It reads the same: results sit directly under the scan row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The `Scan` class, `discover_broadcast()`, and the MAC lookup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/source/e1608/e1608_link.hpp` (after `discover`, line ~77)
|
||||||
|
- Modify: `src/source/e1608/e1608_link.cpp` (includes; anonymous namespace; `discover`; new definitions)
|
||||||
|
- Modify: `src/source/e1608/e1608_source.cpp:270-293`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (used by Task 2):
|
||||||
|
```cpp
|
||||||
|
class Scan {
|
||||||
|
public:
|
||||||
|
explicit Scan(int port);
|
||||||
|
Scan(const Scan&) = delete;
|
||||||
|
Scan& operator=(const Scan&) = delete;
|
||||||
|
const std::vector<DeviceInfo>& poll(); // drains non-blocking; cumulative, one entry per MAC
|
||||||
|
std::size_t interfaces() const noexcept; // sockets opened
|
||||||
|
};
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
|
||||||
|
```
|
||||||
|
- Consumes: existing `Fd`, `DeviceInfo`, `kProductId`, `Clock`, `ms_left`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Declare the API**
|
||||||
|
|
||||||
|
In `e1608_link.hpp`, after the `discover` declaration add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default
|
||||||
|
/// route only, so on a host with several interfaces it can miss a board on another one; this opens
|
||||||
|
/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or
|
||||||
|
/// to the interface address when binding to the device is refused), sends `D` to
|
||||||
|
/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the
|
||||||
|
/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan.
|
||||||
|
class Scan {
|
||||||
|
public:
|
||||||
|
explicit Scan(int port);
|
||||||
|
Scan(const Scan&) = delete;
|
||||||
|
Scan& operator=(const Scan&) = delete;
|
||||||
|
/// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC.
|
||||||
|
const std::vector<DeviceInfo>& poll();
|
||||||
|
/// How many interfaces a probe was sent on.
|
||||||
|
std::size_t interfaces() const noexcept { return socks_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<Fd> socks_;
|
||||||
|
std::vector<DeviceInfo> found_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Share the reply parser**
|
||||||
|
|
||||||
|
In `e1608_link.cpp` add includes `#include <ifaddrs.h>`, `#include <net/if.h>`, `#include <thread>` (keep the list alphabetical with the existing ones). Inside the existing anonymous namespace, just before its closing `} // namespace` (after `tcp_connect`), add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board
|
||||||
|
/// that `found` did not already hold (one entry per MAC).
|
||||||
|
bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from,
|
||||||
|
std::vector<DeviceInfo>& found) {
|
||||||
|
if (n < 29 || buf[0] != 'D') return false;
|
||||||
|
DeviceInfo info;
|
||||||
|
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
|
||||||
|
if (info.product_id != kProductId) return false;
|
||||||
|
std::copy(buf + 1, buf + 7, info.mac.begin());
|
||||||
|
for (const DeviceInfo& d : found) {
|
||||||
|
if (d.mac == info.mac) return false;
|
||||||
|
}
|
||||||
|
char ip[INET_ADDRSTRLEN] = {};
|
||||||
|
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
|
||||||
|
info.ip = ip;
|
||||||
|
found.push_back(info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `discover()`, replace the block from `if (n < 29 || buf[0] != 'D') continue;` through `found.push_back(info);` (the lines that build `info`, test the MAC for a duplicate and push) with the single call:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
take_reply(buf, n, from, found);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `Scan` and `discover_broadcast`**
|
||||||
|
|
||||||
|
After `discover()` (before the `// --- Link` banner) add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
Scan::Scan(int port) {
|
||||||
|
ifaddrs* list = nullptr;
|
||||||
|
if (::getifaddrs(&list) != 0) return;
|
||||||
|
sockaddr_in to{};
|
||||||
|
to.sin_family = AF_INET;
|
||||||
|
to.sin_port = htons(static_cast<std::uint16_t>(port));
|
||||||
|
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
|
||||||
|
const std::uint8_t probe = 'D';
|
||||||
|
std::vector<std::string> done; // an interface with several addresses needs one probe, not several
|
||||||
|
for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) {
|
||||||
|
if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue;
|
||||||
|
const unsigned fl = a->ifa_flags;
|
||||||
|
if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue;
|
||||||
|
const std::string name = a->ifa_name;
|
||||||
|
if (std::find(done.begin(), done.end(), name) != done.end()) continue;
|
||||||
|
Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
|
||||||
|
if (!s.valid()) continue;
|
||||||
|
const int one = 1;
|
||||||
|
::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
|
||||||
|
if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(),
|
||||||
|
static_cast<socklen_t>(name.size())) != 0) {
|
||||||
|
sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
|
||||||
|
me.sin_port = 0;
|
||||||
|
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
|
||||||
|
}
|
||||||
|
if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
|
||||||
|
sizeof to) != 1)
|
||||||
|
continue;
|
||||||
|
done.push_back(name);
|
||||||
|
socks_.push_back(std::move(s));
|
||||||
|
}
|
||||||
|
::freeifaddrs(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<DeviceInfo>& Scan::poll() {
|
||||||
|
for (const Fd& s : socks_) {
|
||||||
|
for (;;) {
|
||||||
|
std::uint8_t buf[128];
|
||||||
|
sockaddr_in from{};
|
||||||
|
socklen_t flen = sizeof from;
|
||||||
|
const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT,
|
||||||
|
reinterpret_cast<sockaddr*>(&from), &flen);
|
||||||
|
if (n < 0) break; // nothing more on this socket for now
|
||||||
|
take_reply(buf, n, from, found_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms) {
|
||||||
|
Scan scan(port);
|
||||||
|
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
|
||||||
|
while (ms_left(deadline) > 0) {
|
||||||
|
scan.poll();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
}
|
||||||
|
return scan.poll();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Use it in the source's lookup**
|
||||||
|
|
||||||
|
In `e1608_source.cpp` `connect_and_start()`, replace
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const std::vector<std::string> targets =
|
||||||
|
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
|
||||||
|
const std::vector<DeviceInfo> found =
|
||||||
|
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));
|
||||||
|
```
|
||||||
|
|
||||||
|
with
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const std::vector<std::string> targets =
|
||||||
|
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
|
||||||
|
const int wait_ms = std::min(cfg_.timeout_ms, 1500);
|
||||||
|
// No address and no subnet: the limited broadcast, sent on every interface rather than only
|
||||||
|
// the default route's.
|
||||||
|
const bool broadcast =
|
||||||
|
host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255";
|
||||||
|
const std::vector<DeviceInfo> found =
|
||||||
|
broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
Run: `cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done`
|
||||||
|
Expected: no `error:`; only the 2 baseline `ui_probe.cpp` MathNode warnings (incremental) -- nothing new from `e1608_link.cpp` or `e1608_source.cpp`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify the MAC lookup end to end against the mock**
|
||||||
|
|
||||||
|
The mock answers a broadcast `D` on UDP 54211. A run with only a `mac` option (no address, no subnet) must find it and stream:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
|
||||||
|
MOCK=$!
|
||||||
|
UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a ./build/udpscope --source e1608 \
|
||||||
|
--source-opt mac=00:80:2f:00:16:08 --source-opt scan_rate=1000 2>&1 | grep -E "^\[e1608\] (stream|state)"
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `[e1608] stream: connected=1 ... ok=1` and `state: connects=1 message=''`.
|
||||||
|
If it reads `connected=0` with `no E-1608 with MAC ... answered`: the sandbox did not loop the broadcast back to the mock. Check with `ip -4 -o addr` that an up non-loopback interface with a broadcast address exists. If one does and it still fails, report that to the user as a finding; do not paper over it.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_source.cpp
|
||||||
|
git commit -m "e1608: discover boards on every interface, not only the default route's"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: The form's "Scan network" row, results and fills
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/source/e1608/e1608_kind.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Scan`, `DeviceInfo`, `format_mac` (Task 1 / existing, via `e1608_source.hpp`'s includes of `e1608_link.hpp`; add the include if the build says otherwise).
|
||||||
|
- Produces (used by Task 3's probe), by widget name: the scan row `AdwExpanderRow` named `e1608-scan-row`; its suffix `GtkButton` named `e1608-scan`; each result an `AdwActionRow` named `e1608-result` (title IP, subtitle MAC, `activated` fills the address) holding a suffix button named `e1608-use-mac`. Subtitle texts: `Scanning...`, `1 board found`, `N boards found`, `No board found`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the state and `fill()` to `Form`**
|
||||||
|
|
||||||
|
In `struct Form`, after `bool alive = true;...` add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows
|
||||||
|
std::vector<GtkWidget*> results; ///< the result rows of the current scan
|
||||||
|
std::unique_ptr<Scan> scan; ///< non-null while a scan is running
|
||||||
|
guint timer = 0; ///< the GLib source polling `scan`, 0 when none
|
||||||
|
int scan_ms = 0; ///< time spent in the running scan
|
||||||
|
```
|
||||||
|
|
||||||
|
and, after `write(...)`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A result click: puts one way of naming the board in and the other two fields out, under the
|
||||||
|
/// `updating` guard so the dialog is told once.
|
||||||
|
void fill(const std::string& addr, const std::string& mac_text) {
|
||||||
|
if (!alive) return;
|
||||||
|
updating = true;
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(subnet), "");
|
||||||
|
updating = false;
|
||||||
|
if (changed && *changed) (*changed)();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop_scan() {
|
||||||
|
if (timer != 0) g_source_remove(timer);
|
||||||
|
timer = 0;
|
||||||
|
scan.reset();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `#include <vector>` to the includes if absent.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Destroy hook stops the timer**
|
||||||
|
|
||||||
|
Replace `on_form_destroy` with:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void on_form_destroy(GtkWidget*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
f.alive = false;
|
||||||
|
f.stop_scan(); // the poll must not outlive the rows it writes to
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: The scan functions**
|
||||||
|
|
||||||
|
After `hook_entry(...)` and before `class E1608Kind`, add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
constexpr int kScanMs = 1500; ///< how long a scan listens
|
||||||
|
constexpr int kTickMs = 50; ///< how often it drains the sockets
|
||||||
|
|
||||||
|
/// A result row's two ways to name the board, with the form they fill.
|
||||||
|
struct Pick {
|
||||||
|
FormPtr form;
|
||||||
|
std::string ip, mac;
|
||||||
|
};
|
||||||
|
|
||||||
|
void free_pick(gpointer d, GClosure*) { delete static_cast<Pick*>(d); }
|
||||||
|
|
||||||
|
void on_pick_address(AdwActionRow*, gpointer user) {
|
||||||
|
Pick& p = *static_cast<Pick*>(user);
|
||||||
|
p.form->fill(p.ip, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_pick_mac(GtkButton*, gpointer user) {
|
||||||
|
Pick& p = *static_cast<Pick*>(user);
|
||||||
|
p.form->fill("", p.mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
void add_result(const FormPtr& f, const DeviceInfo& d) {
|
||||||
|
const std::string mac = format_mac(d.mac);
|
||||||
|
GtkWidget* row = adw_action_row_new();
|
||||||
|
gtk_widget_set_name(row, "e1608-result");
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str());
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str());
|
||||||
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE);
|
||||||
|
GtkWidget* use_mac = gtk_button_new_with_label("Use MAC");
|
||||||
|
gtk_widget_set_name(use_mac, "e1608-use-mac");
|
||||||
|
gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER);
|
||||||
|
adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac);
|
||||||
|
g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address),
|
||||||
|
new Pick{f, d.ip, mac}, free_pick, static_cast<GConnectFlags>(0));
|
||||||
|
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac},
|
||||||
|
free_pick, static_cast<GConnectFlags>(0));
|
||||||
|
adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row);
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE);
|
||||||
|
f->results.push_back(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_scan_status(Form& f, const std::string& text) {
|
||||||
|
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is
|
||||||
|
/// settled and the scan (its sockets) is released.
|
||||||
|
gboolean on_scan_tick(gpointer user) {
|
||||||
|
const FormPtr& fp = *static_cast<FormPtr*>(user);
|
||||||
|
Form& f = *fp;
|
||||||
|
if (!f.alive || !f.scan) {
|
||||||
|
f.timer = 0;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
const std::vector<DeviceInfo> found = f.scan->poll();
|
||||||
|
while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]);
|
||||||
|
f.scan_ms += kTickMs;
|
||||||
|
if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE;
|
||||||
|
if (found.empty())
|
||||||
|
set_scan_status(f, "No board found");
|
||||||
|
else if (found.size() == 1)
|
||||||
|
set_scan_status(f, "1 board found");
|
||||||
|
else
|
||||||
|
set_scan_status(f, std::to_string(found.size()) + " boards found");
|
||||||
|
f.scan.reset();
|
||||||
|
f.timer = 0;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void free_form_ptr(gpointer d) { delete static_cast<FormPtr*>(d); }
|
||||||
|
|
||||||
|
/// A new scan on the Port field's value: the previous results go, a running scan is replaced.
|
||||||
|
void start_scan(const FormPtr& fp) {
|
||||||
|
Form& f = *fp;
|
||||||
|
if (!f.alive) return;
|
||||||
|
f.stop_scan();
|
||||||
|
for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r);
|
||||||
|
f.results.clear();
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE);
|
||||||
|
f.scan = std::make_unique<Scan>(static_cast<int>(value_of(f.port)));
|
||||||
|
f.scan_ms = 0;
|
||||||
|
set_scan_status(f, "Scanning...");
|
||||||
|
f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp),
|
||||||
|
free_form_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_scan_clicked(GtkButton*, gpointer user) {
|
||||||
|
start_scan(*static_cast<FormPtr*>(user));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`f.stop_scan()` removes the timer; `g_source_remove` fires `free_form_ptr`, which only drops one `FormPtr` copy, never the last. `on_scan_tick` never calls `g_source_remove` on itself: it zeroes `timer` and returns `G_SOURCE_REMOVE`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the row in `make_config_form()`**
|
||||||
|
|
||||||
|
After the `f->ao_init[1] = ...` line, add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
f->scan_row = adw_expander_row_new();
|
||||||
|
gtk_widget_set_name(f->scan_row, "e1608-scan-row");
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network");
|
||||||
|
GtkWidget* scan_button = gtk_button_new_with_label("Scan");
|
||||||
|
gtk_widget_set_name(scan_button, "e1608-scan");
|
||||||
|
gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER);
|
||||||
|
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button);
|
||||||
|
g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f),
|
||||||
|
free_form, static_cast<GConnectFlags>(0));
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the Connection group to start with the scan row:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
|
||||||
|
f->code, f->timeout});
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the description string to:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
out.description =
|
||||||
|
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), "
|
||||||
|
"or scan the network for boards";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build, then check the existing form round trip and warnings**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
|
||||||
|
UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=2500 xvfb-run -a ./build/udpscope \
|
||||||
|
--source probe --source-opt rate=500 2>&1 | grep -E "form e1608|gtk warnings|Gtk-CRITICAL"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no `error:`, only the 2 baseline warnings; `[source-kinds] form e1608: round_trip=1 valid=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/source/e1608/e1608_kind.cpp
|
||||||
|
git commit -m "e1608: a Scan network row in the connection form, with click-to-fill results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: The scan probe, docs, and final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_probe.cpp` (after `e1608_probe_reconnect`, ~line 2459)
|
||||||
|
- Modify: `src/ui_app.hpp:1874-1875`
|
||||||
|
- Modify: `src/ui.cpp:6031-6033`
|
||||||
|
- Modify: `README.md` (E-1608 paragraph, lines ~118-146), `docs/claude/build-and-run.md:118-130`, `docs/claude/verification.md` (after the `UDPSCOPE_PROBE_E1608_RECONNECT` bullet, ~line 443), `docs/claude/probe-readings.md` (`## [e1608]` section, ~line 1848-1890), `docs/claude/architecture.md:45-48`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the widget names from Task 2; `find_named()`, `find_kind()` in `ui_probe.cpp`.
|
||||||
|
- Produces: env var `UDPSCOPE_PROBE_E1608_SCAN=<ms>` and optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` (default `00:80:2f:00:16:08`, the mock's); one stderr line `[e1608] scan: ...`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Declare and register the probe**
|
||||||
|
|
||||||
|
In `ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`,
|
||||||
|
/// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks
|
||||||
|
/// the results were replaced, not added to.
|
||||||
|
gboolean e1608_scan_probe(gpointer user);
|
||||||
|
gboolean e1608_scan_probe_fill(gpointer user);
|
||||||
|
gboolean e1608_scan_probe_rescan(gpointer user);
|
||||||
|
```
|
||||||
|
|
||||||
|
In `ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) {
|
||||||
|
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_scan_probe, &app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the probe**
|
||||||
|
|
||||||
|
In `ui_probe.cpp`, after `e1608_probe_reconnect`, add (this is outside the file's anonymous namespace, like its neighbours; the helpers it uses, `find_named` and `find_kind`, are visible):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The scan probe's state, alive from phase 1 to phase 3.
|
||||||
|
struct E1608ScanProbe {
|
||||||
|
ConfigForm form;
|
||||||
|
int changes = 0; ///< how many times the form told the dialog it changed
|
||||||
|
std::string want_mac; ///< the board the probe expects (the mock's by default)
|
||||||
|
std::string status; ///< the scan row's subtitle, as read at the end of the first scan
|
||||||
|
std::string ip; ///< the expected board's address, as listed
|
||||||
|
std::size_t first = 0; ///< result rows after the first scan
|
||||||
|
bool listed = false; ///< the expected board was among them
|
||||||
|
bool fill_addr = false;
|
||||||
|
bool fill_mac = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
E1608ScanProbe* g_scan_probe = nullptr;
|
||||||
|
|
||||||
|
void collect_named(GtkWidget* root, const char* name, std::vector<GtkWidget*>& out) {
|
||||||
|
if (root == nullptr) return;
|
||||||
|
if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root);
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
|
||||||
|
c = gtk_widget_get_next_sibling(c))
|
||||||
|
collect_named(c, name, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string scan_status(E1608ScanProbe& p) {
|
||||||
|
GtkWidget* row = find_named(p.form.rows[0], "e1608-scan-row");
|
||||||
|
const char* t = row != nullptr ? adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(row)) : nullptr;
|
||||||
|
return t != nullptr ? t : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A form holding stale values in all three naming fields, so a fill is seen to clear the others.
|
||||||
|
void scan_probe_stale(E1608ScanProbe& p) {
|
||||||
|
SourceConfig c;
|
||||||
|
c.set("address", "1.2.3.4");
|
||||||
|
c.set("mac", "aa:bb:cc:dd:ee:ff");
|
||||||
|
c.set("subnet", "10.0.0");
|
||||||
|
p.form.write(c);
|
||||||
|
p.changes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_probe_click(E1608ScanProbe& p) {
|
||||||
|
GtkWidget* button = find_named(p.form.rows[0], "e1608-scan");
|
||||||
|
if (button != nullptr) g_signal_emit_by_name(button, "clicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_probe_cleanup() {
|
||||||
|
if (g_scan_probe == nullptr) return;
|
||||||
|
for (GtkWidget* row : g_scan_probe->form.rows) {
|
||||||
|
g_object_ref_sink(row);
|
||||||
|
g_object_unref(row);
|
||||||
|
}
|
||||||
|
delete g_scan_probe;
|
||||||
|
g_scan_probe = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// Phase 3, 2 s after the second Scan: the results were replaced (same count), and the one line.
|
||||||
|
gboolean e1608_scan_probe_rescan(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
E1608ScanProbe& p = *g_scan_probe;
|
||||||
|
std::vector<GtkWidget*> rows;
|
||||||
|
collect_named(p.form.rows[0], "e1608-result", rows);
|
||||||
|
const bool replaced = rows.size() == p.first;
|
||||||
|
const std::string status = scan_status(p);
|
||||||
|
const bool status_ok = status.find("found") != std::string::npos && status.rfind("No", 0) != 0;
|
||||||
|
const bool ok = p.listed && p.fill_addr && p.fill_mac && replaced && status_ok;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[e1608] scan: found=%zu ip=%s mac=%s status='%s' fill_addr=%d fill_mac=%d "
|
||||||
|
"rescan_replaced=%d ok=%d\n",
|
||||||
|
p.first, p.ip.c_str(), p.want_mac.c_str(), p.status.c_str(), p.fill_addr ? 1 : 0,
|
||||||
|
p.fill_mac ? 1 : 0, replaced ? 1 : 0, ok ? 1 : 0);
|
||||||
|
if (!p.listed)
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[e1608] scan: %s did not answer; no up broadcast interface reaches it, or the "
|
||||||
|
"broadcast was refused (this run proves nothing about the scan)\n",
|
||||||
|
p.want_mac.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
scan_probe_cleanup();
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 2, 2 s after the first Scan: the expected board is listed, a result click fills the address
|
||||||
|
/// only, "Use MAC" fills the MAC only (each telling the dialog exactly once), then Scan again.
|
||||||
|
gboolean e1608_scan_probe_fill(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
E1608ScanProbe& p = *g_scan_probe;
|
||||||
|
p.status = scan_status(p);
|
||||||
|
std::vector<GtkWidget*> rows;
|
||||||
|
collect_named(p.form.rows[0], "e1608-result", rows);
|
||||||
|
p.first = rows.size();
|
||||||
|
GtkWidget* mine = nullptr;
|
||||||
|
for (GtkWidget* r : rows) {
|
||||||
|
const char* sub = adw_action_row_get_subtitle(ADW_ACTION_ROW(r));
|
||||||
|
if (sub != nullptr && p.want_mac == sub) mine = r;
|
||||||
|
}
|
||||||
|
p.listed = mine != nullptr;
|
||||||
|
if (mine != nullptr) {
|
||||||
|
const char* title = adw_preferences_row_get_title(ADW_PREFERENCES_ROW(mine));
|
||||||
|
p.ip = title != nullptr ? title : "";
|
||||||
|
|
||||||
|
scan_probe_stale(p);
|
||||||
|
g_signal_emit_by_name(mine, "activated");
|
||||||
|
const SourceConfig by_addr = p.form.read();
|
||||||
|
p.fill_addr = by_addr.get("address") == p.ip && by_addr.get("mac").empty() &&
|
||||||
|
by_addr.get("subnet").empty() && p.changes == 1;
|
||||||
|
|
||||||
|
scan_probe_stale(p);
|
||||||
|
GtkWidget* use = find_named(mine, "e1608-use-mac");
|
||||||
|
if (use != nullptr) g_signal_emit_by_name(use, "clicked");
|
||||||
|
const SourceConfig by_mac = p.form.read();
|
||||||
|
p.fill_mac = by_mac.get("mac") == p.want_mac && by_mac.get("address").empty() &&
|
||||||
|
by_mac.get("subnet").empty() && p.changes == 1;
|
||||||
|
}
|
||||||
|
scan_probe_click(p);
|
||||||
|
g_timeout_add(2000, e1608_scan_probe_rescan, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: the form, Scan pressed (the row must read `Scanning...` at once), phase 2 in 2 s.
|
||||||
|
gboolean e1608_scan_probe(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
SourceKind* k = find_kind("e1608");
|
||||||
|
if (k == nullptr) {
|
||||||
|
std::fprintf(stderr, "[e1608] scan: no e1608 kind\n");
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
g_scan_probe = new E1608ScanProbe;
|
||||||
|
E1608ScanProbe& p = *g_scan_probe;
|
||||||
|
p.form = k->make_config_form();
|
||||||
|
const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN_MAC");
|
||||||
|
p.want_mac = env != nullptr ? env : "00:80:2f:00:16:08";
|
||||||
|
p.form.on_change([]() {
|
||||||
|
if (g_scan_probe != nullptr) ++g_scan_probe->changes;
|
||||||
|
});
|
||||||
|
scan_probe_click(p);
|
||||||
|
const std::string now = scan_status(p);
|
||||||
|
if (now != "Scanning...")
|
||||||
|
std::fprintf(stderr, "[e1608] scan: status right after Scan reads '%s', expected 'Scanning...'\n",
|
||||||
|
now.c_str());
|
||||||
|
g_timeout_add(2000, e1608_scan_probe_fill, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for the implementer: `SourceConfig::get` returns `std::string` (the existing probe calls `f.read().get("signals") == "..."`); `find_kind`, `SourceConfig` and `ConfigForm` are already used in this file. Do not write `x({...}).empty() || y({...}).empty()`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and run against the mock**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
|
||||||
|
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
|
||||||
|
MOCK=$!
|
||||||
|
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_EXIT_AFTER_MS=6500 xvfb-run -a ./build/udpscope \
|
||||||
|
--source e1608 --source-opt address=127.0.0.1 --source-opt scan_rate=1000 2>&1 \
|
||||||
|
| grep -E "^\[e1608\] scan|gtk warnings|Gtk-CRITICAL"
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `[e1608] scan: found=1 ip=<a local address> mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. `found=2` or more is fine if a real board is on the network (status then reads `N boards found`; the line still needs `ok=1`).
|
||||||
|
If `ok=0` and the second line says the board did not answer, check Task 1 Step 6's loopback note, and tell the user honestly; do not mark this step passed.
|
||||||
|
If a flag other than `listed` reads 0, debug that flag (the fill reads the form back; `changes` must be exactly 1 per fill).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clean-build warning count**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf /tmp/scan-clean && cmake -S . -B /tmp/scan-clean >/dev/null && cmake --build /tmp/scan-clean -j8 2>&1 | grep -c "warning:"; rm -rf /tmp/scan-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `3`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Docs**
|
||||||
|
|
||||||
|
- `README.md`, E-1608 paragraph (line ~119): after "...(optionally with the subnet to sweep)," add the scan: the sentence "A **Scan network** row at the top of the form probes every network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not saved: only the filled field is." Also add one sentence that a MAC with no subnet is now looked up on every interface, not only the default route's, because a single limited broadcast leaves through the default route alone and missed a board on another interface (found on a real board 2026-11-02, where the board was on `net0` and the default route was `wlan0`).
|
||||||
|
- `docs/claude/build-and-run.md` line ~125: change "a MAC with no `address` is looked up by broadcast" to "...looked up by a broadcast sent on every interface (`Scan`), or by a sweep of `subnet`".
|
||||||
|
- `docs/claude/verification.md`: after the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` bullet add a bullet for `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt address=127.0.0.1` against the mock on its default port 54211 (the form scans that port), optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` for a real board; one `[e1608] scan:` line; note it needs a non-loopback up interface with a broadcast address, and that the line says so when the board did not answer.
|
||||||
|
- `docs/claude/probe-readings.md`, `## [e1608]`: add the sample line `[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1` to the block, and one bullet per field: `found` (result rows after the first scan; 0 with the "did not answer" second line is an environment without a reachable broadcast interface, not a pass), `status` (the row subtitle; "No board found" with a mock running is the probe sockets not reaching it), `fill_addr=0` (a result click did not set only the address, or told the dialog other than once), `fill_mac=0` (same for Use MAC), `rescan_replaced=0` (the results of the first scan were kept or doubled: `start_scan` did not remove them).
|
||||||
|
- `docs/claude/architecture.md` line ~45: in the E-1608 three-layers bullet, add that discovery is `Scan` (one socket per interface, polled by the caller; the form polls it from a GLib timer on the main thread, the source's lookup through `discover_broadcast`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp README.md docs/claude
|
||||||
|
git commit -m "e1608: probe and docs for the network scan"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual run on the real board (only if it is attached)**
|
||||||
|
|
||||||
|
Check `ip -4 -o addr` for the board's interface and `ping`-free: simply run the probe without the mock, naming the board's MAC (the user knows it; the MAC printed by the earlier hardware run was on `net0`, address 169.254.88.75):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_PROBE_E1608_SCAN_MAC=<board mac> UDPSCOPE_EXIT_AFTER_MS=6500 \
|
||||||
|
xvfb-run -a ./build/udpscope --source e1608 --source-opt address=127.0.0.1 2>&1 | grep -E "^\[e1608\] scan"
|
||||||
|
```
|
||||||
|
|
||||||
|
If it finds the board (`listed`, `ok=1`), add one sentence with the date to the README's E-1608 paragraph ("the network scan was checked against a real board on <date>") and commit `docs: record the scan check on a real board`. If the board is not attached or the MAC is unknown, say so; claim nothing.
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
# Badge bar, slide-over and dock -- step 1 (the shell) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the sidebar's tab switcher with a plot-edge slide-over panel reached from a badge bar under the plot (Trigger and Source badges), keeping Measure and Signals reachable in a temporary always-on dock, so the app is usable after every task.
|
||||||
|
|
||||||
|
**Architecture:** Two new units, `SlideOver` (`src/ui_slideover.cpp`, a `GtkRevealer` + `GtkStack` placed by margins as one opaque `GtkOverlay` child above the plot) and `BadgeBar` (`src/ui_badges.cpp`, a fixed-height row between the plot and the status line). The existing trigger form and the source kind's `SourcePanel` widgets are *reparented* into slide-over pages. The right `AdwOverlaySplitView` sidebar stays, but its `AdwViewSwitcher`/`AdwViewStack` become one scroller holding the signal list, the cursors and the measurements. The left events pane and everything else is untouched (spec step 4).
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8 (`AdwToggleGroup` is 1.7+), CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui.cpp` / `src/ui_probe.cpp` probes, never a separate harness.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` (read it first). Branch: `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
Copied from the spec and the repository's rules; every task includes them.
|
||||||
|
|
||||||
|
- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the dock has a fixed width, the badge bar a fixed height, the slide-over overlays the plot and never resizes it. Rows that appear by trigger condition appear inside the slide-over's scroller.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget aligned to the end edge by `halign=END` and a 360 px width, never a full-area transparent child, so pan, drop and hover on the rest of the plot are untouched.
|
||||||
|
- The header's Trigger / Cursors / Pause toggles keep their two-way mirroring with the panels.
|
||||||
|
- `SourcePanel{id, title, widget}` is unchanged: the E-1608 and Flat UDP panels need no change.
|
||||||
|
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. (Step 1 implements `trigger` and `source-<id>` only.)
|
||||||
|
- The open slide-over page is **not** saved. The session file drops `side_tab`; an old file's `side_tab` is ignored.
|
||||||
|
- Esc or a click outside closes the slide-over; one page at a time; each page has its own scroller.
|
||||||
|
- "Disable, do not hide": the dock is shown but insensitive until the stream has signals.
|
||||||
|
- Text buttons only for new controls (no icon-only buttons, no icons in badges).
|
||||||
|
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
|
||||||
|
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
|
||||||
|
- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` (the host has `WAYLAND_DISPLAY`) and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`.
|
||||||
|
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Deliberate step-1 deviations from the spec (state them in the final report)
|
||||||
|
|
||||||
|
- The dock keeps the sidebar's **320 px** (spec: ~280). The panels it holds were built for 320; the width change belongs to step 3 when they become compact readouts.
|
||||||
|
- The session file gains the dock's *shown* state only, not a width: the dock's width is fixed, so there is nothing to persist.
|
||||||
|
- The Trigger badge shows `off / armed / triggered` plus `capturing` and `disarmed`, so the label never claims a state the engine is not in.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Create/Modify | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/ui_slideover.cpp` | Create | The slide-over: build, page registry, open/close/toggle, Esc, click-away, `slideover` probe. |
|
||||||
|
| `src/ui_badges.cpp` | Create | The badge bar: Trigger and Source badges, per-tick state, pure `trigger_badge_state()`, `badges` probe. |
|
||||||
|
| `src/ui_app.hpp` | Modify | `SlideOver`/`BadgeBar` structs on `App`, declarations, removal of `side_stack`/`side_switcher`/`side_tab`/`keep_tabs_icon_free`. |
|
||||||
|
| `src/ui/main-window.ui` | Modify | `plot_overlay` around `plot_box`; `badge_bar` row; the sidebar tab switcher/stack replaced by one dock scroller. |
|
||||||
|
| `src/ui_widgets.cpp` | Modify | CSS for badges and the slide-over (`install_css`). |
|
||||||
|
| `src/ui.cpp` | Modify | Build/wire both units in `on_activate`; tick sync; Esc; dock visibility; remove tab code. |
|
||||||
|
| `src/ui_source.cpp` | Modify | Source panels become slide-over pages; `keep_tabs_icon_free` deleted. |
|
||||||
|
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Tour/context-help re-pointed (task 5). |
|
||||||
|
| `src/ui_layout.cpp`, `src/layout_store.{hpp,cpp}` | Modify | `side_tab` out, `dock_shown` in. |
|
||||||
|
| `src/shortcuts.{hpp,cpp}`, `src/ui/shortcuts.ui` | Modify | `SideTabMeasure/Signals` -> `ShowMeasure/ShowSignals`. |
|
||||||
|
| `src/ui_probe.cpp` | Modify | New probes; every probe that touched the tab stack re-pointed. |
|
||||||
|
| `CMakeLists.txt` | Modify | Add the two new `.cpp` files to the `udpscope` sources (next to `src/ui_source.cpp`). |
|
||||||
|
| `README.md`, `docs/claude/*.md` | Modify | Task 6. (`USER-GUIDE.md` and the Mallard manual's figures are the spec's step 4.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: SlideOver unit and the plot overlay (confirms the compositing risk)
|
||||||
|
|
||||||
|
Spec step 1 says: confirm the overlay-above-GL risk *before* anything else is built on it. This task builds the slide-over with a throw-away probe page and proves composite, geometry and input.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_slideover.cpp`
|
||||||
|
- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_probe.cpp` (or put the probe in `ui_slideover.cpp`; `ui_app.hpp` declares it either way), `CMakeLists.txt`, `docs/claude/verification.md` (probe env var line)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (all in `namespace udpscope::ui_impl`, declared in `ui_app.hpp`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed.
|
||||||
|
struct SlideOver {
|
||||||
|
GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`)
|
||||||
|
GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px
|
||||||
|
GtkWidget* title = nullptr; ///< GtkLabel in the panel header
|
||||||
|
GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page
|
||||||
|
GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2
|
||||||
|
std::vector<std::string> source_pages;
|
||||||
|
std::string page;
|
||||||
|
bool syncing = false; ///< true while we write the toggle group ourselves
|
||||||
|
};
|
||||||
|
constexpr int kSlideOverWidth = 360;
|
||||||
|
void build_slideover(App& app); // once, after the plot_overlay exists
|
||||||
|
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content);
|
||||||
|
bool slideover_has_page(const App& app, const std::string& id);
|
||||||
|
bool slideover_open(App& app, const std::string& id); // false when the id is unknown
|
||||||
|
void slideover_close(App& app);
|
||||||
|
bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close; false if unknown
|
||||||
|
const std::string& slideover_page(const App& app); // "" when closed
|
||||||
|
bool slideover_escape(App& app); // closes if open; true when it did
|
||||||
|
bool slideover_click_away(App& app, double x, double y); // x,y in overlay coords; closes unless inside the panel; true when it closed
|
||||||
|
gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS
|
||||||
|
```
|
||||||
|
`App` gains `SlideOver slideover;` and `GtkWidget* plot_overlay = nullptr;`. Task 2 consumes `slideover_add_page`, `slideover_toggle`, `slideover_page`; task 3 consumes `source_pages`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Capture the UDPS baseline from the current HEAD**
|
||||||
|
|
||||||
|
The tabs are about to go, so freeze today's self-check first. Build `HEAD` (`cmake --build build -j`), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-badges-base.log 2>&1
|
||||||
|
kill $MOCK
|
||||||
|
sed 's/[0-9.]\+/N/g' /tmp/ui-badges-base.log > .superpowers/sdd/ui-badges-baseline-udps.txt
|
||||||
|
grep -c 'Gtk-CRITICAL' /tmp/ui-badges-base.log; grep 'gtk warnings' /tmp/ui-badges-base.log
|
||||||
|
```
|
||||||
|
Expected: `0` Gtk-CRITICAL, `gtk warnings: 0`. Also keep `/tmp/ui-badges-base.log` un-normalised. Every later task diffs its own normalised run against this file and must **explain every differing line** from that task's "allowed baseline changes" list; any other difference is a defect.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wrap the plot in `plot_overlay` in `src/ui/main-window.ui`**
|
||||||
|
|
||||||
|
In `left_column`, replace the first `<child>` (the bare `plot_box`) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<child>
|
||||||
|
<!-- The plot, and the slide-over panel that opens over its right edge. The panel is
|
||||||
|
one opaque overlay child placed by `halign=end` and a 360 px width -- never a
|
||||||
|
full-area transparent child, which would be picked wherever its allocation is
|
||||||
|
and swallow the plot's pan, drop and hover (docs/claude/invariants-plot-and-layout.md).
|
||||||
|
The overlay is the only parent change: `plot_box` keeps its id and its content. -->
|
||||||
|
<object class="GtkOverlay" id="plot_overlay">
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="plot_box">
|
||||||
|
... (the existing plot_box, unchanged) ...
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
```
|
||||||
|
Update the file's header comment: the plot column is `plot_overlay` > `plot_box`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the CSS to `install_css()` (`src/ui_widgets.cpp`)**
|
||||||
|
|
||||||
|
Append to `kCss` before the final `menubutton.udpscope-hint` rules (keep the string a single literal, each rule on its own `"...\n"` line):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The slide-over panel over the plot's right edge: opaque (it must hide the plot under it),
|
||||||
|
// with an edge and a shadow so it reads as above the plot rather than beside it.
|
||||||
|
".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;"
|
||||||
|
" box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n"
|
||||||
|
".udpscope-slideover-title { font-weight: bold; }\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write `src/ui_slideover.cpp`**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface.
|
||||||
|
// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast<App*>(user)); }
|
||||||
|
|
||||||
|
void on_source_switch(GObject* o, GParamSpec*, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (app->slideover.syncing) return;
|
||||||
|
const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o));
|
||||||
|
if (name != nullptr && app->slideover.page != name) slideover_open(*app, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the
|
||||||
|
/// plot's own pan/zoom/drop gestures see the press exactly as they did before.
|
||||||
|
void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) {
|
||||||
|
slideover_click_away(*static_cast<App*>(user), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void build_slideover(App& app) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
s.overlay = app.plot_overlay;
|
||||||
|
if (s.overlay == nullptr) return;
|
||||||
|
|
||||||
|
GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_widget_add_css_class(panel, "udpscope-slideover");
|
||||||
|
gtk_widget_set_size_request(panel, kSlideOverWidth, -1);
|
||||||
|
gtk_widget_set_hexpand(panel, FALSE);
|
||||||
|
|
||||||
|
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
gtk_widget_set_margin_start(head, 12);
|
||||||
|
gtk_widget_set_margin_end(head, 6);
|
||||||
|
gtk_widget_set_margin_top(head, 6);
|
||||||
|
gtk_widget_set_margin_bottom(head, 6);
|
||||||
|
s.title = gtk_label_new("");
|
||||||
|
gtk_widget_add_css_class(s.title, "udpscope-slideover-title");
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f);
|
||||||
|
gtk_widget_set_hexpand(s.title, TRUE);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END);
|
||||||
|
GtkWidget* close = gtk_button_new_with_label("Close");
|
||||||
|
gtk_widget_add_css_class(close, "flat");
|
||||||
|
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(head), s.title);
|
||||||
|
gtk_box_append(GTK_BOX(head), close);
|
||||||
|
gtk_box_append(GTK_BOX(panel), head);
|
||||||
|
|
||||||
|
// A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under
|
||||||
|
// the header; it is hidden unless the open page is a source page and there are two or more.
|
||||||
|
s.source_switch = adw_toggle_group_new();
|
||||||
|
gtk_widget_set_margin_start(s.source_switch, 12);
|
||||||
|
gtk_widget_set_margin_end(s.source_switch, 12);
|
||||||
|
gtk_widget_set_margin_bottom(s.source_switch, 6);
|
||||||
|
gtk_widget_set_visible(s.source_switch, FALSE);
|
||||||
|
g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app);
|
||||||
|
gtk_box_append(GTK_BOX(panel), s.source_switch);
|
||||||
|
gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||||
|
|
||||||
|
s.stack = gtk_stack_new();
|
||||||
|
gtk_widget_set_vexpand(s.stack, TRUE);
|
||||||
|
gtk_box_append(GTK_BOX(panel), s.stack);
|
||||||
|
|
||||||
|
s.revealer = gtk_revealer_new();
|
||||||
|
gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT);
|
||||||
|
gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120);
|
||||||
|
gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel);
|
||||||
|
gtk_widget_set_halign(s.revealer, GTK_ALIGN_END);
|
||||||
|
gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL);
|
||||||
|
// Closed, it must not be a pick target for the frames of its slide-out.
|
||||||
|
gtk_widget_set_can_target(s.revealer, FALSE);
|
||||||
|
gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer);
|
||||||
|
gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE);
|
||||||
|
|
||||||
|
GtkGesture* click = gtk_gesture_click_new();
|
||||||
|
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE);
|
||||||
|
g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app);
|
||||||
|
gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled
|
||||||
|
// to somewhere it never asked for. It does not propagate the content's height, so the panel's
|
||||||
|
// height is the plot's and never the page's.
|
||||||
|
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||||
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), content);
|
||||||
|
gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str());
|
||||||
|
if (is_source_page(id)) {
|
||||||
|
s.source_pages.push_back(id);
|
||||||
|
AdwToggle* t = adw_toggle_new();
|
||||||
|
adw_toggle_set_name(t, id.c_str());
|
||||||
|
adw_toggle_set_label(t, title.c_str());
|
||||||
|
adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_has_page(const App& app, const std::string& id) {
|
||||||
|
return app.slideover.stack != nullptr &&
|
||||||
|
gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_open(App& app, const std::string& id) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (!slideover_has_page(app, id)) return false;
|
||||||
|
GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str());
|
||||||
|
gtk_stack_set_visible_child(GTK_STACK(s.stack), child);
|
||||||
|
s.page = id;
|
||||||
|
GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child);
|
||||||
|
const char* title = gtk_stack_page_get_title(sp);
|
||||||
|
gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : "");
|
||||||
|
const bool several = is_source_page(id) && s.source_pages.size() >= 2;
|
||||||
|
gtk_widget_set_visible(s.source_switch, several);
|
||||||
|
if (several) {
|
||||||
|
s.syncing = true;
|
||||||
|
adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str());
|
||||||
|
s.syncing = false;
|
||||||
|
}
|
||||||
|
gtk_widget_set_can_target(s.revealer, TRUE);
|
||||||
|
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE);
|
||||||
|
sync_badge_bar(app);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void slideover_close(App& app) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (s.revealer == nullptr || s.page.empty()) return;
|
||||||
|
s.page.clear();
|
||||||
|
gtk_widget_set_can_target(s.revealer, FALSE);
|
||||||
|
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE);
|
||||||
|
sync_badge_bar(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_toggle(App& app, const std::string& id) {
|
||||||
|
if (!slideover_has_page(app, id)) return false;
|
||||||
|
if (app.slideover.page == id) {
|
||||||
|
slideover_close(app);
|
||||||
|
} else {
|
||||||
|
slideover_open(app, id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& slideover_page(const App& app) { return app.slideover.page; }
|
||||||
|
|
||||||
|
bool slideover_escape(App& app) {
|
||||||
|
if (app.slideover.page.empty()) return false;
|
||||||
|
slideover_close(app);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_click_away(App& app, double x, double y) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false;
|
||||||
|
graphene_point_t in = GRAPHENE_POINT_INIT(static_cast<float>(x), static_cast<float>(y));
|
||||||
|
graphene_point_t out;
|
||||||
|
if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) &&
|
||||||
|
gtk_widget_contains(s.revealer, out.x, out.y)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
slideover_close(app);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the probe ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct ProbeState {
|
||||||
|
App* app;
|
||||||
|
int plot_w_before = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool under(GtkWidget* w, GtkWidget* ancestor) {
|
||||||
|
return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean slideover_probe_read(gpointer user) {
|
||||||
|
ProbeState* ps = static_cast<ProbeState*>(user);
|
||||||
|
App& a = *ps->app;
|
||||||
|
SlideOver& s = a.slideover;
|
||||||
|
const int ow = gtk_widget_get_width(s.overlay);
|
||||||
|
const int oh = gtk_widget_get_height(s.overlay);
|
||||||
|
graphene_rect_t r;
|
||||||
|
const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r);
|
||||||
|
const int rw = have ? static_cast<int>(r.size.width + 0.5f) : -1;
|
||||||
|
const int rx = have ? static_cast<int>(r.origin.x + 0.5f) : -1;
|
||||||
|
const int rh = have ? static_cast<int>(r.size.height + 0.5f) : -1;
|
||||||
|
// Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel.
|
||||||
|
GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT);
|
||||||
|
GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT);
|
||||||
|
const int plot_w_open = gtk_widget_get_width(a.plot_box);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d "
|
||||||
|
"outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n",
|
||||||
|
s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0,
|
||||||
|
under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before,
|
||||||
|
plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0);
|
||||||
|
// Click-away from outside the panel closes; a press inside does not.
|
||||||
|
const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0);
|
||||||
|
const bool open_after_inside = !s.page.empty();
|
||||||
|
const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0);
|
||||||
|
slideover_open(a, "probe");
|
||||||
|
const bool esc_closed = slideover_escape(a);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d "
|
||||||
|
"closed=%d\n",
|
||||||
|
inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0,
|
||||||
|
s.page.empty() ? 1 : 0);
|
||||||
|
delete ps;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
gboolean slideover_probe(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
// A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the
|
||||||
|
// panel has something that takes focus and keys.
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||||
|
gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page"));
|
||||||
|
gtk_box_append(GTK_BOX(box), gtk_entry_new());
|
||||||
|
slideover_add_page(*a, "probe", "Probe", box);
|
||||||
|
ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)};
|
||||||
|
slideover_open(*a, "probe");
|
||||||
|
// The revealer's slide is 120 ms; the reading is taken well after it has settled.
|
||||||
|
g_timeout_add(500, slideover_probe_read, ps);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
|
```
|
||||||
|
`sync_badge_bar(App&)` does not exist until task 2: in this task add to `ui_app.hpp` a declaration and, in `ui_slideover.cpp`'s neighbour, nothing -- instead **temporarily omit** the two `sync_badge_bar(app);` calls and add them in task 2 step 4 (they are marked `// task 2` if you prefer a comment). Check every GTK/libadwaita signature against the installed headers (`/usr/include/gtk-4.0`, `/usr/include/libadwaita-1`); fix names rather than guessing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Declare, wire, register**
|
||||||
|
|
||||||
|
- `ui_app.hpp`: add the `SlideOver` struct, `kSlideOverWidth`, the function declarations from **Interfaces**, and on `App`: `SlideOver slideover; GtkWidget* plot_overlay = nullptr;`.
|
||||||
|
- `ui.cpp` `on_activate`: next to the other `gtk_builder_get_object` lookups add `app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));`, add it to the null guard, and call `build_slideover(*app);` right after `app->plot_split` is resolved and the tour overlay is built (the plot overlay exists from the builder, so any point before `make_area` works).
|
||||||
|
- `ui.cpp` probe scheduling (next to line ~5906 `side_tab_probe`): `if (const char* env = std::getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) g_timeout_add(static_cast<guint>(std::atoi(env)), slideover_probe, &app);`
|
||||||
|
- `CMakeLists.txt`: add `src/ui_slideover.cpp` after `src/ui_source.cpp`.
|
||||||
|
- `docs/claude/verification.md`: add an entry for `UDPSCOPE_PROBE_SLIDEOVER_MS=2000` (with `UDPSCOPE_EXIT_AFTER_MS=5000`): the two `[slideover]` lines and what each field must read (below).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build and run the probe**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j 2>&1 | grep -E 'warning|error' ; echo "build rc=$?"
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[slideover\]|Gtk-|gtk warnings'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
Expected: the build has zero warnings (incremental) and `[slideover]` line 1 reads `panel=360x<overlay height> ... edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`; line 2 reads `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`; `gtk warnings: 0`. If `outside_hits_plot=0` the panel has an allocation wider than 360 or a transparent parent -- stop and fix, do not go on.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Confirm the compositing by eye (the risk the spec names)**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 \
|
||||||
|
UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8114 \
|
||||||
|
>/tmp/so.log 2>&1 & sleep 2.4; import -window root /tmp/slideover.png; wait'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
The probe closes the page at ~2.0 s, so for the screenshot only: temporarily leave it open by setting `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which makes `slideover_probe_read` return before the click/esc part (add that early `if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD")) { delete ps; return G_SOURCE_REMOVE; }` after the first `fprintf`). Read `/tmp/slideover.png` with the Read tool and confirm: the panel is drawn **over** the traces on the plot's right edge, opaque, with its header, "Close" button, entry; the plot to its left is intact. Record the result in the task report. If the GL surface draws over the panel or the panel is transparent, stop: the design's premise failed, report BLOCKED.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Baseline diff and commit**
|
||||||
|
|
||||||
|
Re-run Step 1's command into `/tmp/ui-badges-t1.log`, normalise, `diff` against `.superpowers/sdd/ui-badges-baseline-udps.txt`.
|
||||||
|
Allowed baseline changes: none (a layout line that prints the plot area's size may differ by the panel not being open -- it must not).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_slideover.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp CMakeLists.txt docs/claude/verification.md
|
||||||
|
git commit -m "ui: a slide-over panel over the plot's right edge, with its probe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: BadgeBar with the Trigger badge, and the trigger form into the slide-over
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_badges.cpp`
|
||||||
|
- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_slideover.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, `docs/claude/verification.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `slideover_add_page`, `slideover_toggle`, `slideover_page`, `slideover_open`.
|
||||||
|
- Produces:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// One badge: a button with a name and a state line. `page` is the slide-over page it opens.
|
||||||
|
struct Badge {
|
||||||
|
GtkWidget* button = nullptr;
|
||||||
|
GtkWidget* name = nullptr;
|
||||||
|
GtkWidget* state = nullptr;
|
||||||
|
std::string page;
|
||||||
|
};
|
||||||
|
struct BadgeBar {
|
||||||
|
GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height
|
||||||
|
GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it
|
||||||
|
GtkWidget* system_slot = nullptr; ///< right: Trigger, Source
|
||||||
|
Badge trigger;
|
||||||
|
Badge source; ///< task 3
|
||||||
|
};
|
||||||
|
/// The Trigger badge's state word. Pure, so the probe can assert the whole table.
|
||||||
|
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event);
|
||||||
|
void build_badge_bar(App& app);
|
||||||
|
void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed
|
||||||
|
gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS
|
||||||
|
```
|
||||||
|
`App` gains `BadgeBar badges;` and, via the builder, `badge_bar`. `trigger_badge_state` truth table (enabled, armed, pending, has_event) -> word: `!enabled` -> `off`; `has_event && !pending` -> `triggered`; `pending` -> `capturing`; `armed` -> `armed`; else `disarmed`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the badge bar row to `main-window.ui`**
|
||||||
|
|
||||||
|
In `left_column`, between `plot_overlay` and `status_row`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<child>
|
||||||
|
<!-- Setup is reached by the object it configures: a badge per signal on the left
|
||||||
|
(step 2), the system badges (Trigger, Source) on the right. A fixed height, so
|
||||||
|
the plot above it never moves when badges come and go. -->
|
||||||
|
<object class="GtkBox" id="badge_bar">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="height-request">36</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<property name="margin-end">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="badge_signals">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="badge_system">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="halign">end</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
```
|
||||||
|
Add the three ids to the guard in `on_activate`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: CSS (`install_css`)**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is
|
||||||
|
// marked, as a bench scope marks the open menu's button.
|
||||||
|
".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n"
|
||||||
|
".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n"
|
||||||
|
".udpscope-badge-state { opacity: 0.7; }\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `src/ui_badges.cpp`**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
// The badge bar under the plot: setup is reached by the object it configures.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
|
||||||
|
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) {
|
||||||
|
if (!enabled) return "off";
|
||||||
|
if (has_event && !pending) return "triggered";
|
||||||
|
if (pending) return "capturing";
|
||||||
|
return armed ? "armed" : "disarmed";
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void set_text_if_changed(GtkWidget* label, const char* text) {
|
||||||
|
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_open(GtkWidget* button, bool open) {
|
||||||
|
if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return;
|
||||||
|
if (open) {
|
||||||
|
gtk_widget_add_css_class(button, "udpscope-badge-open");
|
||||||
|
} else {
|
||||||
|
gtk_widget_remove_css_class(button, "udpscope-badge-open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_badge_clicked(GtkButton*, gpointer user) {
|
||||||
|
Badge* b = static_cast<Badge*>(user);
|
||||||
|
// The App is reached through the badge's button: the badges live inside App, so the pointer is stable.
|
||||||
|
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(b->button), "udpscope-app"));
|
||||||
|
if (app != nullptr) slideover_toggle(*app, b->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A badge: a flat pill, name on the left, state dimmed beside it. Words only.
|
||||||
|
void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) {
|
||||||
|
b.page = page;
|
||||||
|
b.button = gtk_button_new();
|
||||||
|
gtk_widget_add_css_class(b.button, "flat");
|
||||||
|
gtk_widget_add_css_class(b.button, "udpscope-badge");
|
||||||
|
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||||
|
b.name = gtk_label_new(name);
|
||||||
|
b.state = gtk_label_new("");
|
||||||
|
gtk_widget_add_css_class(b.state, "udpscope-badge-state");
|
||||||
|
gtk_box_append(GTK_BOX(row), b.name);
|
||||||
|
gtk_box_append(GTK_BOX(row), b.state);
|
||||||
|
gtk_button_set_child(GTK_BUTTON(b.button), row);
|
||||||
|
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
|
||||||
|
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
|
||||||
|
gtk_box_append(GTK_BOX(parent), b.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void build_badge_bar(App& app) {
|
||||||
|
BadgeBar& bb = app.badges;
|
||||||
|
bb.bar = app.badge_bar;
|
||||||
|
bb.signals_slot = app.badge_signals;
|
||||||
|
bb.system_slot = app.badge_system;
|
||||||
|
make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger");
|
||||||
|
sync_badge_bar(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sync_badge_bar(App& app) {
|
||||||
|
BadgeBar& bb = app.badges;
|
||||||
|
if (bb.trigger.button == nullptr) return;
|
||||||
|
const bool enabled = app.trigger_toggle != nullptr &&
|
||||||
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE;
|
||||||
|
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
|
||||||
|
const bool pending = app.model->trigger_pending();
|
||||||
|
const bool has_event = app.model->event() != nullptr;
|
||||||
|
set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event));
|
||||||
|
set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean badges_probe(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
// The whole table of the pure function: 16 inputs, 5 words.
|
||||||
|
struct Row { bool e, a, p, v; const char* want; };
|
||||||
|
static const Row kRows[] = {
|
||||||
|
{false, false, false, false, "off"}, {false, true, true, true, "off"},
|
||||||
|
{true, false, false, true, "triggered"}, {true, true, false, true, "triggered"},
|
||||||
|
{true, true, true, false, "capturing"}, {true, false, true, true, "capturing"},
|
||||||
|
{true, true, false, false, "armed"}, {true, false, false, false, "disarmed"},
|
||||||
|
};
|
||||||
|
int bad = 0;
|
||||||
|
for (const Row& r : kRows) {
|
||||||
|
if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad;
|
||||||
|
}
|
||||||
|
// The geometry: fixed height whatever the state, and the open mark follows the slide-over.
|
||||||
|
const int h_closed = gtk_widget_get_height(a->badges.bar);
|
||||||
|
slideover_toggle(*a, "trigger");
|
||||||
|
const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE;
|
||||||
|
const bool page_open = slideover_page(*a) == "trigger";
|
||||||
|
slideover_toggle(*a, "trigger");
|
||||||
|
const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE;
|
||||||
|
const int h_after = gtk_widget_get_height(a->badges.bar);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d "
|
||||||
|
"page_open=%d closed_unmarked=%d\n",
|
||||||
|
bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after,
|
||||||
|
h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0,
|
||||||
|
marked_closed ? 1 : 0);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
|
```
|
||||||
|
(The probe checks `trigger_badge_state` against the full table the declaration gives. Add the 8 remaining combinations if you want all 16; the 8 above cover every word and every branch.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire it**
|
||||||
|
|
||||||
|
- `ui_app.hpp`: `Badge`, `BadgeBar`, declarations; on `App`: `BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; GtkWidget* badge_system = nullptr;`.
|
||||||
|
- `ui_slideover.cpp`: restore the two `sync_badge_bar(app);` calls from task 1 (end of `slideover_open` and `slideover_close`).
|
||||||
|
- `ui.cpp` `on_activate`: resolve the three ids, then `build_badge_bar(*app);` **after** `app->trigger_toggle` exists (it is created in `build_toolbar`, which runs before) and after `build_slideover`.
|
||||||
|
- `ui.cpp` `tick()`: call `sync_badge_bar(*app);` next to `sync_source_panels(*app);`.
|
||||||
|
- `ui.cpp`: probe scheduling `UDPSCOPE_PROBE_BADGES_MS` -> `badges_probe`.
|
||||||
|
- `CMakeLists.txt`: add `src/ui_badges.cpp`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Move the trigger form into the slide-over's `trigger` page**
|
||||||
|
|
||||||
|
In `on_activate` (currently ~ui.cpp:5187):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Signals: the trigger picks a channel from the list beside it.
|
||||||
|
app->trigger_panel = build_trigger_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
|
||||||
|
```
|
||||||
|
becomes
|
||||||
|
```cpp
|
||||||
|
// The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the
|
||||||
|
// sidebar until the dock replaces it.
|
||||||
|
app->trigger_panel = build_trigger_panel(*app);
|
||||||
|
slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel);
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
|
||||||
|
```
|
||||||
|
Then deal with everything that assumed the trigger was on the Signals page **and mapped**: `grep -n "trigger_panel\|trigger_body\|trigger_head\|trigger_summary\|dbg_trigger_\|dbg_signal_page_current" src/*.cpp`. Specifically `exit_probe_step3` (`ui_probe.cpp` ~100-206) reads `gtk_widget_get_width/height(a->trigger_panel)`, `on_signals && gtk_widget_get_sensitive(a->trigger_body)`; any probe that reads a trigger widget's *allocation* must call `slideover_open(*a, "trigger")` and wait one settled frame (the reveal is 120 ms) before reading, and close it after. Reads of sensitivity/visibility/value need no open page. `dbg_signal_page_current` / `dbg_trigger_sensitive` keep their meaning but `on_signals` is now about the sidebar tab only for the signal list: `dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body)`.
|
||||||
|
The trigger form's header toggle mirroring (`trigger_switch` <-> `trigger_toggle`), `sync_trigger_rows` and the locked summary view are all widget-state, not page-state; they must be unaffected -- the existing trigger probes are the proof (Step 6).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify**
|
||||||
|
|
||||||
|
Build (0 warnings). Then, serially, with a mock each:
|
||||||
|
1. `[badges]` probe: `UDPSCOPE_PROBE_BADGES_MS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500` against the UDPS mock -> `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`, `gtk warnings: 0`.
|
||||||
|
2. Every existing trigger probe in `docs/claude/verification.md` (grep `UDPSCOPE_PROBE_.*TRIGGER\|FORCE\|ARM\|PERSIST\|DENSITY\|CONDITION\|LOCK\|SUMMARY`; run those whose names mention the trigger panel, arming, locking, the summary or the panel switch) and compare against the reading each entry in `docs/claude/probe-readings.md` states. A probe that now reads zero because its widget is unallocated is the fix described in Step 5, not a regression to hide.
|
||||||
|
3. Screenshot with `UDPSCOPE_PROBE_BADGES_HOLD`-style early exit is not needed: add `slideover_open(*a,"trigger")` to nothing; instead take one screenshot (Task 1 step 7's command with `UDPSCOPE_PROBE_BADGES_MS` replaced by a one-off env `UDPSCOPE_OPEN_PAGE=trigger` read in `on_activate`'s tail -- remove it after) and view it: badge bar under the plot, Trigger badge pressed, the trigger form in the panel. Do not commit the temporary env.
|
||||||
|
4. Baseline diff (normalised) vs `.superpowers/sdd/ui-badges-baseline-udps.txt`.
|
||||||
|
Allowed baseline changes: the `side`/`trigger` size lines (`trigger_panel` is read through the slide-over), plus the new `[badges]` lines.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_badges.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp src/ui_slideover.cpp src/ui_probe.cpp CMakeLists.txt docs/claude/verification.md
|
||||||
|
git commit -m "ui: a badge bar with a Trigger badge, and the trigger form in the slide-over"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: The Source badge and the source panels in the slide-over
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_source.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `docs/claude/verification.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `slideover_add_page`, `slideover.source_pages`, `Badge`/`BadgeBar::source`.
|
||||||
|
- Produces: `build_source_panels(App&)` now registers `source-<id>` slide-over pages (id and `SourcePanelPage::name` unchanged); `sync_badge_bar` also writes the Source badge. `SourcePanelPage` is unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `build_source_panels` registers slide-over pages**
|
||||||
|
|
||||||
|
In `src/ui_source.cpp`, replace the scroller + `adw_view_stack_add_titled` block with:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const std::string name = "source-" + p.id;
|
||||||
|
// The panel is a slide-over page now: the slide-over wraps it in its own scroller, and with two or
|
||||||
|
// more source pages it draws the segmented switch (the E-1608: Analog out, Digital I/O).
|
||||||
|
if (!slideover_add_page(app, name, p.title, p.widget)) continue;
|
||||||
|
app.source_panels.push_back({name, p.title, p.widget});
|
||||||
|
```
|
||||||
|
and change the guard at the top of the function from `app.side_stack == nullptr` to `app.slideover.stack == nullptr`. `apply_source_panels`/`sync_source_panels` (sensitivity from the connection flag) are unchanged.
|
||||||
|
Leave `keep_tabs_icon_free` in place for now (the sidebar tabs still exist until task 4); it must not be called with source pages any more -- it only hides images, so it is harmless.
|
||||||
|
|
||||||
|
- [ ] **Step 2: The Source badge**
|
||||||
|
|
||||||
|
In `build_badge_bar`, after the Trigger badge:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its
|
||||||
|
// badge is a read-out: not a target, not focusable, but still on screen.
|
||||||
|
const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name;
|
||||||
|
make_badge(app, bb.source, bb.system_slot, "Source", first);
|
||||||
|
if (first.empty()) {
|
||||||
|
gtk_widget_set_can_target(bb.source.button, FALSE);
|
||||||
|
gtk_widget_set_focusable(bb.source.button, FALSE);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`build_badge_bar` must therefore run **after** `build_source_panels(*app)` -- move its call there. (Order in `on_activate`: `build_slideover`, trigger page, `build_source_panels`, `build_badge_bar`.) For a source's open mark, the Source badge is "open" for any `source-*` page: in `sync_badge_bar`, `set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0)`.
|
||||||
|
State text (written in `sync_badge_bar`): the name label is the source kind's `display_name()` (`find_kind(app.source_kind)`, include `source/registry.hpp`; fall back to `"Source"`), the state is `connected` / `disconnected` from `app.model->stats().connected.load(std::memory_order_relaxed)`; in file mode (`app.replaying` or `!app.model->config().open_waveform.empty()`) the name is `"File"` and the state is empty. Set the name label with `set_text_if_changed` too.
|
||||||
|
The badge click on a multi-page source opens the **first** page; the segmented switch inside the panel changes page (Task 1's `on_source_switch`). If the open page is already a source page, a click closes it (that is `slideover_toggle`'s same-page rule, which the badge's `page` = first page gives for the first page; for any other open source page make `on_badge_clicked` close the panel when `b == &app->badges.source` and the open page starts with `source-`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-point the probes that read the source pages**
|
||||||
|
|
||||||
|
- `ui_probe.cpp` ~1958 and ~2400: `adw_view_stack_get_child_by_name(side_stack, name) != nullptr` -> `slideover_has_page(*a, name)`.
|
||||||
|
- `ui_probe.cpp` ~2495: `adw_view_stack_set_visible_child_name(side_stack, "source-ao")` -> `slideover_open(*a, "source-ao")`; the probe reads panel widgets' values and sensitivity, and any read of an allocation needs the page open and one settled frame (>= 150 ms) -- keep the page open for the probe's reads and close it at the end.
|
||||||
|
- Add to the dump (or a `UDPSCOPE_PROBE_BADGES_MS` extension) a `[badges] source name=<...> state=<...> pages=<n> switch_visible=<0|1>` line: after `slideover_open(*a, <first source page>)`, `switch_visible` must read `1` for the E-1608 (two pages) and `0` for the probe source (one page); then select the second page via `adw_toggle_group_set_active_name` and read back `slideover_page(*a)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify (serial; kill each mock)**
|
||||||
|
|
||||||
|
1. Probe source: `UDPSCOPE_PROBE_SOURCE_KINDS=3000 UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_PROBE_BADGES_MS=2500 ./build/udpscope --source probe --source-opt rate=500` -> `[badges] source name=... pages=1 switch_visible=0`; `[source-kinds]` lines unchanged.
|
||||||
|
2. E-1608: `python3 tools/mock_e1608.py` (default port 54211), run the app with `--source e1608` the way `docs/claude/verification.md` states, with the E-1608 panel probes (`UDPSCOPE_PROBE_E1608_*`) and `UDPSCOPE_PROBE_BADGES_MS` -> `pages=2 switch_visible=1`, the panel probes' readings unchanged, `gtk warnings: 0`.
|
||||||
|
3. UDPS mock baseline diff. Allowed baseline changes: none beyond Task 2's; the Source badge on UDPS is the not-a-target read-out.
|
||||||
|
4. Screenshot of the E-1608 run with the Source page open (cairo, as Task 1 step 7), view it: the segmented switch under the header, the Analog out page below it.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_source.cpp src/ui_badges.cpp src/ui_app.hpp src/ui.cpp src/ui_probe.cpp docs/claude/verification.md
|
||||||
|
git commit -m "ui: the Source badge, and the source panels as slide-over pages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Replace the sidebar tabs with the temporary dock; session and keyboard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui/main-window.ui`, `src/ui.cpp`, `src/ui_source.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/shortcuts.hpp`, `src/shortcuts.cpp`, `src/ui/shortcuts.ui` (if it lists the two actions), `src/ui_probe.cpp`, `docs/claude/*` entries touched by their probes
|
||||||
|
- Read first: `docs/claude/invariants-session-and-export.md`, `docs/claude/invariants-plot-and-layout.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `App::dock_page` (the dock's `GtkBox`), `void dock_show_section(App&, const char* section)` (`"signals"` or `"measure"`; scrolls the dock to that section; unknown name is a no-op), `AppAction::ShowMeasure` / `AppAction::ShowSignals` (renamed from `SideTabMeasure` / `SideTabSignals`; same keys `m` and `g`, labels "Go to Measure" / "Go to Signals"), `SessionState::dock_shown` (bool, default true). **Removed:** `App::side_stack`, `App::side_switcher`, `App::side_tab`, `select_side_tab`, `on_side_tab_changed`, `keep_tabs_icon_free`, `SessionState::side_tab`, `App::dbg_switcher_*`, `dbg_signal_page_current`'s tab meaning.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The dock in `main-window.ui`**
|
||||||
|
|
||||||
|
Replace the `side_column` subtree (switcher + stack + the two page scrollers) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- The dock: one scroller holding the signal list, then the cursors and the measurements.
|
||||||
|
It replaces the tab switcher (four text tabs did not fit 320 px and a source can add
|
||||||
|
more); it is the temporary home of Measure and Signals until steps 2 and 3 turn them
|
||||||
|
into the badge bar's signals and the compact readouts. -->
|
||||||
|
<object class="GtkBox" id="side_column">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="width-request">300</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkScrolledWindow" id="dock_scroller">
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="vscrollbar-policy">automatic</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkBox" id="dock_page">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
```
|
||||||
|
Update the file's header comment (the sidebar is the dock; the panels appended to `dock_page`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fill the dock in `on_activate` and delete the tab code**
|
||||||
|
|
||||||
|
- Resolve `dock_page`, `dock_scroller` instead of `side_stack`, `side_switcher`, `measure_page`, `signals_page`; guard them; `app->side_box = side;` stays (the tour and the layout probes measure it). Add `GtkWidget* dock_page; GtkWidget* dock_scroller;` to `App`.
|
||||||
|
- Order in the dock, top to bottom: `signal_panel`, separator, `cursors_panel`, separator, `build_measures_panel(*app)`. The signal list is first because it is what is dragged onto the plot.
|
||||||
|
- Delete: the `adw_view_stack_set_visible_child_name(side_stack, "signals")` block and its `notify::visible-child-name` connection, `keep_tabs_icon_free(*app)` and its definition/declaration in `ui_source.cpp`/`ui_app.hpp` (with `hide_tab_icons`, `on_tab_pages_changed`), `on_side_tab_changed`, `select_side_tab` (`ui.cpp` ~2867-2882), `App::side_stack/side_switcher/side_tab`, and the `dbg_switcher_*` members.
|
||||||
|
- Add in `ui.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void dock_show_section(App& app, const char* section) {
|
||||||
|
if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return;
|
||||||
|
GtkWidget* target = std::strcmp(section, "measure") == 0 ? app.cursors_panel
|
||||||
|
: std::strcmp(section, "signals") == 0 ? app.signal_panel
|
||||||
|
: nullptr;
|
||||||
|
if (target == nullptr) return;
|
||||||
|
graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f);
|
||||||
|
graphene_point_t at;
|
||||||
|
if (!gtk_widget_compute_point(target, app.dock_page, &zero, &at)) return;
|
||||||
|
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller));
|
||||||
|
gtk_adjustment_set_value(adj, at.y);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `run_app_action`: `case AppAction::ShowMeasure: dock_show_section(app, "measure"); break;` / `ShowSignals` likewise. Rename in `shortcuts.hpp`, `shortcuts.cpp` (labels "Go to Measure" / "Go to Signals", same group "Panes and editors"), `shortcuts.ui` if present, and the keyboard probe (`ui_probe.cpp` ~6278-6305: it compared `side_tab` before/after; compare the dock scroller's `vadjustment` value instead -- `m` must move it to the cursors panel's y, `g` back to 0).
|
||||||
|
- Add `case Escape`: in `on_app_key_pressed`, directly after the tour's Escape branch and before the editable check: `if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Dock visibility -- shown, insensitive until signals**
|
||||||
|
|
||||||
|
`update_side_visibility` (`ui.cpp` ~2887) and the per-frame block in `tick()` (~4029-4047) both compute `want = have_signals && sidebar_wanted`. Change both to `want = sidebar_wanted`, and state the dock's availability instead:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// "Disable, do not hide": the dock is on screen from the start and does nothing until the stream has signals.
|
||||||
|
gtk_widget_set_sensitive(app.side_box, have_signals);
|
||||||
|
gtk_widget_set_sensitive(app.sidebar_toggle, TRUE);
|
||||||
|
```
|
||||||
|
(`trigger_toggle`'s visibility from `have_signals` is unchanged; `update_side_visibility` keeps the early-return guard on `app.side_box == nullptr` instead of `side_stack`.) Update the comments above both blocks.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Session -- `side_tab` out, `dock_shown` in**
|
||||||
|
|
||||||
|
- `layout_store.hpp`: remove `side_tab`; add `bool dock_shown = true;` documented as "the dock's shown state (the user's last word on the toolbar's Dock button)".
|
||||||
|
- `layout_store.cpp`: writer (~743) emits `dock = true|false` in the same top section where `side_tab` was written (keep key order per the session invariants); parser: replace the `key == "side_tab"` branch with `key == "dock"` -> `st.dock_shown = value == "true"`; an old file's `side_tab = ...` falls through to the existing unknown-key handling (check the final `else` of that chain ignores it -- read it; do not add a branch to swallow it if it already does). Bump nothing else.
|
||||||
|
- `ui_layout.cpp:67` -> `st.dock_shown = app.sidebar_wanted;`; `:430` -> `app.sidebar_wanted = st.dock_shown;` then `update_side_visibility(app);`.
|
||||||
|
- A change of the user's wish must schedule a save: in `on_sidebar_toggled`, after recording `sidebar_wanted` when `!sidebar_syncing`, `if (!app->updating_panels) schedule_layout_save(*app);`.
|
||||||
|
- If there are session round-trip probes (`grep -n "side_tab" src/ui_probe.cpp docs/claude/*.md`), extend them: save with the dock hidden, reload, read `sidebar_wanted == false`; and a file containing `side_tab = "measure"` loads with no error and `dock_shown` default.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-point the layout/sidebar probes**
|
||||||
|
|
||||||
|
`ui_probe.cpp`: `exit_probe_step3` (~100-206), `side_tab_probe*` (~1800-1875), `sidebar_probe` (~3532-3567), the `select_side_tab(*a, "measure")` calls at ~4519 and ~4678, and the tour-surface probes (done in Task 5). Rules:
|
||||||
|
- `dbg_switcher_*` and the `switcher:` dump line (ui.cpp ~2998, 3055-3090, and the printing at ~6224-6436) are deleted; replace with one `dock: shown=<0|1> sensitive=<0|1> w=<px> scroller_pages=1` line and document it.
|
||||||
|
- `side_tab_probe` keeps its job (measurements keep accumulating while the Measure section is scrolled out of view) with `dock_show_section(*a, "measure")` / `"signals"` in place of `select_side_tab`; rename it `dock_section_probe`, env `UDPSCOPE_PROBE_SIDE_TAB_MS` is kept as is (do not break scripts); its `advanced=4` assertion is unchanged.
|
||||||
|
- `dbg_signal_page_current` becomes "the signal list has rows" (no tab), `dbg_trigger_sensitive` is read directly.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify**
|
||||||
|
|
||||||
|
Build (0 warnings incremental; clean build has exactly one). Serially: UDPS mock baseline diff; `UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_EXIT_AFTER_MS=11000` (`advanced=4`); the sidebar/collapse probe; the keyboard probe; the session round-trip probe; the pane-drag probe (`dbg_drag_hid_sidebar`/`restored` both 1: dragging a signal from an overlaid dock still hides it for the drag); one run with **no signals** (UDPS mock with `--source` that has none, or a run before CONFIG) reading `dock: shown=1 sensitive=0`; E-1608 and probe-source runs from Task 3 again. Screenshot at 1280x800: plot, badge bar, dock with the list, cursors, measurements.
|
||||||
|
Allowed baseline changes: the `switcher:` line goes; a `dock:` line appears; `tab`/`page` readings that named `signals`/`measure` become the section readings; `[sidebar]` lines may differ only by `shown` when no signals existed.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A src docs/claude
|
||||||
|
git commit -m "ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Tour, contextual help and the help figures
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`; read `tools/check_help.sh`, `tools/make-help-shots.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `TourView::on_page` -- `void (*on_page)(void* user, const char* page) = nullptr; void* page_user = nullptr;`. When set it replaces the `page_stack` mechanism for that view: `select_view_page` calls `on_page(page_user, stop->page)` (page may be `nullptr`, meaning "no page needed"). The chooser keeps `page_stack`. `TourStep::page` for the main window now names a slide-over page (`"trigger"`) or is `nullptr`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The page hook in the shared tour machinery (`ui_tour.cpp` / `ui_app.hpp`)**
|
||||||
|
|
||||||
|
- Add the two members to `TourView`.
|
||||||
|
- `select_view_page`: first line `if (view.on_page != nullptr) { view.on_page(view.page_user, page); return; }`.
|
||||||
|
- `stop_is_walkable`: `if (stop.page != nullptr && view.on_page != nullptr) return true;` before the `page_stack` rule -- a slide-over page can always be opened, whatever the dock is doing.
|
||||||
|
- `start_tour`: replace `app.tour.page_stack = app.side_stack;` with `app.tour.on_page = +[](void* u, const char* page) { App& a = *static_cast<App*>(u); if (page != nullptr) slideover_open(a, page); else slideover_close(a); }; app.tour.page_user = &app;`. A stop with `page == nullptr` closes the panel, so the card never walks to the status line over an open panel. On `tour_end`, close the panel too (find where the tour tears down and call `slideover_close` through the same hook with `nullptr`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite the main window's stops**
|
||||||
|
|
||||||
|
`kTourSteps` becomes (titles/bodies in plain words; keep the existing style):
|
||||||
|
|
||||||
|
| id | page | target | text (replace with your own wording of this meaning) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `plot` | nullptr | `&App::area_overlay` | unchanged |
|
||||||
|
| `badges` | nullptr | `&App::badge_bar` | "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the Source badge opens the source's own controls. Click it again, press Esc or click the plot to close the panel." |
|
||||||
|
| `trigger` | `"trigger"` | `&App::trigger_body` | existing trigger text (describe an event, arm it) |
|
||||||
|
| `side` | nullptr | `&App::side_box` | "The dock beside the plot: the list of signals, then the cursors and the measurements. The buttons in the bar above show and hide it." |
|
||||||
|
| `signals` | nullptr | `&App::signal_list` | unchanged |
|
||||||
|
| `status` | nullptr | `&App::status_row` | unchanged |
|
||||||
|
|
||||||
|
Add `GtkWidget* App::badge_bar` to the table (it is already an `App` member from Task 2). Order matters for the walk: the trigger stop opens the panel, the `side` stop closes it. Update `kFigureSurfaces` (`ui_tour.cpp` ~484): drop `side_switcher` and `side_stack`; add `{"badge_bar", &App::badge_bar}` and `{"dock", &App::side_box}`. If `tools/check_help.sh`, `tools/make-help-shots.sh` or any `docs/help/**` figure data names `side_switcher`/`side_stack`, grep for them (`grep -rn "side_switcher\|side_stack" tools docs src data 2>/dev/null`): keep `side_stack` as a **second name for the dock** in `kFigureSurfaces` until the manual's figures are redone in step 4 (record that in the ledger), and drop `side_switcher` only if nothing names it.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Contextual help (`ui_context_help.cpp`)**
|
||||||
|
|
||||||
|
The hint table (~line 92 `{"signals", "The signals", ...}`) anchors "(i)" marks to panel hosts. Read the whole table. Hints whose host is now in the slide-over (the trigger's) are on an unmapped widget while the panel is closed: confirm the code that places them tolerates an unmapped host (hints are overlay children of the host; they should appear with the panel) and that `context_help_count` / the `hints=N ids={...}` reading is the same set of ids as before. Add a hint for the badge bar only if the table's pattern is "one hint per panel"; otherwise do not.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-point the tour probes**
|
||||||
|
|
||||||
|
`ui_probe.cpp` ~6565-6875 (`[tour]`, `[toursurface]`): `side_tab=` becomes `slideover=<page|none>` printed from `slideover_page(a)`; `select_side_tab(*a, "measure")` + `mapped(a->side_stack)` become `slideover_open(*a, "trigger")` and `mapped(a->side_box)`; the "collapsed" leg (dock collapsed) must show that the trigger stop is still walkable (it is the new behaviour: the slide-over does not depend on the dock) and the signals stop is dropped. Read `docs/claude/probe-readings.md` for the `[toursurface]` entry and update it in Task 6.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Tour probes (`grep -n "TOUR\|HELPUI\|CONTEXT" docs/claude/verification.md` for their env names) -> the walk lists the stops above in order, `slideover=trigger` at the trigger stop and `slideover=none` at the next, spotlight rectangles contained in the overlay, `hints=` ids as before; `tools/check_help.sh` passes. UDPS baseline diff; allowed changes: tour step count and stop ids/titles, `side_tab=` fields.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_tour.cpp src/ui_context_help.cpp src/ui_app.hpp src/ui_probe.cpp
|
||||||
|
git commit -m "ui: the tour and the contextual help follow the badge bar, the slide-over and the dock"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Documentation for step 1, and the final check
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/invariants-gtk-and-chrome.md`, `docs/claude/build-and-run.md` (only if it names a changed file), `CLAUDE.md` (only its table if a row's text is now wrong)
|
||||||
|
- Not in this plan (spec step 4): `USER-GUIDE.md`, the Mallard manual's text and figures.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Sweep the references**
|
||||||
|
|
||||||
|
`grep -rn "side_switcher\|side_stack\|side_tab\|switcher:\|keep_tabs_icon_free\|SideTab\|Measure tab\|Signals tab\|tab switcher\|sidebar tab" README.md CLAUDE.md docs/claude src tools` -- every hit outside `docs/superpowers/` is rewritten or deleted. Specifically:
|
||||||
|
- `README.md`: the layout description (badge bar, slide-over, dock), the keyboard table (`m`/`g` are "Go to Measure / Signals"), the session file's keys, the deviations list (the three step-1 deviations above, as known limitations of the in-progress redesign).
|
||||||
|
- `docs/claude/architecture.md`: the three new units and the rule that the slide-over/badge bar are built in `on_activate`, per-tick state in `sync_badge_bar`.
|
||||||
|
- `docs/claude/invariants-plot-and-layout.md`: the slide-over as the second thing placed over the plot (after the layout editor's buttons), with the pick rule; the badge bar's fixed height.
|
||||||
|
- `docs/claude/invariants-gtk-and-chrome.md`: delete the tab-icon paragraph and `keep_tabs_icon_free` (the switcher is gone); add: a capture-phase observer gesture that never claims the sequence is how click-away coexists with the plot's gestures; `AdwToggleGroup` is the segmented switch.
|
||||||
|
- `docs/claude/probe-readings.md` and `verification.md`: the new lines `[slideover]`, `[badges]`, `dock:`, the removal of `switcher:`; the new baseline statement for the UDPS diff ("unchanged except ..." now relative to the commit before this series, with the allowed list); the renamed keyboard actions.
|
||||||
|
- `docs/claude/verification.md` header sentence about the UDPS baseline: say a self-check taken **before** `c572ec2` is no longer the baseline for these lines.
|
||||||
|
|
||||||
|
- [ ] **Step 2: The final run, serially**
|
||||||
|
|
||||||
|
Clean build count: `touch src/icons/udpscope.gresource.xml && cmake --build build -j 2>&1 | grep -c warning` -> `1`; incremental -> `0`. UDPS mock full self-check: `gtk warnings: 0`, no `Gtk-CRITICAL`, `buffer allocated:` lines 2-3. Each probe touched in Tasks 1-5 once more, on the final tree. A last screenshot at 1280x800 with the trigger page open and one with a source page open (E-1608 mock) for the report. Kill every mock.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md CLAUDE.md docs/claude
|
||||||
|
git commit -m "docs: the badge bar, the slide-over and the dock (step 1 of the redesign)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (against the spec)
|
||||||
|
|
||||||
|
- **Spec step 1 -- "plot overlay, badge bar with Trigger and Source, slide-over holding the existing trigger form and source pages, sidebar tabs removed, existing Measure and Signals in a temporary dock":** Task 1 (overlay + slide-over), Task 2 (badge bar, Trigger, trigger form), Task 3 (Source, source pages), Task 4 (tabs removed, temporary dock).
|
||||||
|
- **Risks the spec assigns to step 1:** overlay above GL -> Task 1 steps 6-7 before anything else; reparenting the trigger form -> Task 2 step 6 (existing trigger probes). "Many signals" is step 2.
|
||||||
|
- **Header:** the Events toggle removal and the Dock toggle rename are *not* step 1 (the events pane stays until step 4); the sidebar toggle already is the dock toggle in behaviour -- its tooltip/label text, if it says "sidebar", is renamed "Dock" in Task 4 step 3 (look at `header-bar.ui`).
|
||||||
|
- **Keyboard / session / probes / tour / docs:** Tasks 4, 4, 4-5, 5, 6.
|
||||||
|
- **Left to later steps (by the spec's rollout):** signal badges and drag (2), compact readouts and the 280 px dock (3), events section and deleting the left pane (4), USER-GUIDE and manual figures (4).
|
||||||
|
- **Names used across tasks:** `slideover_add_page/has_page/open/close/toggle/page/escape/click_away`, `SlideOver::source_pages`, `build_badge_bar`, `sync_badge_bar`, `trigger_badge_state`, `BadgeBar::{trigger,source}`, `dock_show_section`, `ShowMeasure/ShowSignals`, `SessionState::dock_shown`, `TourView::on_page/page_user` are used with one spelling throughout.
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
# Horizontal plot split Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let each row of the stacked plots be split into 1..4 plots side by side, all sharing one time window, with draggable cell widths saved in the session.
|
||||||
|
|
||||||
|
**Architecture:** Extend the vendored plotter's `plot::Figure` so a figure row holds several plots (flattened plot index, `FigureLayout.cells`). Adapters hit-test by cell rect. The host `RowSet` keeps its flattened plot index (`row_count()` = plot count) and gains `cells_per_row_` plus per-row cell weights. The layout editor, session file, signals page and a new headless probe follow.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita, vendored `deps/plotter` (own `PLOT_TEST` suite, GLFW + GTK4 adapters), CMake, headless probes under `xvfb-run` against `tools/mock_udpstreamer.py`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-11-03-horizontal-split-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All plots (cells in a row, rows in the figure) are members of the one x link group. No new time semantics; a split is not an independent time window and not an XY plot.
|
||||||
|
- Each figure row holds 1..4 cells, independently of the other rows. At most 4 cells per row and at most 8 plots in total (`RowSet::kMaxRows` keeps its value and now bounds plots, not figure rows).
|
||||||
|
- `Figure(u32 rows)` stays and means one cell per row; existing behaviour and `tests/test_figure.cpp` are unchanged.
|
||||||
|
- `plot(i)` indexes the flattened cells, top to bottom, left to right. Everything keyed by a plot index in the host keeps meaning "flattened plot index".
|
||||||
|
- Widths use the height scheme: a floor per cell, the weights share the rest, the last cell closes the row exactly (no seam). A figure too narrow for the floors shrinks the cells proportionally and drops the column gap; no rect is ever negative.
|
||||||
|
- Only the bottom figure row's cells carry x labels and the x title; other cells get `XStrip::None`.
|
||||||
|
- The left y gutter stays one common maximum for the whole figure with the same reserved floor (layout must not depend on content).
|
||||||
|
- `FigureLayout` gains `cells` (one `Rect` per plot, flattened). `rows` stays the full-width rect per figure row.
|
||||||
|
- Cursors: the vertical pair is one shared time drawn in every cell; horizontal rulers are per cell. The crosshair is one x on every cell; the hovered cell shows its y. Y axes are per cell. An x gesture anywhere moves the shared window.
|
||||||
|
- Layout editor: cell `+` insensitive at 4 cells in the row or 8 plots in total; row `+` insensitive at 8 plots in total; each cell keeps its top-left `×` (removes the cell in a multi-cell row, the row in a one-cell row; insensitive when it would leave no plot). Divider drag floors each neighbour at about 96 px logical and is saved on drag end.
|
||||||
|
- Session file: `[[plot]]` tables gain `row` (the figure row) and `width` (the cell's weight). A session without `row` loads as one cell per row. Row `weight` stays per figure row (carried on the row's first cell).
|
||||||
|
- Every source file carries an SPDX MIT header; match it in new files.
|
||||||
|
- No unit-test suite for the app: extend the headless probes in `src/ui.cpp` / `src/ui_probe.cpp`. The plotter has its own `PLOT_TEST` suite.
|
||||||
|
- Baselines for every run: exactly one build warning on a clean build; `gtk warnings: 0`; no `Gtk-CRITICAL`.
|
||||||
|
- Keep verification runs short and serial; kill the mock servers you start (never the foreign mock on port 8114).
|
||||||
|
- Plotter changes are committed inside `deps/plotter` (not pushed), then the new pin is committed in the main repo with `git add deps/plotter`.
|
||||||
|
- Branch is `feature/source-plugins`. Do not push or merge to `main`.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` (paths as found in the submodule) | `Figure` cell grid, `place_cells`, `FigureLayout.cells` |
|
||||||
|
| `deps/plotter/src/.../renderer.cpp` | draw every plot (`plot_count()`) |
|
||||||
|
| `deps/plotter/tests/test_figure.cpp`, `tests/test_glfw_host.cpp` | plotter tests |
|
||||||
|
| `deps/plotter/adapters/gtk4/gtk_plot_area.*`, `adapters/glfw/glfw_host.*` | x-aware pointer routing over cell rects |
|
||||||
|
| `src/plot_host.hpp/.cpp` | `RowSet` cell state and operations |
|
||||||
|
| `src/layout_store.hpp/.cpp` | `row`/`width` in the session file |
|
||||||
|
| `src/ui_layout.cpp` | `collect_layout` / `apply_layout` |
|
||||||
|
| `src/ui.cpp`, `src/ui_app.hpp` | layout editor controls, notices, probe scheduling |
|
||||||
|
| `src/ui_signal_page.cpp`, `src/ui_signals.cpp` | "Plot on" labels, drop target notices |
|
||||||
|
| `src/ui_probe.cpp` | `UDPSCOPE_PROBE_SPLIT_MS` probe, layout probe re-baseline |
|
||||||
|
| docs (see Task 8) | user and developer docs |
|
||||||
|
|
||||||
|
Locate the exact plotter paths first with `git -C deps/plotter ls-files | grep -E 'figure|renderer|gtk_plot_area|glfw_host'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Plotter `Figure` cell grid
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/.../figure.hpp`, `deps/plotter/.../figure.cpp`, `deps/plotter/.../renderer.cpp` (~line 474)
|
||||||
|
- Test: `deps/plotter/tests/test_figure.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (all in `plot::Figure`):
|
||||||
|
- `explicit Figure(std::span<const u32> cells_per_row);` (a count of 0 is clamped to 1). `explicit Figure(u32 rows)` stays and means one cell per row. No `initializer_list` constructor (`Figure{3u}` must keep its meaning).
|
||||||
|
- `u32 row_count() const` = figure rows; `u32 plot_count() const`; `u32 cells_in_row(u32 row) const`; `u32 first_plot_of_row(u32 row) const` (returns `plot_count()` for `row == row_count()`); `u32 row_of_plot(u32 plot) const`.
|
||||||
|
- `Status set_cell_weights(u32 row, std::span<const f32> weights)`; `void clear_cell_weights(u32 row)`; `bool has_cell_weights(u32 row) const`; `void set_col_gap(f32 px)`; `f32 col_gap() const`.
|
||||||
|
- `FigureLayout::cells` (`std::vector<Rect>`, flattened, one per plot) and `FigureLayout::col_gap` (`f32`, 0 when any row dropped the gap).
|
||||||
|
- `Plot& plot(u32 i)` indexes flattened cells; `set_row_weights` validates against the figure row count.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current code**
|
||||||
|
|
||||||
|
Read `figure.hpp` fully and `figure.cpp` `layout()`, the constructor, `set_row_weights`, `set_crosshair`. Note member names `plots_`, `row_weights_`, `row_gap_`, `min_y_gutter_`, `right_gutter_`, `labels_bottom_only_`, `links_`, and `kMinRowHeightLogical = 24`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_figure.cpp` (add `#include <span>` and `#include <vector>` if missing). Use the file's existing helpers `figure_with_axes`, `give_magnitude_row`, `whole_figure()` if present; if the viewport helper has a different name, use the one the neighbouring tests use for a 640x480 viewport.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
plot::Figure split_figure(const std::vector<plot::u32>& cells)
|
||||||
|
{
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a most-vexing parse
|
||||||
|
figure.set_viewport(plot::Rect{0, 0, 640, 480});
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
|
||||||
|
figure.plot(i).add_x_axis();
|
||||||
|
figure.plot(i).add_y_axis();
|
||||||
|
}
|
||||||
|
return figure;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_all_ones_equals_rows)
|
||||||
|
{
|
||||||
|
auto split = split_figure({1, 1, 1});
|
||||||
|
auto plain = figure_with_axes(3);
|
||||||
|
split.layout();
|
||||||
|
plain.layout();
|
||||||
|
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(split.layout_info().cells[i].y, plain.layout_info().rows[i].y, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(split.layout_info().cells[i].h, plain.layout_info().rows[i].h, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(split.layout_info().cells[i].w, plain.layout_info().rows[i].w, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_tile_exactly)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({1, 2, 1});
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
figure.layout();
|
||||||
|
const auto& fl = figure.layout_info();
|
||||||
|
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
|
||||||
|
PLOT_CHECK(figure.cells_in_row(1) == 2u);
|
||||||
|
PLOT_CHECK(figure.first_plot_of_row(1) == 1u && figure.first_plot_of_row(3) == 4u);
|
||||||
|
PLOT_CHECK(figure.row_of_plot(2) == 1u);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cell_weights_and_floor)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({2});
|
||||||
|
figure.set_col_gap(0.0f);
|
||||||
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0, w));
|
||||||
|
figure.layout();
|
||||||
|
{
|
||||||
|
const auto& c = figure.layout_info().cells;
|
||||||
|
PLOT_CHECK_NEAR((c[0].w - 64.0) / (c[1].w - 64.0), 3.0, 0.01);
|
||||||
|
}
|
||||||
|
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0, tiny));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.layout_info().cells[1].w >= 64.0f - 0.001f);
|
||||||
|
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
|
||||||
|
const std::vector<plot::f32> zero{1.0f, 0.0f};
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(0, three));
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(0, zero));
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(5, w));
|
||||||
|
figure.clear_cell_weights(0);
|
||||||
|
PLOT_CHECK(!figure.has_cell_weights(0));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.layout_info().cells[0].w, figure.layout_info().cells[1].w, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_narrow_degrades)
|
||||||
|
{
|
||||||
|
std::vector<plot::u32> cells{4};
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)};
|
||||||
|
figure.set_viewport(plot::Rect{0, 0, 20, 100});
|
||||||
|
for (plot::u32 i = 0; i < 4; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); }
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
figure.layout();
|
||||||
|
const auto& fl = figure.layout_info();
|
||||||
|
double sum = 0;
|
||||||
|
for (const auto& c : fl.cells) {
|
||||||
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
|
||||||
|
sum += c.w;
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_share_x_link_and_crosshair)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({2, 1});
|
||||||
|
const auto group = figure.add_x_link();
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) figure.link_x(group, i, 0);
|
||||||
|
figure.plot(1).set_x_range(0, {3.0, 9.0});
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0).lo, 3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0).hi, 9.0, 1e-9);
|
||||||
|
}
|
||||||
|
figure.set_crosshair(4.0, true);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
PLOT_CHECK(figure.plot(i).crosshair().active);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
|
||||||
|
}
|
||||||
|
figure.clear_crosshair();
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_x_labels_bottom_row_only)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({1, 2});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(0).layout().x_label_area.h == 0.0f);
|
||||||
|
PLOT_CHECK(figure.plot(1).layout().x_label_area.h > 0.0f);
|
||||||
|
PLOT_CHECK(figure.plot(2).layout().x_label_area.h > 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(figure.layout_info().x_axis_strip.h, figure.plot(2).layout().x_label_area.h, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_common_y_gutter)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({2});
|
||||||
|
give_magnitude_row(figure.plot(0), 0);
|
||||||
|
give_magnitude_row(figure.plot(1), 1);
|
||||||
|
figure.layout();
|
||||||
|
const auto& fl = figure.layout_info();
|
||||||
|
const double g0 = figure.plot(0).layout().axes.x - fl.cells[0].x;
|
||||||
|
const double g1 = figure.plot(1).layout().axes.x - fl.cells[1].x;
|
||||||
|
PLOT_CHECK(g0 > 0.0);
|
||||||
|
PLOT_CHECK_NEAR(g0, g1, 0.01);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust accessor names (`layout_info()`, `set_viewport`, `link_x`, `x_range`, `add_x_axis`) to the ones the existing tests in the same file use; the intent of each test is fixed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build --target plot_tests 2>&1 | tail -20` (use the plotter build dir the repo already has; if none, configure with the plotter's own `CMakeLists.txt` and `-DPLOT_BUILD_TESTS=ON`).
|
||||||
|
Expected: compile failure, `plot_count` / `set_cell_weights` not members of `Figure`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the header**
|
||||||
|
|
||||||
|
In `figure.hpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
explicit Figure(u32 rows);
|
||||||
|
explicit Figure(std::span<const u32> cells_per_row);
|
||||||
|
|
||||||
|
u32 row_count() const; // figure rows
|
||||||
|
u32 plot_count() const; // flattened cells
|
||||||
|
u32 cells_in_row(u32 row) const;
|
||||||
|
u32 first_plot_of_row(u32 row) const; // plot_count() for row == row_count()
|
||||||
|
u32 row_of_plot(u32 plot) const;
|
||||||
|
|
||||||
|
Status set_cell_weights(u32 row, std::span<const f32> weights);
|
||||||
|
void clear_cell_weights(u32 row);
|
||||||
|
bool has_cell_weights(u32 row) const;
|
||||||
|
void set_col_gap(f32 px);
|
||||||
|
f32 col_gap() const;
|
||||||
|
```
|
||||||
|
|
||||||
|
Members: `std::vector<u32> cells_per_row_; std::vector<u32> row_first_; // prefix sums, rows+1 entries; std::vector<std::vector<f32>> cell_weights_; f32 col_gap_ = 0.0f; void build_cells(std::span<const u32>);`. In `FigureLayout` add `std::vector<Rect> cells; f32 col_gap = 0.0f;`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement the constructors and accessors**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
Figure::Figure(u32 rows)
|
||||||
|
{
|
||||||
|
const std::vector<u32> ones(rows, 1u);
|
||||||
|
build_cells(std::span<const u32>(ones));
|
||||||
|
}
|
||||||
|
|
||||||
|
Figure::Figure(std::span<const u32> cells_per_row) { build_cells(cells_per_row); }
|
||||||
|
|
||||||
|
void Figure::build_cells(std::span<const u32> cells)
|
||||||
|
{
|
||||||
|
cells_per_row_.assign(cells.begin(), cells.end());
|
||||||
|
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
|
||||||
|
row_first_.assign(cells_per_row_.size() + 1, 0u);
|
||||||
|
for (std::size_t r = 0; r < cells_per_row_.size(); ++r)
|
||||||
|
row_first_[r + 1] = row_first_[r] + cells_per_row_[r];
|
||||||
|
plots_ = std::vector<Plot>(row_first_.back());
|
||||||
|
cell_weights_.assign(cells_per_row_.size(), {});
|
||||||
|
// keep whatever the old Figure(u32) constructor initialised besides plots_ (row_weights_ etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::row_count() const { return static_cast<u32>(cells_per_row_.size()); }
|
||||||
|
u32 Figure::plot_count() const { return static_cast<u32>(plots_.size()); }
|
||||||
|
u32 Figure::cells_in_row(u32 row) const { return row < row_count() ? cells_per_row_[row] : 0u; }
|
||||||
|
u32 Figure::first_plot_of_row(u32 row) const { return row <= row_count() ? row_first_[row] : plot_count(); }
|
||||||
|
u32 Figure::row_of_plot(u32 plot) const
|
||||||
|
{
|
||||||
|
u32 r = 0;
|
||||||
|
while (r + 1 < row_count() && row_first_[r + 1] <= plot) ++r;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::set_cell_weights(u32 row, std::span<const f32> w)
|
||||||
|
{
|
||||||
|
if (row >= row_count() || w.size() != cells_per_row_[row]) return Status::invalid_argument();
|
||||||
|
for (f32 v : w) if (!(v > 0.0f)) return Status::invalid_argument();
|
||||||
|
cell_weights_[row].assign(w.begin(), w.end());
|
||||||
|
return Status::ok();
|
||||||
|
}
|
||||||
|
void Figure::clear_cell_weights(u32 row) { if (row < row_count()) cell_weights_[row].clear(); }
|
||||||
|
bool Figure::has_cell_weights(u32 row) const { return row < row_count() && !cell_weights_[row].empty(); }
|
||||||
|
void Figure::set_col_gap(f32 px) { col_gap_ = std::max(0.0f, px); }
|
||||||
|
f32 Figure::col_gap() const { return col_gap_; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the `Status` constructors the file already uses for `set_row_weights` failures (copy that spelling). Remove any other `plots_` initialisation from the old constructor and make `set_row_weights` validate against `row_count()` (it already did if it used `row_count()`; verify it did not use `plots_.size()`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Implement `place_cells` and wire it into `layout()`**
|
||||||
|
|
||||||
|
In an anonymous namespace of `figure.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
constexpr f32 kMinCellWidthLogical = 64.0f;
|
||||||
|
|
||||||
|
// Splits the row rect `r` into n cells. Returns the gap actually used.
|
||||||
|
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap, f32 ui_scale,
|
||||||
|
Rect* out)
|
||||||
|
{
|
||||||
|
if (n == 1) { out[0] = r; return col_gap; }
|
||||||
|
f32 gap = col_gap;
|
||||||
|
f32 usable = r.w - gap * static_cast<f32>(n - 1);
|
||||||
|
if (usable <= 0.0f) { gap = 0.0f; usable = r.w; }
|
||||||
|
usable = std::max(0.0f, usable);
|
||||||
|
const f32 floor_px = std::min(kMinCellWidthLogical * ui_scale, usable / static_cast<f32>(n));
|
||||||
|
const f32 extra = usable - floor_px * static_cast<f32>(n);
|
||||||
|
f32 total = 0.0f;
|
||||||
|
if (weights.size() == n) for (f32 w : weights) total += w;
|
||||||
|
f32 x = r.x;
|
||||||
|
f32 consumed = 0.0f;
|
||||||
|
for (u32 i = 0; i < n; ++i) {
|
||||||
|
const f32 share = (weights.size() == n && total > 0.0f) ? weights[i] / total
|
||||||
|
: 1.0f / static_cast<f32>(n);
|
||||||
|
const f32 end = (i + 1 == n) ? usable : consumed + floor_px + extra * share;
|
||||||
|
out[i] = Rect{x, r.y, end - consumed, r.h};
|
||||||
|
consumed = end;
|
||||||
|
x = out[i].right() + gap;
|
||||||
|
}
|
||||||
|
return gap;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `Figure::layout()`, after the row rects (`layout_.rows`) are computed and before viewports are set: size `layout_.cells` to `plot_count()`, call `place_cells` per figure row (pass `&layout_.cells[row_first_[r]]`), track `layout_.col_gap = (any row with n>1 and returned gap < col_gap_) ? 0 : col_gap_` (set `col_gap` to `col_gap_` otherwise), and set each plot's viewport to `layout_.cells[i]`. The strip rule becomes: per plot `i`, `XStrip::None` when `labels_bottom_only_` and `row_of_plot(i) + 1 != row_count()`, else the existing strip. Every loop that ran over rows for `set_right_gutter`, `sync_x_links()`, the two gutter passes and crosshair now runs over `plots_` (flattened). Keep `layout_.x_axis_strip = plots_.back().layout().x_label_area`. The ui_scale reference stays `plots_[0].ui_scale()`.
|
||||||
|
|
||||||
|
In `renderer.cpp` ~474 replace `const u32 rows = figure.row_count();` with `figure.plot_count()`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build --target plot_tests && deps/plotter/build/plot_tests 2>&1 | tail -15`
|
||||||
|
Expected: all tests pass, including the pre-existing `figure_*` tests.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit in the submodule**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: rows can hold several plots (cells), cell weights and column gap"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Adapters route pointers by cell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/adapters/gtk4/gtk_plot_area.cpp/.hpp`, `deps/plotter/adapters/glfw/glfw_host.cpp/.hpp`
|
||||||
|
- Test: `deps/plotter/tests/test_glfw_host.cpp`
|
||||||
|
- Modify (main repo): `deps/plotter` pin
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Figure::plot_count()`, `FigureLayout::cells`.
|
||||||
|
- Produces: `GtkPlotArea::row_at(x, y)` returns the flattened plot index under (x, y) (nearest cell when outside all); GLFW `Host::controller_at(x, y)` is x-aware. `row_controller(i)` indexes flattened plots.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing GLFW routing test**
|
||||||
|
|
||||||
|
Add `#include <span>` and, in `tests/test_glfw_host.cpp`, next to the existing `Routed(rows, gap)` fixture test:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PLOT_TEST(glfw_host_routes_by_cell)
|
||||||
|
{
|
||||||
|
std::vector<plot::u32> cells{1, 2};
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)};
|
||||||
|
figure.set_viewport(plot::Rect{0, 0, 300, 600});
|
||||||
|
figure.set_col_gap(6.0f);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); }
|
||||||
|
figure.layout();
|
||||||
|
plot::glfw::Host host;
|
||||||
|
host.set_figure(&figure);
|
||||||
|
const auto& c = figure.layout_info().cells;
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
|
||||||
|
PLOT_CHECK(&host.controller_at(cx, cy) == host.row_controller(i));
|
||||||
|
}
|
||||||
|
// in the gap: nearest cell wins
|
||||||
|
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0, c[1].y + 5.0) == host.row_controller(1));
|
||||||
|
PLOT_CHECK(&host.controller_at(c[2].x - 1.0, c[2].y + 5.0) == host.row_controller(2));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mirror the existing fixture for how `controller_at` / `row_controller` are called (names may differ slightly; keep the three assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build --target test_glfw_host 2>&1 | tail` then run the binary.
|
||||||
|
Expected: FAIL (the cell-2 centre routes to cell 1, or build error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: GLFW host**
|
||||||
|
|
||||||
|
In `glfw_host.cpp`: `set_figure` uses `plot_count()` for the controllers; `refresh_row_rects` copies `layout().cells`; replace `row_in(rows, y)` with
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static int row_in(const std::vector<Rect>& rects, double x, double y)
|
||||||
|
{
|
||||||
|
int best = -1;
|
||||||
|
double best_d = 0.0;
|
||||||
|
for (std::size_t i = 0; i < rects.size(); ++i) {
|
||||||
|
const Rect& r = rects[i];
|
||||||
|
const double dx = x < r.x ? r.x - x : (x > r.right() ? x - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? r.y - y : (y > r.bottom() ? y - r.bottom() : 0.0);
|
||||||
|
const double d = dx * dx + dy * dy;
|
||||||
|
if (best < 0 || d < best_d) { best = static_cast<int>(i); best_d = d; }
|
||||||
|
if (d == 0.0) break;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use `r.y + r.h` / `r.x + r.w` if `Rect` has no `bottom()`.) For unsplit figures this is equivalent to the old y-only nearest-row rule. Make `controller_at(x, y)`, `pointer_target(x, y, ...)` and `pointer_row(rows)` pass `x` (use `last_pointer_x_` in `pointer_row`) and drop the `(void)x`. `sync_figure_crosshair` loops over the plot count. Update the header comments.
|
||||||
|
|
||||||
|
- [ ] **Step 4: GTK adapter**
|
||||||
|
|
||||||
|
In `gtk_plot_area.cpp`: `set_figure` uses `plot_count()` (line ~248); `refresh_row_rects` caches `.layout().cells`; add the same x-aware `row_in(rows, x, y)`; change `controller_at`, `row_at(x, y)` (use `to_device(x, y)`), `target_controller(f32 x, f32 y)`, `pointer_row()` (`pointer_.x/.y`) and `press_row_` (set with `row_in(row_rects_, p.x, p.y)` at ~619). Update every call site of `target_controller` (~637 `p`, ~646 `position`, ~751 `sample.position`, ~798 `g`, ~837 `at`, ~861 `p`) to pass both coordinates. Update the header declarations.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build and run plotter tests**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build 2>&1 | tail -5 && for t in deps/plotter/build/test_*; do $t >/dev/null || echo FAIL $t; done`
|
||||||
|
Expected: no FAIL lines.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the submodule, then the pin; check the app still builds**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C deps/plotter add -u && git -C deps/plotter commit -m "adapters: route pointer events by cell rect"
|
||||||
|
git add deps/plotter && git commit -m "deps/plotter: figure rows can hold several plots"
|
||||||
|
cmake --build build 2>&1 | grep -c warning
|
||||||
|
```
|
||||||
|
Expected: the build succeeds with the single baseline warning (count as before the change; compare with `git stash`-free run of an untouched clean build if unsure).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `RowSet` cell state and the split probe scaffold
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/plot_host.hpp`, `src/plot_host.cpp` (`rebuild_all` ~309, `set_row_count` ~156, `carry_row` ~164, `clear_row` ~184, `insert_row` ~195, `remove_row` ~205, `apply_row_weights` ~303, `x_note` caption ~920)
|
||||||
|
- Modify: `src/ui_app.hpp` (`dbg_split_step`, declarations near ~2093), `src/ui_probe.cpp` (new `split_probe_*`), `src/ui.cpp` (scheduling next to `UDPSCOPE_PROBE_LAYOUT_MS`, ~6003)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces in `RowSet`:
|
||||||
|
- `static constexpr int kMaxCellsPerRow = 4;`
|
||||||
|
- `int figure_row_count() const; int cells_in_row(int fr) const; int first_plot_of_row(int fr) const; int figure_row_of(int plot) const /* -1 if invalid */; int cell_of(int plot) const; const std::vector<float>& cell_weights(int fr) const;`
|
||||||
|
- `std::string plot_label(int plot) const` ("2" for a single-cell row, "2a".."2d" for a split row; the figure row is `fr + 1`)
|
||||||
|
- `bool insert_cell(int fr, int at)`, `bool remove_cell(int plot)`, `void set_cell_weights(int fr, std::vector<float> w)`, `void set_cells_per_row(std::vector<int> cells)`
|
||||||
|
- `insert_row(at)` / `remove_row(at)` act on figure rows; `set_row_count(n)` means n single-cell rows; `row_count()` stays the plot count.
|
||||||
|
- Invariant: `sum(cells_per_row_) == rows_` (rows_ ≤ kMaxRows = 8).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `plot_host.hpp` 35–215, 280–320 and `plot_host.cpp` 135–345, 905–950 to see `carry_row`, `clear_row`, `storage_`, `rebuild_all`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Header additions**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static constexpr int kMaxCellsPerRow = 4;
|
||||||
|
|
||||||
|
int figure_row_count() const { return static_cast<int>(cells_per_row_.size()); }
|
||||||
|
int cells_in_row(int fr) const;
|
||||||
|
int first_plot_of_row(int fr) const; // rows_ for fr == figure_row_count()
|
||||||
|
int figure_row_of(int plot) const; // -1 if plot is out of range
|
||||||
|
int cell_of(int plot) const; // 0-based position inside its row, -1 if invalid
|
||||||
|
const std::vector<float>& cell_weights(int fr) const;
|
||||||
|
std::string plot_label(int plot) const;
|
||||||
|
|
||||||
|
bool insert_cell(int fr, int at); // false at kMaxCellsPerRow cells or kMaxRows plots
|
||||||
|
bool remove_cell(int plot); // a single-cell row removes the row; false if it would leave no plot
|
||||||
|
void set_cell_weights(int fr, std::vector<float> w);
|
||||||
|
void set_cells_per_row(std::vector<int> cells);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<int> cells_per_row_{1};
|
||||||
|
std::vector<std::vector<float>> cell_weights_ = std::vector<std::vector<float>>(1);
|
||||||
|
void apply_cell_weights();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement queries and labels** in `plot_host.cpp`
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
int RowSet::cells_in_row(int fr) const
|
||||||
|
{
|
||||||
|
return fr >= 0 && fr < figure_row_count() ? cells_per_row_[fr] : 0;
|
||||||
|
}
|
||||||
|
int RowSet::first_plot_of_row(int fr) const
|
||||||
|
{
|
||||||
|
int p = 0;
|
||||||
|
for (int r = 0; r < fr && r < figure_row_count(); ++r) p += cells_per_row_[r];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
int RowSet::figure_row_of(int plot) const
|
||||||
|
{
|
||||||
|
if (plot < 0 || plot >= rows_) return -1;
|
||||||
|
int p = 0;
|
||||||
|
for (int r = 0; r < figure_row_count(); ++r) {
|
||||||
|
p += cells_per_row_[r];
|
||||||
|
if (plot < p) return r;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int RowSet::cell_of(int plot) const
|
||||||
|
{
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
return fr < 0 ? -1 : plot - first_plot_of_row(fr);
|
||||||
|
}
|
||||||
|
const std::vector<float>& RowSet::cell_weights(int fr) const
|
||||||
|
{
|
||||||
|
static const std::vector<float> none;
|
||||||
|
return fr >= 0 && fr < static_cast<int>(cell_weights_.size()) ? cell_weights_[fr] : none;
|
||||||
|
}
|
||||||
|
std::string RowSet::plot_label(int plot) const
|
||||||
|
{
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
if (fr < 0) return std::to_string(plot + 1);
|
||||||
|
if (cells_per_row_[fr] == 1) return std::to_string(fr + 1);
|
||||||
|
return std::to_string(fr + 1) + static_cast<char>('a' + cell_of(plot));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Figure construction in `rebuild_all`**
|
||||||
|
|
||||||
|
Replace the `std::make_unique<plot::Figure>(rows_)` with
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::vector<plot::u32> cells(cells_per_row_.begin(), cells_per_row_.end());
|
||||||
|
figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(cells));
|
||||||
|
```
|
||||||
|
|
||||||
|
and after `set_row_gap(kRowGap)` add `figure_->set_col_gap(kColGap);` (`constexpr float kColGap = 6.0f;` beside `kRowGap`), then call `apply_row_weights(); apply_cell_weights();`. The per-plot loop stays per plot index (it already iterates `rows_`).
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void RowSet::apply_cell_weights()
|
||||||
|
{
|
||||||
|
if (!figure_) return;
|
||||||
|
cell_weights_.resize(cells_per_row_.size());
|
||||||
|
for (int fr = 0; fr < figure_row_count(); ++fr) {
|
||||||
|
auto& w = cell_weights_[fr];
|
||||||
|
if (cells_per_row_[fr] < 2) { w.clear(); continue; }
|
||||||
|
if (w.empty()) { figure_->clear_cell_weights(fr); continue; }
|
||||||
|
w.resize(cells_per_row_[fr], 1.0f);
|
||||||
|
figure_->set_cell_weights(fr, std::span<const float>(w));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`apply_row_weights` resizes to `figure_row_count()` instead of `rows_`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Mutators**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool RowSet::insert_cell(int fr, int at)
|
||||||
|
{
|
||||||
|
if (fr < 0 || fr >= figure_row_count()) return false;
|
||||||
|
if (cells_per_row_[fr] >= kMaxCellsPerRow || rows_ >= kMaxRows) return false;
|
||||||
|
const int n = cells_per_row_[fr];
|
||||||
|
const int p = first_plot_of_row(fr) + std::clamp(at, 0, n);
|
||||||
|
for (int i = rows_; i > p; --i) carry_row(i, i - 1); // carry_row(dst, src), as insert_row uses it
|
||||||
|
clear_row(p);
|
||||||
|
++rows_;
|
||||||
|
++cells_per_row_[fr];
|
||||||
|
auto& w = cell_weights_[fr];
|
||||||
|
if (!w.empty()) {
|
||||||
|
float mean = 0.0f;
|
||||||
|
for (float v : w) mean += v;
|
||||||
|
mean /= static_cast<float>(w.size());
|
||||||
|
w.insert(w.begin() + std::clamp(at, 0, n), mean);
|
||||||
|
}
|
||||||
|
rebuild_all();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RowSet::remove_cell(int plot)
|
||||||
|
{
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
if (fr < 0) return false;
|
||||||
|
if (cells_per_row_[fr] == 1) {
|
||||||
|
if (figure_row_count() == 1) return false;
|
||||||
|
remove_row(fr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (int i = plot; i + 1 < rows_; ++i) carry_row(i, i + 1);
|
||||||
|
--rows_;
|
||||||
|
clear_row(rows_);
|
||||||
|
const int k = plot - first_plot_of_row(fr);
|
||||||
|
--cells_per_row_[fr];
|
||||||
|
auto& w = cell_weights_[fr];
|
||||||
|
if (cells_per_row_[fr] < 2) w.clear();
|
||||||
|
else if (k < static_cast<int>(w.size())) w.erase(w.begin() + k);
|
||||||
|
rebuild_all();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_cell_weights(int fr, std::vector<float> w)
|
||||||
|
{
|
||||||
|
if (fr < 0 || fr >= figure_row_count()) return;
|
||||||
|
cell_weights_[fr] = std::move(w);
|
||||||
|
if (figure_) apply_cell_weights();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_cells_per_row(std::vector<int> cells)
|
||||||
|
{
|
||||||
|
std::vector<int> next;
|
||||||
|
int total = 0;
|
||||||
|
for (int c : cells) {
|
||||||
|
c = std::clamp(c, 1, kMaxCellsPerRow);
|
||||||
|
if (total + c > kMaxRows) c = kMaxRows - total;
|
||||||
|
if (c < 1) break;
|
||||||
|
next.push_back(c);
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
if (next.empty()) next.push_back(1), total = 1;
|
||||||
|
cells_per_row_ = std::move(next);
|
||||||
|
cell_weights_.assign(cells_per_row_.size(), {});
|
||||||
|
rows_ = total;
|
||||||
|
rebuild_all();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check against the real `carry_row` signature (read in Step 1) and keep its argument order; if `insert_row` uses a different shifting idiom, copy it. `insert_row(at)` / `remove_row(at)`: `at` is a figure row; the plot position is `first_plot_of_row(at)`; insert/erase the matching `cells_per_row_` entry (one cell for an inserted row) and `cell_weights_` entry; `remove_row` shifts storage down by the removed row's cell count `n` and decrements `rows_` by `n`. `insert_row` refuses when `rows_ >= kMaxRows`. `set_row_count(n)`: sets all cells to one per row (`cells_per_row_.assign(n, 1)`, `cell_weights_.assign(n, {})`) and early-returns only when `n == rows_` and every row already has one cell.
|
||||||
|
|
||||||
|
- [ ] **Step 6: The `x_note` caption** (~line 920): replace `const bool bottom = i == rows_ - 1;` with `const bool bottom = i >= first_plot_of_row(figure_row_count() - 1);`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Probe scaffold**
|
||||||
|
|
||||||
|
In `ui_app.hpp` add `int dbg_split_step = 0;` beside `dbg_layout_step` and declarations beside the layout probe's: `void split_probe_report(App& app, const char* tag); void split_probe_next(App& app); gboolean split_probe_step(gpointer data);`. In `ui_probe.cpp` (SPDX header already in the file) add, modelled on `layout_probe_*` (read `ui_probe.cpp` 3386–3471 and 3751–3890 first, reuse `enable_all_probe`'s channel assignment loop):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void split_probe_report(App& app, const char* tag)
|
||||||
|
{
|
||||||
|
RowSet& rs = *app.rows;
|
||||||
|
const auto& fl = rs.figure().layout();
|
||||||
|
int tile = 1;
|
||||||
|
for (int fr = 0; fr < rs.figure_row_count(); ++fr) {
|
||||||
|
const int first = rs.first_plot_of_row(fr), n = rs.cells_in_row(fr);
|
||||||
|
const auto& row = fl.rows[fr];
|
||||||
|
if (std::fabs(fl.cells[first].x - row.x) > 0.5f) tile = 0;
|
||||||
|
if (std::fabs(fl.cells[first + n - 1].right() - row.right()) > 0.5f) tile = 0;
|
||||||
|
for (int k = 0; k < n; ++k) {
|
||||||
|
const auto& c = fl.cells[first + k];
|
||||||
|
if (std::fabs(c.y - row.y) > 0.5f || std::fabs(c.h - row.h) > 0.5f) tile = 0;
|
||||||
|
if (k > 0 && std::fabs((c.x - fl.cells[first + k - 1].right()) - fl.col_gap) > 0.5f) tile = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// x_equal: every plot's x range equals plot 0's; cursors_aligned: every plot's cursor a/b x equal
|
||||||
|
int x_equal = 1, aligned = 1;
|
||||||
|
const auto r0 = rs.plot(0)->x_range(rs.axis_x(0));
|
||||||
|
for (int p = 1; p < rs.row_count(); ++p) {
|
||||||
|
const auto r = rs.plot(p)->x_range(rs.axis_x(p));
|
||||||
|
const double span = std::max(1e-12, std::fabs(r0.hi - r0.lo));
|
||||||
|
if (std::fabs(r.lo - r0.lo) > 1e-9 * span || std::fabs(r.hi - r0.hi) > 1e-9 * span) x_equal = 0;
|
||||||
|
if (std::fabs(rs.cursor_x(p, 0) - rs.cursor_x(0, 0)) > 1e-9 * span) aligned = 0;
|
||||||
|
if (std::fabs(rs.cursor_x(p, 1) - rs.cursor_x(0, 1)) > 1e-9 * span) aligned = 0;
|
||||||
|
}
|
||||||
|
// print: [split-probe] %-14s plots=%d frows=%d cells=[a,b] widths=[logical px, | between rows] tile=%d x_equal=%d cursors_aligned=%d weights=[...] traces=[P:name,...|...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The accessor names `plot(p)`, `axis_x(p)` and `cursor_x(p, which)` are placeholders for the host's real accessors for the plot object, its x axis index and cursor A/B x (the layout probe and `RowSet::cursor_a_x()` show them: `r.plot->cursor(r.cursor_a).x`, `r.plot->x_range(r.axis_x)`); use those, adding a tiny `RowSet::cursor_x(int plot, int which)` if none exists. Fill in the print line with `g_print`/`std::printf` in the style of `layout_probe_report`, with widths in logical px (`cell.w / scale`), weights from `rs.cell_weights(fr)` and traces as `P:name` from the slot names the layout probe already prints.
|
||||||
|
|
||||||
|
`split_probe_step` follows `layout_probe_step` (200 ms) but with 250 ms steps; after any structural change call `app.area->set_figure(&app.rows->figure()); app.relayout_pending = true;` and let a frame run before the report. Steps:
|
||||||
|
0. `rows->set_cells_per_row({1}); ` free the slots; enable all signals and assign channels via the `enable_all_probe` loop; report `"start"` (expect `plots=1 frows=1 cells=[1]`).
|
||||||
|
1. `insert_cell(0, 1)`; assign channel 0 to plot 0 and channel 1 to plot 1; `set_cursors_visible(true)`; report `"split"` (expect `cells=[2] tile=1 x_equal=1 cursors_aligned=1`).
|
||||||
|
2. `set_cell_weights(0, {3, 1})`; report `"weights 3:1"` (widths ratio about 3:1 after the floors; `tile=1`).
|
||||||
|
3. `insert_row(1)` then `insert_cell(1, 1)`; report `"row+cell"` (expect `cells=[2,2] plots=4`).
|
||||||
|
4. Call `insert_cell` until it returns false and then `insert_row` until the plot total is 8; report `"caps"` printing the refusals (`insert_cell(0, 2)` true, `…` false at 4 cells; `insert_row` false at 8 plots). Keep these as separate printed `refused=` fields.
|
||||||
|
5. `remove_cell` on the last plot of the widest row, report `"remove"`; then finish by returning `G_SOURCE_REMOVE`.
|
||||||
|
|
||||||
|
In `ui.cpp` next to `UDPSCOPE_PROBE_LAYOUT_MS` (~6003) add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (const char* ms = g_getenv("UDPSCOPE_PROBE_SPLIT_MS"))
|
||||||
|
g_timeout_add(static_cast<guint>(std::atoi(ms)), split_probe_step, app);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use the exact `g_timeout_add` argument shape the layout probe uses.)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Build and run the probe**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build build 2>&1 | grep -E 'warning|error' | head
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8171 --rate 1000 --batch 20 & MOCK=$!; sleep 2
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_SPLIT_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8171 2>&1 | grep -E 'split-probe|gtk warnings|Gtk-CRITICAL'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
Expected: the six `[split-probe]` lines above with `tile=1 x_equal=1 cursors_aligned=1`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Also confirm that the first build output still has exactly one warning.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/plot_host.hpp src/plot_host.cpp src/ui_app.hpp src/ui_probe.cpp src/ui.cpp
|
||||||
|
git commit -m "plot host: rows hold 1..4 cells; split probe scaffold"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Session file and probe round-trip
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/layout_store.hpp` (`LayoutState::Row`), `src/layout_store.cpp` (writer ~786, `plots = N` ~729, parser `[[plot]]` ~457 and keys ~605–614, fallback ~718), `src/ui_layout.cpp` (`collect_layout` 17–36, `apply_layout` ~445–520)
|
||||||
|
- Modify: `src/ui_probe.cpp` (new probe steps)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `RowSet::cells_in_row`, `figure_row_of`, `cell_weights`, `set_cells_per_row`, `set_cell_weights`, `set_row_weights`.
|
||||||
|
- Produces: `LayoutState::Row { …; int row = -1; double width = 1.0; }`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Format**
|
||||||
|
|
||||||
|
`LayoutState::Row` gains `int row = -1; // figure row; -1 = file had none` and `double width = 1.0; // cell weight`. The writer emits, per `[[plot]]`: `row = <fr>`, `width = <w>`, then the existing `weight` (the figure row's weight, written on every cell) and `signals`. Parser: keys `row` (int ≥ 0) and `width` (double > 0) next to `weight`. A file where no `[[plot]]` has `row` leaves every `row == -1`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `collect_layout`** — for each plot `p`: `fr = rs.figure_row_of(p)`, `row.row = fr`, `row.weight = row_weights()[fr]`, `row.width = cell_weights(fr)` entry `cell_of(p)` or 1.0 when the row has no weights.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `apply_layout`** — before `set_row_count`: if every `Row.row >= 0`, validate that rows are non-decreasing, contiguous from 0 (each step 0 or +1), at most `kMaxCellsPerRow` cells per row and at most `kMaxRows` plots, otherwise fall back to the one-cell-per-row path. Valid: build `cells_per_row` counts and call `rows->set_cells_per_row(counts)`; row weights come from the first cell of each figure row (`set_row_weights`); for rows with ≥ 2 cells call `set_cell_weights(fr, {widths…})`. Fallback (no `row`, or invalid): `set_row_count(min(n, kMaxRows))` as today. The per-plot signal assignment loop is unchanged (it is per flattened plot index).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Probe steps** — append to `split_probe_step`:
|
||||||
|
6. Build `{1,2}` with weights `{3,1}` and signals in each cell, `collect_layout` → `save_layout(tmp)` (use a path under `g_get_tmp_dir()` and delete it afterwards) → set to `{1}` → `load_layout` → `apply_layout`; report `"roundtrip"` and add `rt=1/0` comparing cells, widths (tolerance 1e-6) and traces.
|
||||||
|
7. Write a copy of that file with all `row = …` lines stripped, load/apply it, report `"legacy"` (expect `cells=[1,1,1]`, or the plot count of the file, one cell per row).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the probe** (same recipe as Task 3 Step 8). Expected: `roundtrip rt=1`, `legacy` with one cell per row, `gtk warnings: 0`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/layout_store.hpp src/layout_store.cpp src/ui_layout.cpp src/ui_probe.cpp
|
||||||
|
git commit -m "session: plots carry row and width; files without row load one cell per row"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Layout editor cell controls and column drag
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp` (layout editor 568–807), `src/ui_layout.cpp` (534–598 handlers), `src/ui_app.hpp` (constants ~1600–1607, new `App` fields), `src/ui_probe.cpp` (`layout_probe_*` re-baseline, new split-probe click steps)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 3 `RowSet` API, Task 2 `FigureLayout.cells`.
|
||||||
|
- Produces: `void layout_col_apply(App&, int plot, double offset_px)`, `GtkWidget* layout_cell_button(App&, int fr, int pos)`, `GtkWidget* layout_col_handle(App&, int plot)`; `App::layout_col_drag_plot`, `App::layout_col_drag_left0` / `layout_col_drag_right0` (start widths, logical px); constants `kLayoutCellKey = "udpscope-layout-cell"`, `kLayoutColKey = "udpscope-layout-col"`, `kMinCellPx = 96`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `ui.cpp` 568–807 (overlay build, `sync_layout_editor`, `layout_overlay_button`), `ui_layout.cpp` 534–598 and the drag handlers, `ui_app.hpp` ~1600–1607.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Row-keyed controls by figure row.** The row boundary `+` buttons and the row resize handles are keyed by **figure row** (`figure_row_count() + 1` boundaries, positioned from `fl.rows`). `on_layout_row_insert(boundary)` calls `rows->insert_row(boundary)`. The per-cell `×` stays keyed by plot index (existing `set_row_key`), positioned from `fl.cells[idx]` top-left, and its handler calls `rows->remove_cell(idx)`; it is insensitive when `remove_cell` would fail (`row_count() == 1`). After each handler: `app->area->set_figure(&app->rows->figure())`, `rebuild_layout_editor`, `rebuild_signal_panels`, `schedule_layout_save` (same tail as the existing handlers). The row `+` is insensitive at `row_count() >= RowSet::kMaxRows`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Cell `+` buttons.** For each figure row `fr` with `n = cells_in_row(fr)`, create `n + 1` buttons via `layout_overlay_button(app, "list-add-symbolic", tooltip)` with `g_object_set_data(btn, kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1))` (do **not** call `set_row_key`). Positions: `pos 0` at `cells[first].x/scale + kLayoutButtonPad`; internal `pos k` centred on the gap between cells `k-1` and `k`; `pos n` at `cells[last].right()/scale - kLayoutButtonPx - kLayoutButtonPad`; vertically centred in `fl.rows[fr]`. Clicking calls `rows->insert_cell(fr, pos)` then the same tail as Step 2. Sensitivity: `cells_in_row(fr) < kMaxCellsPerRow && row_count() < kMaxRows`. Convert with `/scale` as the existing code does in `sync_layout_editor`, and re-place them in `sync_layout_editor` every frame.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Column resize handles.** At each internal boundary `k` (1..n-1) of a split row create a drag handle with `kLayoutColKey` = plot index of the right-hand cell (`first + k`), placed just right of the cell `+` there, with a left-right arrow icon (`go-next-symbolic` is acceptable, use whatever the row handle's icon pair provides for the horizontal direction), copying the row handle's `GtkGestureDrag` ("udpscope-layout-drag" pattern with its own begin/update/end handlers). Handlers:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void layout_col_apply(App& app, int plot, double offset_px)
|
||||||
|
{
|
||||||
|
RowSet& rs = *app.rows;
|
||||||
|
const int fr = rs.figure_row_of(plot);
|
||||||
|
const int k = rs.cell_of(plot);
|
||||||
|
if (fr < 0 || k < 1) return;
|
||||||
|
const double scale = gtk_widget_get_scale_factor(app.window) > 0 ? ui_scale_of(app) : 1.0; // use the helper sync_layout_editor uses
|
||||||
|
const double pair = app.layout_col_drag_left0 + app.layout_col_drag_right0;
|
||||||
|
const double want_left = std::clamp(app.layout_col_drag_left0 + offset_px / scale, double(kMinCellPx), pair - kMinCellPx);
|
||||||
|
std::vector<float> w = rs.cell_weights(fr);
|
||||||
|
w.resize(rs.cells_in_row(fr), 1.0f);
|
||||||
|
const float sum = w[k - 1] + w[k];
|
||||||
|
w[k - 1] = static_cast<float>(sum * want_left / pair);
|
||||||
|
w[k] = sum - w[k - 1];
|
||||||
|
rs.set_cell_weights(fr, w);
|
||||||
|
app.area->queue_draw(); // same redraw call the row drag uses
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`begin` records `layout_col_drag_plot = plot` and the logical widths `cells[plot-1].w/scale` and `cells[plot].w/scale`, and also snapshots `rs.cell_weights(fr)` (resized with 1.0) so repeated `update` calls are relative to drag start, as the row drag is. If the pair is narrower than `2 * kMinCellPx`, make the clamp degenerate to `pair / 2` rather than inverted bounds. `end` calls `schedule_layout_save(app)`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Tooltips and sensitivity wording.** Cell `+`: "Split here"; column handle: "Drag to resize the plots"; `×` in a multi-cell row: "Remove this plot"; one-cell row: unchanged ("Remove this row").
|
||||||
|
|
||||||
|
- [ ] **Step 6: Helpers for the probe.** `layout_cell_button(App&, fr, pos)` and `layout_col_handle(App&, plot)` find the widget by walking the overlay children and matching `kLayoutCellKey` / `kLayoutColKey` data (as `layout_button` does for `row_key`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Probe steps.** Append to `split_probe_step`: with the layout editor open (reuse how `layout_probe_step` opens it), 8. click `layout_cell_button(app, 0, 1)` via `layout_click` → expect `cells=[2]`; 9. call `layout_col_apply` through the real handle's begin/update/end (or `layout_col_apply(app, 1, 80.0)` after setting the start widths as the begin handler does) → widths shift by 80 logical px and neither cell is below 96; a drag of ±10000 px clamps both to ≥ 96; 10. click the `×` of the new cell → `cells=[1]`; 11. report the sensitivity of the cell `+` at 4 cells in the row (`cell_plus_sensitive=0`) and of the row `+` at 8 plots.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Re-baseline the layout probe.** Run the existing `UDPSCOPE_PROBE_LAYOUT_MS` probe on the previous commit and on this one (fresh `XDG_CONFIG_HOME` per side) and diff number-normalised (`sed 's/[0-9.]\+/N/g'`). The only expected difference is the button count: `+2` per figure row (the two edge `+`s). Update the expected count in `docs/claude/probe-readings.md` in Task 8; do not change anything else in the baseline.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Build, run both probes** (`UDPSCOPE_PROBE_LAYOUT_MS` and `UDPSCOPE_PROBE_SPLIT_MS`, serially). Expected: split-probe lines as designed, `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui.cpp src/ui_layout.cpp src/ui_app.hpp src/ui_probe.cpp
|
||||||
|
git commit -m "layout editor: split a row into cells, resize cells by drag"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Signals page, notices and drop target
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_signal_page.cpp` (~260–285), `src/ui_signals.cpp` (~557, ~570, ~579), `src/ui.cpp` (~325, ~1680, ~2762, ~2766, ~2942)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `RowSet::plot_label(int)`, x-aware `area->row_at(x, y)` from Task 2.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `ui_signal_page.cpp` "Plot on" buttons keep their `udpscope-row` data (plot index) and the `assign_signal_to_row` callback but their label becomes `rows->plot_label(r)` and, for split rows, the tooltip "row 2, cell 1 of 2" (`"row %d, cell %d of %d"` with `figure_row_of + 1`, `cell_of + 1`, `cells_in_row`). This amends the spec's "row 2, left" wording to a unified numeric form; a single-cell row keeps its existing tooltip.
|
||||||
|
- [ ] **Step 2:** Replace `"row %d"` / `r + 1` notices at `ui_signals.cpp:557`, `:570`, `ui.cpp:1680`, `:2762`, `:2766` with `"row %s"` and `plot_label(i).c_str()`.
|
||||||
|
- [ ] **Step 3:** Confirm `row_at(app, x, y)` (`ui.cpp:325`) forwards `x` to `area->row_at` (it did not need it before) and that the two callers (`ui_signals.cpp:579`, `ui.cpp:2942`) pass the pointer position unchanged. Persistence layers (`sync_layers`) fill free slots of the same plot, with no change.
|
||||||
|
- [ ] **Step 4:** Extend the split probe: after step 3 of Task 3, call `assign_signal_to_row` for a signal onto plot 1 of a split row and report the notice text / `traces=` showing the signal on `1b`. Expected probe line shows `traces=[…|1b:name…]`.
|
||||||
|
- [ ] **Step 5:** Build, run the split probe and `gtk warnings: 0`; commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_signal_page.cpp src/ui_signals.cpp src/ui.cpp src/ui_probe.cpp
|
||||||
|
git commit -m "signals: label plots by row and cell, drop onto a cell"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Regression runs
|
||||||
|
|
||||||
|
**Files:** none (verification); `docs/claude/probe-readings.md` is updated in Task 8 for any feature-explained difference.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Clean build from scratch in a temporary build dir; expect exactly one warning.
|
||||||
|
- [ ] **Step 2:** Serially run the probes listed in `docs/claude/verification.md` that touch layout, cursors, measurements, readouts and the E-1608 source (one mock per run, killed afterwards). For each, run once on the pre-feature commit (`git worktree` or checkout of `HEAD` before Task 1, built separately) and once now with a fresh `XDG_CONFIG_HOME`; diff number-normalised. The only acceptable differences are those the feature explains (layout probe's button count, new `cells`/column-weights lines in layout dumps).
|
||||||
|
- [ ] **Step 3:** Layout dumps and probes that print per-row layout gain a `cells=[…]` list and a `colweights=[…]` line: add those to `layout_probe_report` and to the layout dump in `ui.cpp`, then re-run to confirm the numbers.
|
||||||
|
- [ ] **Step 4:** Run the plotter suite once more (`deps/plotter/build`, all `test_*`).
|
||||||
|
- [ ] **Step 5:** Commit any report-line additions: `git add src/ui_probe.cpp src/ui.cpp && git commit -m "probes: print cells and column weights"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Docs
|
||||||
|
|
||||||
|
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`, `docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `src/ui_context_help.cpp`, `CLAUDE.md` (only if the "figure of up to eight stacked rows" sentence needs "plots").
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write each doc paragraph** (real text, in the style of the neighbouring sections):
|
||||||
|
- `build-and-run.md` (vendored plotter changes list): "`plot::Figure` rows can hold several plots: `Figure(std::span<const u32> cells_per_row)`, `plot_count()`, `cells_in_row()`, `first_plot_of_row()`, `row_of_plot()`, `set_cell_weights()`, `set_col_gap()`, `FigureLayout::cells`/`col_gap`. `Figure(u32 rows)` still means one cell per row. Cell widths use the height scheme with a 64 px (logical) floor; the GTK4 and GLFW adapters route pointer events by cell rect."
|
||||||
|
- `invariants-plot-and-layout.md` (cell rules): plots are indexed flat; `row_count()` is the plot count and `figure_row_count()` the rows; the x link, the crosshair and the vertical cursor pair are shared by all cells; y axes, horizontal rulers and fit are per cell; only the bottom row's cells carry x labels; the left gutter is one maximum for the whole figure; caps 4 cells per row / 8 plots; the editor's `+` and drag rules.
|
||||||
|
- `verification.md` and `probe-readings.md`: `UDPSCOPE_PROBE_SPLIT_MS`, each `[split-probe]` line, the reading it must give (`tile=1 x_equal=1 cursors_aligned=1`, `rt=1`, legacy file → one cell per row), and the new layout-probe button count / the `cells` and `colweights` fields.
|
||||||
|
- `README.md` and `docs/USER-GUIDE.md`: a "Splitting a row" subsection: Layout editor → `+` at a row's left/right edge or between cells; drag the arrow to resize; `×` removes a cell; all cells share one time window; limits; session `row`/`width` keys.
|
||||||
|
- Mallard layout page and `ui_context_help.cpp` layout-editor text: the same user-facing paragraph, in the page's format.
|
||||||
|
- [ ] **Step 2:** Build (the help page is validated at build/install, if the project has a `yelp-check` target run it) and run the context-help probe if `verification.md` lists one.
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md docs/USER-GUIDE.md help docs/claude src/ui_context_help.cpp CLAUDE.md
|
||||||
|
git commit -m "docs: horizontal plot split"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:** shared time window (Task 1 x link test, Task 3 probe `x_equal`); per-row 1..4 cells and caps (Tasks 3, 5); draggable widths saved (Tasks 4, 5); plotter API and layout rules including narrow degradation, bottom-only labels, common gutter (Task 1); hit-testing by cell (Task 2); host model, flattened indices, caps, cursors/crosshair/y axes per cell (Tasks 1, 3); drop onto a cell and "place on row" listing (Task 6, tooltip wording amended to "row 2, cell 1 of 2"); layout editor controls (Task 5); session format and legacy loading (Task 4); verification probes and regression (Tasks 3–5, 7); docs list (Task 8).
|
||||||
|
- **Placeholders:** accessor names that the plan could not confirm without re-reading code (`layout_info()`, `rs.plot(p)`, `axis_x`, `cursor_x`, `carry_row` argument order, `Status` constructors) are flagged where used with how to resolve them from the neighbouring code; no behaviour is left unspecified.
|
||||||
|
- **Type consistency:** `insert_cell(fr, at)`, `remove_cell(plot)`, `set_cell_weights(fr, vector<float>)`, `set_cells_per_row(vector<int>)`, `plot_label(plot)`, `figure_row_count()`, `cells_in_row(fr)`, `first_plot_of_row(fr)`, `figure_row_of(plot)`, `cell_of(plot)` are used with the same signatures in Tasks 3–6; plotter names (`plot_count`, `cells_in_row`, `set_cell_weights(u32, span<const f32>)`, `set_col_gap`, `FigureLayout::cells/col_gap`) match across Tasks 1–3.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Split rows share one y axis -- Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The cells of a split row share one y range and one y axis (labels on the leftmost cell only), as the figure's rows share one x window and one x axis.
|
||||||
|
|
||||||
|
**Architecture:** The vendored plotter's `Figure` gets `set_share_row_y(bool)`: when on, every figure row with more than one cell keeps its cells' y axis 0 in step (range and auto flag) from `Figure::layout()`, and every cell but the first loses its y gutter (`YStrip::None`). The host turns it on in `RowSet::rebuild_all()`, mirrors the ruler pair across a row, and maps the layout editor's divider drag through plot widths.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita, the vendored `deps/plotter` (git submodule; commit inside it first, then the pin in the main repo), headless probes under `xvfb-run`.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-03-split-shared-y-design.md` (amends `2026-11-03-horizontal-split-design.md`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Shared y is always on for split rows, no option; an unsplit row behaves exactly as before. (spec Decisions 1)
|
||||||
|
- Only the leftmost cell of a row has a y gutter and y labels; the choice is structural (column index), never from content. (Decisions 2; "layout must not depend on content", `docs/claude/invariants-plot-and-layout.md`)
|
||||||
|
- `Figure::set_share_row_y` defaults to off so existing figures and every existing test in `deps/plotter/tests` are unchanged.
|
||||||
|
- Cell rects still tile the row exactly (last cell closes the row); no rect is ever negative; `Figure(u32 rows)` and `{1}` rows lay out as today.
|
||||||
|
- The row's y gutter is taken off the row width first; the cells then share the rest by the existing `floor + extra*w/total` rule (floor `kMinCellWidthLogical = 64` x ui_scale), so equal weights give equal plot areas. The first cell's rect is gutter + its plot width.
|
||||||
|
- Rulers: one pair per figure row, drawn at the same y in every cell; a drag in any cell moves them. Measure level marks, readouts, measurements stay per trace / per plot index.
|
||||||
|
- Session file unchanged (y ranges were never persisted).
|
||||||
|
- Every source file carries the SPDX MIT header (existing files already do).
|
||||||
|
- Verification is by the plotter suite and the app's headless probes only. Build: exactly 1 warning (baseline). Probes: `gtk warnings: 0`, no `Gtk-CRITICAL`. Run recipe in `docs/claude/verification.md`; use a fresh throwaway `XDG_CONFIG_HOME` per run (never write `~/.config/udpscope`); run probes short and serial; kill the mock afterwards. The plotter test `staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build` may hang: run the suite under `ulimit -c 0; timeout 300`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `deps/plotter/include/plot/plot/plot.hpp`, `src/plot/plot.cpp` | `YStrip`, `set_y_strip_mode`, `set_y_link_member`, `y_data_bounds`, `fit_y_bounds`; skip autoscale of a linked y axis; zero y chrome for `YStrip::None` |
|
||||||
|
| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` | `set_share_row_y`, `row_y_auto`, `sync_row_y()`, strip modes, gutter maximum over leftmost cells, plot-width cell placement |
|
||||||
|
| `deps/plotter/src/render/renderer.cpp` (+ adapters if needed) | no y labels for a `None` cell |
|
||||||
|
| `deps/plotter/tests/test_figure.cpp` | new cases |
|
||||||
|
| `src/plot_host.cpp`, `src/ui.cpp`, `src/ui_cursors.cpp`, `src/ui_probe.cpp` | host wiring, rulers, pane, drag, probes |
|
||||||
|
| docs | see Task 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Plotter -- y link of a row's cells (range and auto)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp`
|
||||||
|
- Modify: `deps/plotter/include/plot/plot/figure.hpp`, `deps/plotter/src/plot/figure.cpp`
|
||||||
|
- Test: `deps/plotter/tests/test_figure.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (Plot): `void set_y_link_member(u32 axis, bool linked) noexcept;` `bool y_data_bounds(u32 axis, Range& out) const;` (union of the visible traces' y bounds on that axis, false if none) `bool fit_y_bounds(u32 axis, Range bounds);` (the axis' own padded fit of `bounds`, as `fit_y_axis` does; leaves `auto_scale` alone).
|
||||||
|
- Produces (Figure): `void set_share_row_y(bool on);` `bool share_row_y() const noexcept;` `bool row_y_auto(u32 row) const noexcept;` (figure row; true for a row with fewer than two cells or when sharing is off, i.e. "no group").
|
||||||
|
- Behaviour: with sharing on, for each figure row with `n > 1` cells, y axis 0 of its cells is one group. A range set on any member (wheel, drag, `set_y_range`) becomes the group's and is pushed to all; the group is auto or manual as one; auto fits the union of all members' visible traces (cells without a trace ignored; padding/log handling is `fit_axis_range`'s, via `fit_y_bounds` on the first member). Members' own autoscale is suppressed.
|
||||||
|
|
||||||
|
Group state per row (`Figure::RowY`): `Range pushed; bool have = false; bool auto_scale = true;`. Sync, run from `Figure::layout()` right after `sync_x_links()` (before any `Plot::layout()` call, because that is where a plot would otherwise autoscale):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void Figure::sync_row_y() {
|
||||||
|
if (!share_row_y_) return;
|
||||||
|
row_y_.resize(cells_per_row_.size());
|
||||||
|
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
|
||||||
|
const u32 n = cells_per_row_[row];
|
||||||
|
if (n < 2u) continue;
|
||||||
|
RowY& g = row_y_[row];
|
||||||
|
const u32 first = row_first_[row];
|
||||||
|
if (!g.have) {
|
||||||
|
g.pushed = plots_[first].y_axis(0).range;
|
||||||
|
g.auto_scale = plots_[first].y_axis(0).auto_scale;
|
||||||
|
}
|
||||||
|
// What happened since the last push. A member's auto flag that differs from the group's is a
|
||||||
|
// request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it); otherwise a
|
||||||
|
// member whose range differs from the pushed one was moved by a gesture.
|
||||||
|
bool want_auto = g.auto_scale;
|
||||||
|
Range target = g.pushed;
|
||||||
|
bool flag_request = false;
|
||||||
|
for (u32 i = first; i < first + n && !flag_request; ++i) {
|
||||||
|
const Axis& a = plots_[i].y_axis(0);
|
||||||
|
if (a.auto_scale != g.auto_scale) {
|
||||||
|
want_auto = a.auto_scale;
|
||||||
|
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
|
||||||
|
flag_request = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!flag_request && !want_auto) {
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
const Range r = plots_[i].y_axis(0).range;
|
||||||
|
if (r.min != g.pushed.min || r.max != g.pushed.max) { target = r; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (want_auto) {
|
||||||
|
Range bounds{};
|
||||||
|
bool any = false;
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
Range b{};
|
||||||
|
if (!plots_[i].y_data_bounds(0, b)) continue;
|
||||||
|
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if (any && plots_[first].fit_y_bounds(0, bounds)) target = plots_[first].y_axis(0).range;
|
||||||
|
// else: nothing to fit -- keep the range the group had
|
||||||
|
}
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
plots_[i].set_y_range(0, target); // clears the flag ...
|
||||||
|
plots_[i].set_y_auto_scale(0, want_auto); // ... so restate it: every member mirrors the group
|
||||||
|
}
|
||||||
|
g.pushed = target;
|
||||||
|
g.auto_scale = want_auto;
|
||||||
|
g.have = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`set_share_row_y(on)` stores the flag, clears `row_y_`, and calls `set_y_link_member(0, on)` on every cell of every row with `n > 1` (cells whose plot has no y axis yet are fine: `set_y_link_member` ignores a missing axis, so also re-apply in `sync_row_y()` before the loop so axes added after the call are covered). `Plot::autoscale_axes()` must skip a y axis whose `link_member` is set (reuse `Axis::link_member`, currently x-only in meaning; update its comment). `y_data_bounds` is `trace_bounds_union(false, axis, out)`. `fit_y_bounds` is `return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** -- append to `deps/plotter/tests/test_figure.cpp` (they use the existing `split_figure`, `whole_figure`, `plot::data::ArraySeries`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
|
||||||
|
plot::Figure figure = split_figure({2, 1});
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
|
||||||
|
// The unsplit row is its own thing.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
|
||||||
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
|
||||||
|
plot::Figure figure = split_figure({3});
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
||||||
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
|
||||||
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||||
|
figure.set_share_row_y(true); // cell 2 has no trace
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.row_y_auto(0u));
|
||||||
|
const plot::Range r0 = figure.plot(0u).y_range(0u);
|
||||||
|
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
|
||||||
|
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
|
||||||
|
for (plot::u32 i = 1; i < 3u; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
|
||||||
|
plot::Figure figure = split_figure({2});
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
||||||
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
|
||||||
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
|
||||||
|
figure.layout(); // manual holds: no fight with the plots' own autoscale
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
|
||||||
|
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.row_y_auto(0u));
|
||||||
|
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
|
||||||
|
plot::Figure figure = split_figure({2});
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
|
||||||
|
PLOT_CHECK(!figure.share_row_y());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run -- expect compile failure** (`set_share_row_y` undeclared). Build and run the plotter tests per `docs/claude/build-and-run.md` (the plotter's own CMake test target; run the binary with `ulimit -c 0; timeout 300`).
|
||||||
|
- [ ] **Step 3: Implement** the Plot additions, `Figure` members (`bool share_row_y_ = false; struct RowY {...}; std::vector<RowY> row_y_;`), `set_share_row_y`, `share_row_y`, `row_y_auto` (`row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true`), `sync_row_y` as above (declare it private next to `sync_x_links`), and call `sync_row_y();` in `Figure::layout()` immediately after `sync_x_links();`. Doc comments in the file's style (what, and why the group, not the plot, owns auto scale).
|
||||||
|
- [ ] **Step 4: Run the whole plotter suite.** Expect all new cases pass and 0 failed overall (was 407 cases).
|
||||||
|
- [ ] **Step 5: Commit** inside the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: cells of a row can share one y range"`. Do not touch the pin yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Plotter -- one y axis per row (strip mode, gutter, cell widths)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp` (`Plot::layout`, ~lines 1286-1390)
|
||||||
|
- Modify: `deps/plotter/src/plot/figure.cpp` (`Figure::layout`, ~lines 550-625)
|
||||||
|
- Modify: `deps/plotter/src/render/renderer.cpp` (~lines 900-920, y label staging)
|
||||||
|
- Test: `deps/plotter/tests/test_figure.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `set_share_row_y`.
|
||||||
|
- Produces: `enum class YStrip { Both, None };` next to `XStrip` in `plot.hpp`; `Plot::set_y_strip_mode(YStrip)` / `y_strip_mode()`. With `None`, `Plot::layout()` reserves no y label width and no y title width (`y_axis_width == 0`, `y_title_width == 0`, `y_label_area.w == 0`) and ignores `min_y_gutter_`; the grid lines are still built. With sharing on, `Figure::layout()` sets `None` on every cell but the first of its row; the common-gutter maximum is taken over `Both` cells only; `None` cells get `set_min_y_gutter(0)`.
|
||||||
|
- Cell widths with sharing on, for a row with `n > 1` cells: after the gutter `G` is known (end of the measurement pass), `G_eff = min(G, row.w / 2)`; call `place_cells` on the rect `{row.x + G_eff, row.y, row.w - G_eff, row.h}`, then grow cell 0 to the left by `G_eff` (`x -= G_eff; w += G_eff`). Update `layout_.cells`, call `set_viewport` on each cell, then run the final `p.layout()` pass. With sharing off nothing changes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** (append to `test_figure.cpp`; `give_magnitude_row` and `split_figure` exist):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
|
||||||
|
plot::Figure figure = split_figure({1, 3}, false);
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
|
||||||
|
for (plot::u32 i = 2; i < 4u; ++i) {
|
||||||
|
const plot::PlotLayout& l = figure.plot(i).layout();
|
||||||
|
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
|
||||||
|
PLOT_CHECK(l.axes.w > 0.0f);
|
||||||
|
}
|
||||||
|
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
|
||||||
|
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
|
||||||
|
plot::Figure figure = split_figure({3}, false);
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(8.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
const double w0 = figure.plot(0u).layout().axes.w;
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
|
||||||
|
plot::Figure figure = split_figure({2}, false);
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(0.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, w));
|
||||||
|
figure.layout();
|
||||||
|
const double a0 = figure.plot(0u).layout().axes.w;
|
||||||
|
const double a1 = figure.plot(1u).layout().axes.w;
|
||||||
|
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
|
||||||
|
plot::Figure figure = split_figure({4}, false);
|
||||||
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
|
||||||
|
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
double sum = 0.0;
|
||||||
|
for (const plot::Rect& c : figure.layout().cells) {
|
||||||
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||||
|
sum += c.w;
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
|
||||||
|
plot::Figure figure = split_figure({2}, false);
|
||||||
|
give_magnitude_row(figure.plot(0u), 0u);
|
||||||
|
give_magnitude_row(figure.plot(1u), 1u);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a renderer check: find how existing tests count or inspect staged text (see `tests/test_render_smoke.cpp` / the `staging_a_glyph_...` neighbourhood). Add one case that a `None` cell stages no y tick labels (the staged label count of a two-cell shared row equals that of a one-cell row of the same height, plus nothing for the second cell's y side). If the harness cannot observe staged text, instead assert in the renderer change below with a comment and report that no automated check was possible.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run -- expect failures** (the gutter/width cases; `YStrip` undeclared).
|
||||||
|
- [ ] **Step 3: Implement.**
|
||||||
|
- `Plot::layout`: `const bool y_none = y_strip_ == YStrip::None;` seed `y_width = y_none ? 0 : ...`, `y_title_w = y_none ? 0 : ...`, `y_floor = y_none ? 0 : min(min_y_gutter_, y_budget)`, and `need_y = y_none ? 0 : ...` in the fixed-point test so the loop still settles in two passes. Read the loop first; every use of the y strip widths must collapse to 0 and the `axes` must start at `viewport_.x`.
|
||||||
|
- `Figure::layout`: before the viewport loop compute `const bool shared = share_row_y_ && cells_per_row_[row] > 1u;` and set `p.set_y_strip_mode(shared && i != row_first_[row] ? YStrip::None : YStrip::Both)` (reset to `Both` when not shared). In the gutter maximum loop skip `None` plots and give them `set_min_y_gutter(0.0f)`. After the measurement pass and before the final `p.layout()` loop, re-place the cells of each shared row as specified in Interfaces and re-apply `set_viewport`.
|
||||||
|
- Renderer: do not stage y tick labels (and y title) when the plot's `layout().y_label_area.w <= 0.0f`; read the staging code at `renderer.cpp:~906-917` first. Check `src/input/controller.cpp:~524` still cannot start a y axis-drag in a zero-width label area (it hit-tests `y_label_area`); if it can, guard it.
|
||||||
|
- Check both adapters (`adapters/gtk4`, `adapters/glfw`) route by cell rect only, so nothing else changes.
|
||||||
|
- [ ] **Step 4: Run the whole plotter suite.** Expect 0 failed, including all pre-existing figure cases (`figure_cells_common_y_gutter` still reads the old behaviour because sharing defaults off).
|
||||||
|
- [ ] **Step 5: Commit** in the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: shared-y rows draw one y axis, on the first cell"`. Then in the main repo: `git add deps/plotter && git commit -m "pin deps/plotter: shared y axis for split rows"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Host -- turn sharing on; rulers per row; pane and units
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/plot_host.cpp` (`rebuild_all`, `sync_cursors`, `place_cursors` call sites), `src/plot_host.hpp` if a helper is declared
|
||||||
|
- Modify: `src/ui.cpp` (`row_unit` ~1759, cursors-pane line construction ~1840-1855), `src/ui_cursors.cpp` (~55-70)
|
||||||
|
- Modify: `src/ui_probe.cpp` (split probe: new steps), `src/ui_app.hpp` if a probe state is needed
|
||||||
|
- Test: the split probe (`UDPSCOPE_PROBE_SPLIT_MS`), layout probe
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 2's `Figure::set_share_row_y(bool)` and its pin.
|
||||||
|
- Produces: no new public host API required. Behaviour: after `rebuild_all()` every split figure row shares y; `RowSet::set_y_auto/set_y_range/fit_all` need no change (they call per-plot setters that the figure's sync turns into row-wide effects).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Probe first (RED).** Add a new split-probe step after the existing steps (follow the existing step style in `ui_probe.cpp` near the `[split-probe] ...` lines; note the next free step index) that, on a figure with a split row holding a trace in each cell:
|
||||||
|
1. `y_shared`: set the y range on cell A via `rs.set_y_range(a, -3, 7)`, pump a frame, assert cell B's `plot->y_range(axis_y)` equals it.
|
||||||
|
2. `y_gutter`: cell B (non-first) `layout().y_axis_width == 0` and `y_label_area.w == 0`; cell A `> 0`; and `axes.w` of A and B equal within 1 px with default weights.
|
||||||
|
3. `y_fit`: `rs.fit_all()` then one frame: both cells' ranges equal and cover both traces' y bounds.
|
||||||
|
4. `ruler_mirror`: with cursor mode showing rulers, set cell A's ruler (via `set_cursor_y` on its plot) then call `rs.sync_cursors(a)`; `ruler_y(a)==ruler_y(b)` and `ruler_b_y` likewise.
|
||||||
|
Print one line: `[split-probe] shared-y y_shared=%d y_gutter=%d y_fit=%d ruler_mirror=%d ranges=[...] axes_w=[..]`. Build and run it; expect `y_shared=0 y_gutter=0` (sharing not wired) -- RED.
|
||||||
|
- [ ] **Step 2: Wire it.**
|
||||||
|
- `RowSet::rebuild_all()`: call `figure_->set_share_row_y(true);` right after the `Figure` is created (before `install`, so the axes added by `install` are picked up -- Task 1 re-applies membership in `sync_row_y`).
|
||||||
|
- `RowSet::sync_cursors(int drag_row)`: after `r.ruler_y/ruler_b_y` are read back from the dragged cell, copy both values to every other cell of the same figure row (`first_plot_of_row(figure_row_of(drag_row))` .. `+ cells_in_row`) and `place_cursors(that, plot::CursorPlacement::Free)` on them.
|
||||||
|
- `reset_cursors()` already writes each plot's rulers from its y range; confirm both cells of a row get identical values (the range is shared). Rulers that exist before the row was split: when `insert_cell` creates the new cell, copy the row's first cell's `ruler_y/ruler_b_y` into it (read `insert_cell` and `copy`/reset helpers near `plot_host.cpp:224-240`).
|
||||||
|
- Cursors pane (`ui.cpp:~1840`): the ruler line (`slot = -1`) is created once per figure row, for the row's first plot only (`app.rows->cell_of(r) == 0`). Label: unsplit rows keep today's wording; a split row uses `"row N"` with the same numbering `plot_label` uses for the row (without the cell letter). Check `ui_cursors.cpp:~60` and every other reader of `cursors_lines` / `slot == -1` for assumptions of one ruler line per plot, and update the probe line `ruler-labels` accordingly (its expected value changes by design -- say so in the report).
|
||||||
|
- `row_unit(app, row)` (`ui.cpp:~1759`): join the units over every cell of `row`'s figure row (loop `first_plot_of_row .. + cells_in_row`, same lock), "" if they differ.
|
||||||
|
- `sync_compare_axes` (`ui_events.cpp:~555`): leave as is; the probe in Step 3 includes a comparison-mode check only if the existing compare probe covers split rows, otherwise note it in the report as unverified.
|
||||||
|
- `ui.cpp:~4237` gutter reading takes a max over all plots and `~4361` reads plot 0: both stay correct (None cells read 0). Confirm by reading, change nothing.
|
||||||
|
- [ ] **Step 3: Verify.** Incremental `cmake --build build` (1 warning baseline). Run the split probe and the layout probe per `docs/claude/verification.md` and diff against the baseline commit `18b91c8`-era numbers as in the earlier tasks (number-normalised): only lines this feature explains may differ (`ruler-labels`, `buttons=` unchanged, `cells=` unchanged; widths of non-first cells grow by the gutter). Expect the new line `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, all earlier split-probe steps still reading as before (col-drag may change -- that is Task 4), `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
- [ ] **Step 4: Commit** `git add src/plot_host.cpp src/plot_host.hpp src/ui.cpp src/ui_cursors.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "plot host: split rows share y, rulers per row"` (add only files actually changed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Layout editor -- divider drag through plot widths
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp` (`layout_col_drag_begin` ~786, `layout_col_apply` ~755)
|
||||||
|
- Modify: `src/ui_probe.cpp` (the `col-drag` step ~4190-4250), `src/ui_app.hpp` (`layout_col_drag_left0/right0` docs)
|
||||||
|
- Test: split probe `col-drag` line
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 2-3. A cell's rect width is no longer its weight's width: weights apply to **plot widths** (`plot->layout().axes.w + right_gutter`, i.e. rect width minus the y gutter, which only cell 0 has).
|
||||||
|
- Produces: `layout_col_drag_begin` stores the left/right **plot** widths in logical px (rect width minus `y_axis_width + y_title_width` of that cell's plot; the right cell's is 0). `layout_col_apply` is unchanged otherwise: the 96 px floor and the 64 px plotter floor both refer to plot widths now (`kMinCellPx`, `kPlotterMinCellPx`), the formula `frac = (want_left - 64)/(pair - 128)` stays, and the pair's weight sum stays.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Probe first (RED).** Extend the `col-drag` step so the cells compared are plot widths (use `axes.w` of the two plots, in logical px) on a row whose first cell is the left one, in addition to the existing checks (+80 px tracking within 1 px, both extremes 96 +/- 1, pair sum preserved). With the old begin code the tracking is off by the gutter; expect `ok=0` before the fix. Keep the other split-probe lines as they are.
|
||||||
|
- [ ] **Step 2: Fix** `layout_col_drag_begin` to subtract the gutter from the left cell: `gutter = plot.layout().y_axis_width + plot.layout().y_title_width` of the left plot (0 for a non-first left cell, which has `None`), converted by the same `scale`. Update the comment above `layout_col_apply` to say "plot width".
|
||||||
|
- [ ] **Step 3: Verify.** Build; run the split probe; expect `col-drag ok=1` and the previous steps unchanged; layout probe unchanged; `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 4: Commit** `git add src/ui.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "layout editor: column drag measures plot widths, not cell rects"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Docs
|
||||||
|
|
||||||
|
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/layout.page`, `docs/claude/probe-readings.md`, `docs/claude/verification.md` (new probe line), `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `docs/claude/architecture.md` if it describes per-cell y, `CLAUDE.md` if it mentions per-cell y, `src/ui_context_help.cpp`, `src/ui_tour.cpp` if they say cells have their own y axis.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `grep -rn -i "per cell\|own y\|each cell\|y axis" README.md docs help src/ui_context_help.cpp src/ui_tour.cpp CLAUDE.md` to find every statement that the cells of a split row have their own y axis or one common gutter "not per column"; rewrite each to the new rule: one y range and one y axis per figure row, labels on the left cell, y zoom/pan/fit/Auto-Fixed act on the whole row, one ruler pair per row, cells share the y gutter's width only through the first cell.
|
||||||
|
- [ ] **Step 2:** `build-and-run.md`: add the plotter change (`set_share_row_y`, y link semantics, `YStrip`, plot-width cell placement) beside the other vendored changes. `invariants-plot-and-layout.md`: replace the "one gutter, not a per-column one" wording with the structural rule (first cell only, a `None` cell contributes 0, never content-dependent). `probe-readings.md` / `verification.md`: document the `[split-probe] shared-y` line and the changed `ruler-labels` / `col-drag` readings with the defect each wrong value names.
|
||||||
|
- [ ] **Step 3:** Build (help/manual and context help compile), run the split probe once more for `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 4: Commit** `git add <the files changed> && git commit -m "docs: split rows share one y axis"`.
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
# Signal badges -- step 2 of the badge-bar redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Put the signals on the badge bar: one badge per enabled signal (a colour dot and the name; up to 6, then one "N signals" badge), each opening a per-signal slide-over page, each a drag handle (to a plot row, or to reorder), plus a trailing `+` that opens the full signal list as a slide-over page. The signal list leaves the dock.
|
||||||
|
|
||||||
|
**Architecture:** The existing `signal_panel` (heading, hint, list, math divider) is *reparented* from the temporary dock into the `signals` slide-over page. A new unit, `src/ui_signal_page.cpp`, owns the lazily-created `signal:<name>` pages (colour, calibration, information, "plot on row N", math edit/delete) and keeps them in step with the model. `src/ui_badges.cpp` grows the signal badges (inside a scroller so the bar's minimum width never depends on how many there are). The drop that today puts a dragged list row on a plot becomes `assign_signal_to_row()`, shared by the drop and by the page's buttons; the badge replaces the list row as the drag source.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` / `src/ui_badges.cpp` probes, never a separate harness.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Badge bar", "Slide-over panel", rollout step 2). Step 1 plan: `docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md`. Branch: `feature/ui-badges-dock` (continue on it).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
Copied from the spec and the repository's rules; every task includes them.
|
||||||
|
|
||||||
|
- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the badge bar keeps its fixed 36 px height and its minimum width must not grow with the number of signals (the signal badges sit in a `GtkScrolledWindow`, `GTK_POLICY_EXTERNAL` / `GTK_POLICY_NEVER`); the slide-over still overlays and never resizes the plot.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: nothing in this step adds an overlay child.
|
||||||
|
- Signal badges: "one badge per enabled signal, up to 6; beyond that a single 'N signals' badge. A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which replaces today's drag from the signal list), and opens that signal's page (colour, calibration, row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor entry)." (spec, verbatim)
|
||||||
|
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. This step adds `signals` and `signal:<name>`.
|
||||||
|
- One slide-over page at a time; Esc or a click outside closes it; each page has its own scroller; the open page is not saved.
|
||||||
|
- "Disable, do not hide": a control that does not apply is insensitive and stays on screen (the one precedent kept is the acquire tick, hidden in a file view -- see `rebuild_signal_list`).
|
||||||
|
- Text buttons only for new controls (the `+` is the character, not an icon); no icons in badges. The colour dot is the existing `chip_markup()` disc.
|
||||||
|
- The widget-lifetime trap (`docs/claude/invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_signal_panels()` destroys the page/row that owns the emitting button; hold the emitter with `outlive_the_rebuild(widget)` first. A drop handler never destroys the widgets it was emitted from.
|
||||||
|
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
|
||||||
|
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
|
||||||
|
- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`.
|
||||||
|
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Deliberate step-2 deviations from the spec (state them in the final report)
|
||||||
|
|
||||||
|
- **Reorder is in memory only.** The badge order lives in `App::badge_order` for the session; it is not written to the session file (the spec's session section lists only the dock state). A new signal sorts after the ordered ones, in model order.
|
||||||
|
- **Beyond 6 enabled signals the "N signals" badge replaces the per-signal badges *and* the `+`** (it opens the same `signals` page, so a second button for it would be a duplicate). Per-signal pages for those signals are reached by activating a row of the `signals` list (new in this step); drag-to-row for them is by the page's "Plot on" buttons.
|
||||||
|
- **The list's drag source is removed**, as the spec says ("replaces today's drag from the signal list"); `row_drag_sidebar()` goes with it if nothing else calls it.
|
||||||
|
- A signal page opened for a signal that is not acquired shows its acquire tick (so a list row can be activated without first being ticked).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Create/Modify | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/ui_signal_page.cpp` | Create | The `signal:<name>` pages: ensure/fill/prune, their buttons, the `signal_pages` probe helpers. |
|
||||||
|
| `src/ui_badges.cpp` | Modify | Signal badges, the `+` badge, "N signals" overflow, scroller, reorder drop, drag source, `signal_badges_probe`. |
|
||||||
|
| `src/ui_signals.cpp` | Modify | `listed_signals()`, `assign_signal_to_row()`, `reset_signal_color()`, row-activated -> page, list drag source removed, header comment fixed. |
|
||||||
|
| `src/ui_slideover.cpp` | Modify | `slideover_remove_page()`. |
|
||||||
|
| `src/ui_app.hpp` | Modify | `Badge` fields, `BadgeBar` fields, `App::badge_order` / `App::signal_pages`, declarations. |
|
||||||
|
| `src/ui.cpp` | Modify | Signal panel into the `signals` page; `ShowSignals` opens it; probe env var; `row_drag_sidebar` removal. |
|
||||||
|
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | The signals step / hint re-pointed to the page; badge text. |
|
||||||
|
| `src/ui_probe.cpp` | Modify | Every probe that measured or walked `signal_panel` / `signal_list` while it lived in the dock. |
|
||||||
|
| `src/ui_widgets.cpp` | Modify | CSS for the signal badge dot/name if needed. |
|
||||||
|
| `CMakeLists.txt` | Modify | Add `src/ui_signal_page.cpp` next to `src/ui_badges.cpp`. |
|
||||||
|
| `README.md`, `docs/claude/*.md` | Modify | Task 5. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The `signals` page and the `+` badge
|
||||||
|
|
||||||
|
The list moves out of the dock into a slide-over page; the `+` badge and the `g` key open it. Nothing else changes yet (the list rows keep their menu and their drag source).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (header comment), `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_probe.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (step 1): `slideover_add_page(App&, const std::string& id, const std::string& title, GtkWidget* content) -> bool`, `slideover_open/close/toggle(App&, ...)`, `slideover_page(const App&) -> std::string`, `Badge{button,name,state,page}`, `make_badge(...)`, `BadgeBar{bar,signals_slot,system_slot,trigger,source}`.
|
||||||
|
- Produces: `BadgeBar::plus` (`Badge`, page `"signals"`, name label `"+"`); the `signals` page registered once in `on_activate`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Freeze today's self-check**
|
||||||
|
|
||||||
|
Build `HEAD` (`cmake --build build -j`), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-base.log 2>&1
|
||||||
|
kill $MOCK
|
||||||
|
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-base.log > .superpowers/sdd/ui-signals-baseline-udps.txt
|
||||||
|
grep -c 'Gtk-CRITICAL' /tmp/ui-signals-base.log; grep 'gtk warnings' /tmp/ui-signals-base.log
|
||||||
|
```
|
||||||
|
Expected: `0` and `gtk warnings: 0`. Every later task diffs its own normalised run against this file and must explain every differing line from that task's "allowed baseline changes"; any other difference is a defect.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix the stray `\n` in `src/ui_signals.cpp` line 4**
|
||||||
|
|
||||||
|
The header comment reads `...with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.` on one line (a literal backslash-n). Replace it with two comment lines:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,
|
||||||
|
// and the drop that puts a signal on a row.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reparent the panel into a `signals` page (`src/ui.cpp`, `on_activate`, ~line 5172)**
|
||||||
|
|
||||||
|
Replace
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel);
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
|
||||||
|
```
|
||||||
|
with
|
||||||
|
```cpp
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
|
||||||
|
```
|
||||||
|
and register the page where the other pages are registered (after `build_slideover`, next to the `"trigger"` page; the panel must exist first, so if that registration happens before line 5172, add the `slideover_add_page(*app, "signals", "Signals", app->signal_panel);` call right after the panel is built instead):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
slideover_add_page(*app, "signals", "Signals", app->signal_panel);
|
||||||
|
```
|
||||||
|
Read the surrounding code before editing: the registration must happen once, after `build_slideover(*app)` and after `build_signal_panel`. Update the comments that say the dock holds "the signal list, the cursors and the measurements" (`dock_show_section`, `ui_app.hpp` `dock_page`) to "the cursors and the measurements".
|
||||||
|
|
||||||
|
- [ ] **Step 4: `g` opens the page (`run_app_action`, ~line 4682)**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
case AppAction::ShowSignals: slideover_toggle(app, "signals"); break;
|
||||||
|
```
|
||||||
|
and in `dock_show_section` delete the `"signals"` branch (the dock no longer holds the list); keep `"measure"`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: The `+` badge (`src/ui_badges.cpp`, `src/ui_app.hpp`)**
|
||||||
|
|
||||||
|
In `BadgeBar` add `Badge plus;`. In `build_badge_bar`, after the Source badge:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The full signal list. Insensitive until the stream has declared signals ("disable, do not hide").
|
||||||
|
make_badge(app, bb.plus, bb.signals_slot, "+", "signals");
|
||||||
|
gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel");
|
||||||
|
```
|
||||||
|
(`make_badge` appends a second, empty `state` label; for `+` leave it empty, it takes no width.) In `sync_badge_bar`, before the early return at the `bb.source.button == nullptr` check:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (bb.plus.button != nullptr) {
|
||||||
|
gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty());
|
||||||
|
set_open(bb.plus.button, slideover_page(app) == "signals");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Step 2's later tasks insert the signal badges *before* the `+` in `signals_slot`; Task 3 handles the ordering, so do not worry about it here.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Tour and context help**
|
||||||
|
|
||||||
|
`src/ui_tour.cpp`: the `"signals"` step (line ~460) becomes `{"signals", "signals", "The signals", ...}` (second field is the slide-over page; see the `"trigger"` step), text: `"Every signal the source declares, each with the switch that acquires it. The + badge opens this list; a signal has to be switched on before it can be plotted, measured or searched."`. The `"side"` step text becomes `"The dock beside the plot: the cursors, then the measurements. The buttons in the bar above show and hide it."`. Keep `&App::signal_list` as the rect. `src/ui_context_help.cpp` line ~92: replace the last sentence of the `"signals"` hint (`A signal that is dragged onto a plot is drawn there.`) with `A signal's badge, dragged onto a plot, draws it there.`; the `"signals_config"` hint's `which plot a signal is dragged onto` becomes `which plot a signal is drawn on`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Re-point the probes that treated the list as part of the dock**
|
||||||
|
|
||||||
|
`src/ui_probe.cpp` references `signal_panel`/`signal_list` at ~103-186, ~1828, ~1853, ~3843-3847, ~5827, ~6165, ~6431 and the tour-surface probe ~7024-7097. The page is closed by default, so widgets inside it are unmapped and have no allocation. For each: (a) a probe that measures the panel's allocated size -- open the `signals` page first (`slideover_open(*a, "signals")`) and close it after, or measure with `gtk_widget_measure`; (b) a probe that walks the rows needs nothing (children exist while unmapped); (c) the stress line at ~1853 (`gtk_widget_set_size_request(a->signal_panel, -1, 3000)`) existed to overflow the *dock's* scroller; re-point it to `a->cursors_panel`; (d) the `ShowSignals` check at ~6431 asserts `slideover_page(*a) == "signals"` and then closes it; (e) `~6165` (`gtk_widget_get_parent(app.signal_list)`) -- read what it needs and take it from the page instead. Print the same lines as before wherever the value is still meaningful.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j 2>&1 | grep -E 'warning|error'; echo "rc=$?"
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-t1.log 2>&1
|
||||||
|
kill $MOCK
|
||||||
|
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-t1.log | diff .superpowers/sdd/ui-signals-baseline-udps.txt - ; grep -c Gtk-CRITICAL /tmp/ui-signals-t1.log; grep 'gtk warnings' /tmp/ui-signals-t1.log
|
||||||
|
```
|
||||||
|
Then run `UDPSCOPE_PROBE_SLIDEOVER_MS=1500`, `UDPSCOPE_PROBE_BADGES_MS=1500` and `UDPSCOPE_PROBE_TOUR_SURFACE_MS=<as in verification.md>` the same way. Expected: zero build warnings; the slide-over and badge probes read as in step 1 (`table_bad=0`, `open_marked=1`, `closed_unmarked=1`); the tour-surface probe shows the `signals` step now opens the `signals` page and `tour_plan_holds(signal_list)=1`. **Allowed baseline changes:** the dock's size/position lines that included the list (re-pointed per Step 7), any `signal_panel` geometry line. Explain each in the report.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs
|
||||||
|
git commit -m "ui: the signal list is the signals slide-over page, opened by a + badge or g"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Per-signal pages, `assign_signal_to_row()` and `reset_signal_color()`
|
||||||
|
|
||||||
|
Builds `signal:<name>` pages (nothing opens them yet except activating a list row) and extracts the two actions the page shares with the list.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_signal_page.cpp`
|
||||||
|
- Modify: `src/ui_signals.cpp`, `src/ui_slideover.cpp`, `src/ui_app.hpp`, `src/ui.cpp` (probe env var), `src/ui_probe.cpp` or `src/ui_signal_page.cpp` (probe), `CMakeLists.txt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (declare in `ui_app.hpp`, namespace `udpscope::ui_impl`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order.
|
||||||
|
/// The one rule the list and the badges share.
|
||||||
|
std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals);
|
||||||
|
/// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if
|
||||||
|
/// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a
|
||||||
|
/// signal page's "Plot on" button share.
|
||||||
|
bool assign_signal_to_row(App& app, std::size_t signal, int row);
|
||||||
|
/// Back to the palette colour for the signal's position.
|
||||||
|
void reset_signal_color(App& app, std::size_t signal);
|
||||||
|
/// Holds `widget` alive across the current emission (was `static` in ui_signals.cpp).
|
||||||
|
void outlive_the_rebuild(GtkWidget* widget);
|
||||||
|
bool slideover_remove_page(App& app, const std::string& id); // ui_slideover.cpp
|
||||||
|
/// Creates the page `signal:<name>` if it is missing, fills it, and returns whether it exists afterwards.
|
||||||
|
bool signal_page_ensure(App& app, const std::string& name);
|
||||||
|
/// Called at the end of `rebuild_signal_panels`: refills every existing signal page, drops the pages of
|
||||||
|
/// signals the model no longer has (closing the slide-over first if one of them is open).
|
||||||
|
void signal_pages_sync(App& app);
|
||||||
|
```
|
||||||
|
- `App` gains `std::map<std::string, GtkWidget*> signal_pages;` (name -> the page's content box).
|
||||||
|
|
||||||
|
- [ ] **Step 1: `listed_signals()`**
|
||||||
|
|
||||||
|
In `src/ui_signals.cpp`, add (before `rebuild_signal_list`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals) {
|
||||||
|
std::vector<std::size_t> out;
|
||||||
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
|
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
|
||||||
|
const std::string base = layer_base_name(signals[i].name);
|
||||||
|
bool seen = false;
|
||||||
|
for (std::size_t j = 0; j < i && !seen; ++j) {
|
||||||
|
seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base;
|
||||||
|
}
|
||||||
|
if (!seen) out.push_back(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
and make `rebuild_signal_list` use it:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::vector<std::size_t> rows;
|
||||||
|
for (std::size_t i : listed_signals(signals)) {
|
||||||
|
// Locked: a row per signal that is *on* ... (keep the existing comment)
|
||||||
|
if (app.signals_locked && !app.model->signal_enabled(i)) continue;
|
||||||
|
rows.push_back(i);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Behaviour is unchanged (the dump's `rows == signals + 1 + math` line must not move).
|
||||||
|
|
||||||
|
- [ ] **Step 2: `assign_signal_to_row()`**
|
||||||
|
|
||||||
|
Move lines `on_drop_channel` from `if (signal >= app->model->signals().size()) return FALSE;` through `notice("plotting '" ...)` into the new function, with `app->` -> `app.`; keep the `notice` lambda, the "enable it, then take the channel that appears" branch, the free-slot search and the two refusal messages verbatim, `return false`/`return true`. `on_drop_channel` becomes:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
|
||||||
|
const int row = row_at(*app, x, y); // where the drop landed decides the row: one area covers them all
|
||||||
|
const char* text = g_value_get_string(value);
|
||||||
|
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
|
||||||
|
if (!assign_signal_to_row(*app, signal, row)) return FALSE;
|
||||||
|
cancel_drag_on(*app, row);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`rebuild_signal_panels(app)` stays inside `assign_signal_to_row` (before the notice), as it is today.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `reset_signal_color()`**
|
||||||
|
|
||||||
|
Extract the body of the `recolor` lambda (`ui_signals.cpp` ~386-401) into:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void reset_signal_color(App& app, std::size_t signal) {
|
||||||
|
std::string name;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
const std::vector<SignalInfo>& sigs = app.model->signals();
|
||||||
|
if (signal >= sigs.size()) return;
|
||||||
|
name = sigs[signal].name;
|
||||||
|
}
|
||||||
|
app.rows->clear_signal_color(name);
|
||||||
|
app.relayout_pending = true;
|
||||||
|
rebuild_signal_panels(app);
|
||||||
|
schedule_layout_save(app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The lambda keeps `outlive_the_rebuild(GTK_WIDGET(b))` first (the list rebuild destroys it) and then calls this. Remove `static` from `outlive_the_rebuild` and move its definition above `rebuild_signal_list` users unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: `slideover_remove_page()` (`src/ui_slideover.cpp`)**
|
||||||
|
|
||||||
|
Read how `slideover_add_page` records a page (stack child name, any title/page registry on `SlideOver`). Implement the inverse: returns false if the id is unknown; refuses (returns false) if it is the open page; otherwise removes the stack child (`gtk_stack_remove`) and erases whatever registry entry `slideover_add_page` made.
|
||||||
|
|
||||||
|
- [ ] **Step 5: `src/ui_signal_page.cpp`**
|
||||||
|
|
||||||
|
Create the file with the SPDX header, `#include "ui_app.hpp"`, `<gtk/gtk.h>`, `<string>`, namespaces `udpscope::ui_impl`. Page ids are `"signal:" + name`. Contents (an anonymous namespace plus the two public functions):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string page_id(const std::string& name) { return "signal:" + name; }
|
||||||
|
|
||||||
|
/// The index of `name` in the model's signals, or npos.
|
||||||
|
std::size_t signal_index(App& app, const std::string& name) {
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
const std::vector<SignalInfo>& s = app.model->signals();
|
||||||
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||||
|
if (s[i].name == name) return i;
|
||||||
|
}
|
||||||
|
return std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* text_button(const char* label, GtkWidget* parent) {
|
||||||
|
GtkWidget* b = gtk_button_new_with_label(label);
|
||||||
|
gtk_box_append(GTK_BOX(parent), b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the signal's index and the page's name on the button, so a handler resolves identity the way the
|
||||||
|
/// list does.
|
||||||
|
void tag(GtkWidget* b, std::size_t index) {
|
||||||
|
g_object_set_data(G_OBJECT(b), "udpscope-signal", GSIZE_TO_POINTER(index));
|
||||||
|
}
|
||||||
|
std::size_t tagged(GtkButton* b) {
|
||||||
|
return static_cast<std::size_t>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void fill(App& app, const std::string& name, GtkWidget* page) {
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) {
|
||||||
|
GtkWidget* next = gtk_widget_get_next_sibling(c);
|
||||||
|
gtk_box_remove(GTK_BOX(page), c);
|
||||||
|
c = next;
|
||||||
|
}
|
||||||
|
const std::size_t index = signal_index(app, name);
|
||||||
|
if (index == std::string::npos) return;
|
||||||
|
// A snapshot, taken under the lock: the chip helper wants the whole vector, and the producer replaces it
|
||||||
|
// on every CONFIG.
|
||||||
|
std::vector<SignalInfo> all;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
all = app.model->signals();
|
||||||
|
}
|
||||||
|
if (index >= all.size()) return;
|
||||||
|
const SignalInfo sig = all[index];
|
||||||
|
const bool enabled = app.model->signal_enabled(index);
|
||||||
|
const bool plotted = signal_plotted(app, sig.name);
|
||||||
|
const bool unlocked = !app.signals_locked;
|
||||||
|
|
||||||
|
// Back to the list: a page reached from a list row has no other way home.
|
||||||
|
GtkWidget* back = text_button("All signals", page);
|
||||||
|
gtk_widget_set_halign(back, GTK_ALIGN_START);
|
||||||
|
gtk_widget_add_css_class(back, "flat");
|
||||||
|
g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
slideover_open(*static_cast<App*>(user), "signals");
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
|
||||||
|
GtkWidget* title = gtk_label_new(sig.name.c_str());
|
||||||
|
gtk_widget_add_css_class(title, "title-3");
|
||||||
|
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_box_append(GTK_BOX(page), title);
|
||||||
|
if (!sig.unit.empty()) {
|
||||||
|
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
|
||||||
|
gtk_widget_add_css_class(unit, "dim-label");
|
||||||
|
gtk_widget_set_halign(unit, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget.
|
||||||
|
GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal");
|
||||||
|
app.updating_panels = true;
|
||||||
|
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), enabled);
|
||||||
|
app.updating_panels = false;
|
||||||
|
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index));
|
||||||
|
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
|
||||||
|
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
|
||||||
|
gtk_box_append(GTK_BOX(page), enable);
|
||||||
|
|
||||||
|
// Colour.
|
||||||
|
GtkWidget* colour_head = gtk_label_new("Colour");
|
||||||
|
gtk_widget_add_css_class(colour_head, "heading");
|
||||||
|
gtk_widget_set_halign(colour_head, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), colour_head);
|
||||||
|
GtkWidget* chip = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, all, index).c_str());
|
||||||
|
GtkWidget* colour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||||
|
gtk_box_append(GTK_BOX(colour_row), chip);
|
||||||
|
GtkWidget* pick = text_button("Change colour...", colour_row);
|
||||||
|
gtk_widget_set_sensitive(pick, plotted && unlocked);
|
||||||
|
gtk_widget_set_tooltip_text(pick, plotted ? "the colour this signal is plotted in"
|
||||||
|
: "on no plot, so there is no trace to colour");
|
||||||
|
tag(pick, index);
|
||||||
|
g_signal_connect(pick, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
open_signal_color(*static_cast<App*>(user), tagged(b));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
GtkWidget* reset = text_button("Default colour", colour_row);
|
||||||
|
gtk_widget_set_sensitive(reset, unlocked && app.rows->signal_colors().count(sig.name) != 0);
|
||||||
|
tag(reset, index);
|
||||||
|
g_signal_connect(reset, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
outlive_the_rebuild(GTK_WIDGET(b));
|
||||||
|
reset_signal_color(*static_cast<App*>(user), tagged(b));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
gtk_box_append(GTK_BOX(page), colour_row);
|
||||||
|
|
||||||
|
// Row: "Plot on 1 2 3 ..." and the way back off.
|
||||||
|
GtkWidget* row_head = gtk_label_new("Plot on");
|
||||||
|
gtk_widget_add_css_class(row_head, "heading");
|
||||||
|
gtk_widget_set_halign(row_head, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), row_head);
|
||||||
|
GtkWidget* rows_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||||
|
for (int r = 0; r < app.rows->row_count(); ++r) {
|
||||||
|
GtkWidget* b = text_button(std::to_string(r + 1).c_str(), rows_box);
|
||||||
|
tag(b, index);
|
||||||
|
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r));
|
||||||
|
gtk_widget_set_tooltip_text(b, "draw this signal on this plot");
|
||||||
|
g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
outlive_the_rebuild(GTK_WIDGET(b));
|
||||||
|
assign_signal_to_row(*static_cast<App*>(user), tagged(b),
|
||||||
|
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row")));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
}
|
||||||
|
gtk_box_append(GTK_BOX(page), rows_box);
|
||||||
|
GtkWidget* off = text_button("Remove from plots", page);
|
||||||
|
gtk_widget_set_halign(off, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_sensitive(off, plotted);
|
||||||
|
tag(off, index);
|
||||||
|
g_signal_connect(off, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
outlive_the_rebuild(GTK_WIDGET(b));
|
||||||
|
std::string n;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
|
const std::vector<SignalInfo>& s = a->model->signals();
|
||||||
|
if (tagged(b) >= s.size()) return;
|
||||||
|
n = s[tagged(b)].name;
|
||||||
|
}
|
||||||
|
const int gone = remove_signal_from_plots(*a, n);
|
||||||
|
a->relayout_pending = true;
|
||||||
|
rebuild_signal_panels(*a);
|
||||||
|
schedule_layout_save(*a);
|
||||||
|
set_notice(*a, "'" + n + "' left " + std::to_string(gone) + (gone == 1 ? " plot" : " plots"), 4);
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
|
||||||
|
// Information (a streamed signal's), calibration (ditto), and a math channel's editor.
|
||||||
|
GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal");
|
||||||
|
gtk_widget_add_css_class(more_head, "heading");
|
||||||
|
gtk_widget_set_halign(more_head, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), more_head);
|
||||||
|
GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
if (sig.math) {
|
||||||
|
GtkWidget* edit = text_button("Edit graph...", more);
|
||||||
|
tag(edit, index);
|
||||||
|
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app);
|
||||||
|
GtkWidget* del = text_button("Delete", more);
|
||||||
|
gtk_widget_add_css_class(del, "destructive-action");
|
||||||
|
tag(del, index);
|
||||||
|
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app);
|
||||||
|
} else {
|
||||||
|
GtkWidget* info = text_button("Information", more);
|
||||||
|
tag(info, index);
|
||||||
|
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
|
||||||
|
GtkWidget* cal = text_button("Calibrate...", more);
|
||||||
|
gtk_widget_set_sensitive(cal, unlocked);
|
||||||
|
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
|
||||||
|
tag(cal, index);
|
||||||
|
g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
open_calibration(*a, GTK_WINDOW(a->window), tagged(b));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
}
|
||||||
|
gtk_box_append(GTK_BOX(page), more);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
```
|
||||||
|
`on_math_edit_clicked`, `on_math_delete_clicked`, `on_info_clicked`, `open_calibration`, `open_signal_color`, `signal_plotted` and `signal_chip_markup` are already declared in `ui_app.hpp` (check; if `on_math_edit_clicked` / `on_math_delete_clicked` are not, add the declarations). `on_math_edit_clicked` calls `dismiss_row_menu(button)`, which looks for a `GtkPopover` ancestor and does nothing when there is none -- confirm that by reading it. `on_math_delete_clicked` already calls `outlive_the_rebuild`. Then the two public functions:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool signal_page_ensure(App& app, const std::string& name) {
|
||||||
|
if (signal_index(app, name) == std::string::npos) return false;
|
||||||
|
auto it = app.signal_pages.find(name);
|
||||||
|
if (it == app.signal_pages.end()) {
|
||||||
|
GtkWidget* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||||
|
gtk_widget_set_margin_top(page, 12);
|
||||||
|
gtk_widget_set_margin_bottom(page, 12);
|
||||||
|
gtk_widget_set_margin_start(page, 12);
|
||||||
|
gtk_widget_set_margin_end(page, 12);
|
||||||
|
if (!slideover_add_page(app, page_id(name), name, page)) return false;
|
||||||
|
it = app.signal_pages.emplace(name, page).first;
|
||||||
|
}
|
||||||
|
fill(app, name, it->second);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void signal_pages_sync(App& app) {
|
||||||
|
for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) {
|
||||||
|
if (signal_index(app, it->first) == std::string::npos) {
|
||||||
|
if (slideover_page(app) == page_id(it->first)) slideover_close(app);
|
||||||
|
slideover_remove_page(app, page_id(it->first));
|
||||||
|
it = app.signal_pages.erase(it);
|
||||||
|
} else {
|
||||||
|
fill(app, it->first, it->second);
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`slideover_add_page` wraps `content` in a scroller and returns it through the stack, so `page` stays the box `fill` clears. Check that the gtk widget `page` is not destroyed by `slideover_remove_page` before `erase` (it is not used afterwards). Call `signal_pages_sync(app)` at the end of `rebuild_signal_panels` (before `app.signal_generation = ...`). `signal_index` takes the model mutex, so `signal_pages_sync` must not be called with it held (`rebuild_signal_panels` releases it before the list rebuild).
|
||||||
|
|
||||||
|
- [ ] **Step 6: A list row opens its page**
|
||||||
|
|
||||||
|
In `rebuild_signal_list`, tag each row: `g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));` (the math divider row has none and is not activatable). In `build_signal_panel`, after the null check:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
g_signal_connect(app.signal_list, "row-activated",
|
||||||
|
G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
const std::size_t index = static_cast<std::size_t>(
|
||||||
|
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal")));
|
||||||
|
std::string name;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
|
const std::vector<SignalInfo>& s = a->model->signals();
|
||||||
|
if (index >= s.size()) return;
|
||||||
|
name = s[index].name;
|
||||||
|
}
|
||||||
|
if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name);
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
```
|
||||||
|
If `signals-panel.ui`'s list box has `activate-on-single-click` off or selection mode that swallows activation, set `activate-on-single-click` to true in code. Read the file; the row's own check button / colour-chip gesture / menu button must still work (they consume their own clicks).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Probe `UDPSCOPE_PROBE_SIGNAL_PAGES_MS`**
|
||||||
|
|
||||||
|
Declare `gboolean signal_page_probe(gpointer)` (put it in `ui_signal_page.cpp`), schedule it in `ui.cpp` next to the badges probe. It takes the first non-math listed signal (from `listed_signals`), calls `signal_page_ensure`, `slideover_open("signal:<name>")` and prints:
|
||||||
|
|
||||||
|
```
|
||||||
|
[signalpage] name=<n> open=<slideover_page==id> pages=<signal_pages.size()> enable_visible=<0|1> plot_buttons=<count of the "Plot on" buttons> plot_on_row0=<1 if assign_signal_to_row(...,0) returned true and signal_plotted> removed=<remove button click leaves signal_plotted false> back_to_list=<slideover_open("signals") -> page "signals"> pruned=<1 after the page's signal is gone>
|
||||||
|
```
|
||||||
|
To check `pruned` without a CONFIG, call `slideover_close`, `slideover_remove_page` directly is *not* a test of `signal_pages_sync`; instead leave `pruned=-1` when it cannot be exercised, and exercise `signal_pages_sync` by the ordinary reconfiguration paths (adding then deleting a math channel with `add_math_channel` / `a->math.erase(...)`+`apply_math_channels` if the probe harness already does that elsewhere; otherwise `-1`). Close the slide-over at the end.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify**
|
||||||
|
|
||||||
|
Build (zero warnings), then the UDPS baseline run as in Task 1 Step 8 diffed against `.superpowers/sdd/ui-signals-baseline-udps.txt` (expected: the Task 1 differences only), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[signalpage\]|\[udpscope\] drop|Gtk-|gtk warnings'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
Expected: `open=1 plot_on_row0=1 removed=1 back_to_list=1`, `plot_buttons` equal to the figure's row count, `gtk warnings: 0`, no `Gtk-CRITICAL`. The existing drop probes (`[drop]` lines in the baseline) must read unchanged: they now run through `assign_signal_to_row`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src CMakeLists.txt docs
|
||||||
|
git commit -m "ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: The signal badges, the "N signals" overflow and the order
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (call), `src/ui.cpp` (probe env var), `src/ui_widgets.cpp` (CSS, if any)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `listed_signals`, `signal_page_ensure`, `signal_chip_markup`, `signal_pages_sync` (Task 2).
|
||||||
|
- Produces:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// ui_app.hpp
|
||||||
|
struct Badge { /* existing */ std::string signal_name; std::size_t signal = 0; }; // the two new fields: empty/0 for non-signal badges
|
||||||
|
struct BadgeBar {
|
||||||
|
/* existing */
|
||||||
|
GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then "+"
|
||||||
|
std::vector<std::unique_ptr<Badge>> signal_badges; ///< one per shown enabled signal; empty when overflowed
|
||||||
|
Badge overflow; ///< the "N signals" badge, used only beyond kMaxSignalBadges
|
||||||
|
Badge plus; ///< (Task 1)
|
||||||
|
};
|
||||||
|
inline constexpr std::size_t kMaxSignalBadges = 6;
|
||||||
|
// App: std::vector<std::string> badge_order; ///< names, in the order the user left the badges (memory only)
|
||||||
|
void sync_signal_badges(App& app); ///< rebuilds the signal badges from the model; ui_badges.cpp
|
||||||
|
gboolean signal_badges_probe(gpointer user);
|
||||||
|
```
|
||||||
|
Include `<memory>` in `ui_app.hpp` if it is not already.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Put the signals in a scroller (`build_badge_bar`)**
|
||||||
|
|
||||||
|
Before creating any badge:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would
|
||||||
|
// widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar.
|
||||||
|
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||||
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_EXTERNAL, GTK_POLICY_NEVER);
|
||||||
|
gtk_widget_set_hexpand(scroller, TRUE);
|
||||||
|
bb.signals_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), bb.signals_row);
|
||||||
|
gtk_box_append(GTK_BOX(bb.signals_slot), scroller);
|
||||||
|
```
|
||||||
|
and change the `+` badge to `make_badge(app, bb.plus, bb.signals_row, "+", "signals")` (it follows the signal badges because they are inserted before it; see Step 3). Move `make_badge`'s parent argument usage accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `make_signal_badge`**
|
||||||
|
|
||||||
|
In the anonymous namespace of `ui_badges.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon.
|
||||||
|
void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signals, std::size_t index) {
|
||||||
|
b.signal = index;
|
||||||
|
b.signal_name = signals[index].name;
|
||||||
|
b.page = "signal:" + b.signal_name;
|
||||||
|
b.button = gtk_button_new();
|
||||||
|
gtk_widget_add_css_class(b.button, "flat");
|
||||||
|
gtk_widget_add_css_class(b.button, "udpscope-badge");
|
||||||
|
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
GtkWidget* dot = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str());
|
||||||
|
b.name = gtk_label_new(b.signal_name.c_str());
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14);
|
||||||
|
gtk_box_append(GTK_BOX(row), dot);
|
||||||
|
gtk_box_append(GTK_BOX(row), b.name);
|
||||||
|
gtk_button_set_child(GTK_BUTTON(b.button), row);
|
||||||
|
gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str());
|
||||||
|
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
|
||||||
|
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`b.state` stays `nullptr`; `sync_badge_bar` never touches it for these. In `on_badge_clicked`, before `slideover_toggle(*app, b->page)`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// A signal's page is made on first use (and kept in step by `signal_pages_sync`).
|
||||||
|
if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `sync_signal_badges`**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void sync_signal_badges(App& app) {
|
||||||
|
BadgeBar& bb = app.badges;
|
||||||
|
if (bb.signals_row == nullptr) return;
|
||||||
|
std::vector<SignalInfo> signals;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
signals = app.model->signals();
|
||||||
|
}
|
||||||
|
// The enabled signals, in the user's order: names in `badge_order` first, the rest in model order.
|
||||||
|
std::vector<std::size_t> shown;
|
||||||
|
for (std::size_t i : listed_signals(signals)) {
|
||||||
|
if (app.model->signal_enabled(i)) shown.push_back(i);
|
||||||
|
}
|
||||||
|
const auto rank = [&](std::size_t i) {
|
||||||
|
const auto it = std::find(app.badge_order.begin(), app.badge_order.end(), signals[i].name);
|
||||||
|
return static_cast<std::size_t>(it - app.badge_order.begin()); // == size() when unknown: sorts last
|
||||||
|
};
|
||||||
|
std::stable_sort(shown.begin(), shown.end(), [&](std::size_t a, std::size_t b) { return rank(a) < rank(b); });
|
||||||
|
|
||||||
|
// Clear: every signal badge, and the overflow if it was there. The "+" is kept.
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr;) {
|
||||||
|
GtkWidget* next = gtk_widget_get_next_sibling(c);
|
||||||
|
if (c != bb.plus.button) gtk_box_remove(GTK_BOX(bb.signals_row), c);
|
||||||
|
c = next;
|
||||||
|
}
|
||||||
|
bb.signal_badges.clear();
|
||||||
|
bb.overflow = Badge{};
|
||||||
|
|
||||||
|
const bool overflowed = shown.size() > kMaxSignalBadges;
|
||||||
|
if (overflowed) {
|
||||||
|
make_badge(app, bb.overflow, bb.signals_row, "", "signals");
|
||||||
|
gtk_label_set_text(GTK_LABEL(bb.overflow.name), (std::to_string(shown.size()) + " signals").c_str());
|
||||||
|
} else {
|
||||||
|
for (std::size_t i : shown) {
|
||||||
|
bb.signal_badges.push_back(std::make_unique<Badge>());
|
||||||
|
make_signal_badge(app, *bb.signal_badges.back(), signals, i);
|
||||||
|
gtk_box_insert_child_after(GTK_BOX(bb.signals_row), bb.signal_badges.back()->button,
|
||||||
|
gtk_widget_get_prev_sibling(bb.plus.button));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The overflow badge stands for the "+" (it opens the same page); the "+" is shown only when it is not.
|
||||||
|
gtk_widget_set_visible(bb.plus.button, !overflowed);
|
||||||
|
sync_badge_bar(app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`make_badge` appends to its parent; for the overflow badge it lands after the `+`, which is hidden then, so the order is immaterial. `make_badge(..., "", ...)` with an empty name: fine, the text is set right after. (`Badge{}` assignment: `Badge` holds only pointers/strings so this is a plain reset; the overflow button was destroyed by the clear loop.) The "+" being hidden when overflowed is the one deliberate use of hiding: the overflow badge replaces it. In `sync_badge_bar` add, for each signal badge and the overflow, `set_open(button, slideover_page(app) == badge.page)` (the overflow's page is `"signals"`, so it shares the `+`'s rule: both read the same state, only one is visible).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rebuild when the model's structure or a plot assignment changes**
|
||||||
|
|
||||||
|
At the end of `rebuild_signal_panels` (after `signal_pages_sync(app)` from Task 2) add `sync_signal_badges(app);`. Also call it once at the end of `build_badge_bar` (after the `+` is created) so the first frame is right. Also add `std::vector<std::string> badge_order;` and `#include <algorithm>` / `<memory>` where needed. Every event that changes a dot (assign, remove, colour, enable) already calls `rebuild_signal_panels`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Probe `UDPSCOPE_PROBE_SIGNAL_BADGES_MS`**
|
||||||
|
|
||||||
|
Schedule `signal_badges_probe` in `ui.cpp` (like the badges probe). It prints one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
[sigbadges] enabled=<shown count> badges=<signal_badges.size()> overflow=<0|1> overflow_text=<text|-> plus_visible=<0|1> dot_plotted=<number of badges whose dot is not the unplotted grey; compute by signal_plotted> bar_h=<gtk_widget_get_height(bar)> bar_h_fixed=<1 if bar_h before == after toggling one signal off and on> bar_min_w=<gtk_widget_measure min width of badge_bar> opens=<1 if emitting "clicked" on badges[0] makes slideover_page == signal:<name>, and a second emit closes it> plus_opens=<1 if the same for plus (or the overflow when overflowed)>
|
||||||
|
```
|
||||||
|
Toggle a signal with `app.model->set_signal_enabled(i, false)` then `rebuild_signal_panels(app)`, and back. `bar_min_w` is only a record: the check is that it is the same with the mock's N signals as `bar_min_w` printed by the E-1608 run in Task 5 (stated there).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify**
|
||||||
|
|
||||||
|
Build (zero warnings). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` against the mock: expected `badges == enabled` (or `overflow=1` if the mock declares more than 6), `bar_h=36` or the bar's measured value with `bar_h_fixed=1`, `opens=1 plus_opens=1`, `gtk warnings: 0`. Then the Task 1 baseline diff and the slide-over/badges/tour-surface probes: **allowed baseline changes:** none beyond Tasks 1-2. A screenshot at 1400x900 (`GSK_RENDERER=cairo`, `import -window root`) to look at the bar once.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs
|
||||||
|
git commit -m "ui: one badge per enabled signal, an N signals overflow, and the order kept in memory"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Badge drag (to a plot row, and to reorder); the list stops being a drag source
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_badges.cpp`, `src/ui_signals.cpp`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `on_drag_prepare(GtkDragSource*, double, double, gpointer)` (payload: the signal index as a decimal string), `DragContext{App*, size_t}`, `on_drop_channel` (the plot area's drop target, unchanged), `badge_order`, `signal_badges`.
|
||||||
|
- Produces: nothing new in `ui_app.hpp` except removals.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The badge is a drag source and a reorder target (`make_signal_badge`)**
|
||||||
|
|
||||||
|
Append to `make_signal_badge`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it
|
||||||
|
// unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge.
|
||||||
|
DragContext* ctx = new DragContext{&app, index};
|
||||||
|
GtkDragSource* source = gtk_drag_source_new();
|
||||||
|
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
|
||||||
|
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
|
||||||
|
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
|
||||||
|
G_CONNECT_DEFAULT);
|
||||||
|
g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
|
||||||
|
slideover_close(*static_cast<App*>(user));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(source));
|
||||||
|
|
||||||
|
// Dropping another signal's badge on this one moves it in front of this one.
|
||||||
|
GtkDropTarget* drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
|
||||||
|
g_signal_connect(drop, "drop", G_CALLBACK(on_badge_drop), &b);
|
||||||
|
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(drop));
|
||||||
|
```
|
||||||
|
and, above it in the anonymous namespace:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop"
|
||||||
|
/// emission is running) is destroyed under the drag.
|
||||||
|
gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) {
|
||||||
|
Badge* target = static_cast<Badge*>(user);
|
||||||
|
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(target->button), "udpscope-app"));
|
||||||
|
if (app == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE;
|
||||||
|
const char* text = g_value_get_string(value);
|
||||||
|
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
|
||||||
|
Badge* moved = nullptr;
|
||||||
|
for (auto& b : app->badges.signal_badges) {
|
||||||
|
if (b->signal == signal) moved = b.get();
|
||||||
|
}
|
||||||
|
if (moved == nullptr) return FALSE;
|
||||||
|
if (moved == target) return TRUE;
|
||||||
|
GtkWidget* prev = gtk_widget_get_prev_sibling(target->button);
|
||||||
|
if (prev != moved->button) { // already directly in front: nothing to move
|
||||||
|
gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev);
|
||||||
|
}
|
||||||
|
// The order, as the widgets now stand.
|
||||||
|
app->badge_order.clear();
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr;
|
||||||
|
c = gtk_widget_get_next_sibling(c)) {
|
||||||
|
for (auto& b : app->badges.signal_badges) {
|
||||||
|
if (b->button == c) app->badge_order.push_back(b->signal_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`gtk_box_reorder_child_after(box, child, nullptr)` puts the child first, which is what a `nullptr` `prev` means here.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the list's drag source (`rebuild_signal_list`)**
|
||||||
|
|
||||||
|
Delete the `DragContext* ctx ...` block through `gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));` (~454-471). `grep -n row_drag_sidebar src` : if `rebuild_signal_list` was its only caller, delete `row_drag_sidebar`, its declaration and `App::sidebar_hidden_for_drag`; if something else calls it, leave it. Update the comment above the list's row loop that mentions dragging, if any.
|
||||||
|
|
||||||
|
- [ ] **Step 3: The probes that drove the list's drag**
|
||||||
|
|
||||||
|
`src/ui_probe.cpp` ~3843-3847 walks `signal_list` rows (read what it does: if it performs a drag or a drop by index, it now takes the index from `signal_badges[0]->signal` and calls `assign_signal_to_row` / `on_drop_channel` with the same payload). Keep every printed line it printed before.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Extend `signal_badges_probe`**
|
||||||
|
|
||||||
|
After the existing line print a second:
|
||||||
|
|
||||||
|
```
|
||||||
|
[sigbadges] drag_sources=<number of badges with a GtkDragSource controller> drop_targets=<... GtkDropTarget> prepare_ok=<1 if on_drag_prepare for badges[0]'s DragContext returns a non-null provider> reorder_before=<names comma-separated> reorder_after=<...> reorder_ok=<1 if dropping the last badge on the first moved it to the front and badge_order matches the widget order> payload_drop=<1 if on_drop_channel-equivalent: assign_signal_to_row(badges[0]->signal, 0) leaves it plotted> list_drag=<number of GtkDragSource controllers on the first list row; must be 0>
|
||||||
|
```
|
||||||
|
Controllers are enumerated with `gtk_widget_observe_controllers()` (a `GListModel`) and `G_TYPE_CHECK_INSTANCE_TYPE`/`GTK_IS_DRAG_SOURCE`. With fewer than 2 badges print `reorder_ok=-1`. Drop the reordered badge back into its place at the end so later probes see the original order. For the `GValue`: `GValue v = G_VALUE_INIT; g_value_init(&v, G_TYPE_STRING); g_value_set_string(&v, std::to_string(signal).c_str());` and call `on_badge_drop` directly -- it is in the anonymous namespace of `ui_badges.cpp` where the probe lives, so no export is needed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Build (zero warnings; `-Wunused` would flag a leftover `row_drag_sidebar`). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500`. Expected: `drag_sources == drop_targets == badges`, `prepare_ok=1`, `reorder_ok=1` (or `-1` if fewer than 2), `payload_drop=1`, `list_drag=0`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Then the Task 1 baseline diff: **allowed changes:** a drag-by-list-row probe line that now reports through the badge (explain it). A real pointer drag cannot be simulated headless: say so in the report, and note that the drop path itself is `assign_signal_to_row` (probed in Task 2).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs
|
||||||
|
git commit -m "ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Docs, the E-1608 run and the sweep of what the list's move touched
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/architecture.md`, `docs/claude/invariants-gtk-and-chrome.md` (only if it names the list as being in the sidebar), `src/ui_tour.cpp` (badges step text), `SPECS.md` is **not** touched.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The E-1608 run (the spec's stress case)**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_e1608.py & MOCK=$!
|
||||||
|
sleep 1
|
||||||
|
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=3000 ./build/udpscope --source e1608 --source-opt address=127.0.0.1 > /tmp/ui-signals-e1608.log 2>&1 & sleep 4.5; import -window root /tmp/ui-signals-e1608.png; wait'
|
||||||
|
kill $MOCK
|
||||||
|
grep -E '\[sigbadges\]|Gtk-|gtk warnings' /tmp/ui-signals-e1608.log
|
||||||
|
```
|
||||||
|
Expected: `overflow=1 overflow_text=<N> signals plus_visible=0`, `bar_h_fixed=1`, and `bar_min_w` equal to the UDPS run's (the scroller keeps it constant -- if it differs by more than the one badge's rounding, the scroller is not constraining: fix before going on). Look at the screenshot once: the "N signals" badge, Trigger and Source on the right, bar height unchanged. Then open the `signals` page from a probe or by `g`, activate a row (E-1608 has up to ~17): the page for it exists (`[signalpage]` run against the mock too: `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=3000`). Kill the mock.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Docs**
|
||||||
|
|
||||||
|
- `README.md`: the signals section describes the badge bar (signal badges, `+`, the six-badge overflow, drag to a plot or to reorder, the per-signal page) instead of the list in the sidebar; state that the order is kept for the session only.
|
||||||
|
- `docs/claude/verification.md`: add `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` and `UDPSCOPE_PROBE_SIGNAL_BADGES_MS` next to the badge probes; `docs/claude/probe-readings.md`: the `[signalpage]` line and the two `[sigbadges]` lines, each field with the value it must read and the defect a wrong reading names (the fields above).
|
||||||
|
- `docs/claude/invariants-plot-and-layout.md`: one rule -- the badge bar's signal badges live in a scroller so the bar's minimum width never depends on the signal count (the E-1608 case), and the bar's height is fixed.
|
||||||
|
- `docs/claude/architecture.md`: the page ids now implemented (`signals`, `signal:<name>`), `ui_signal_page.cpp`, and that the dock holds the cursors and measurements only. `docs/claude/invariants-gtk-and-chrome.md`: if it describes `outlive_the_rebuild` or the list's drag/sidebar hiding, say it now covers the signal page's buttons and that the list is no longer a drag source.
|
||||||
|
- `src/ui_tour.cpp`: the `"badges"` step text becomes `"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, calibration and plot; the + opens the full list; Trigger and Source open their own forms. Drag a signal badge onto a plot to draw it there, or onto another badge to reorder. Click again, press Esc or click the plot to close the panel."`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final run**
|
||||||
|
|
||||||
|
Build from scratch (`rm -rf build/CMakeFiles/udpscope.dir; cmake --build build -j 2>&1 | grep -c warning` -- expected exactly `1`, the icons' warning; if a clean rebuild is too slow, report the incremental result instead), the UDPS baseline diff, and the slide-over / badges / signal-pages / signal-badges / tour-surface probes once each. Report every baseline difference with its explanation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs README.md
|
||||||
|
git commit -m "docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)"
|
||||||
|
```
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Events in the dock, the left pane deleted -- Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Step 4 of the badges/dock redesign (spec `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md`): a compact Events section in the dock, the full events list and its actions on an `events` slide-over page, the left events pane / divider / header toggle deleted, and the Mallard manual, USER-GUIDE and remaining docs swept for the new layout.
|
||||||
|
|
||||||
|
**Architecture:** The dock gets a third block under the readouts (`build_events_dock` in `src/ui_dock.cpp`): one compact row per `EventRecord` (title, time, small marker; no thumbnails), a "Manage" button, one click replays. The existing pane widgets (`events-pane.ui`: head with Live, Compare, Accumulate, the multi-select list with thumbnails) stop being a `GtkRevealer` beside the plot and become the content of the `events` slide-over page; the list keeps its builder, thumbnail cache and selection logic. Both views are rebuilt from the same `event_store->list()` by the same refresh entry point.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita, the headless self-check probes (no unit tests).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- The dock is fixed at 280 px; dock event rows are ellipsized labels with fixed `width-chars`/`max-width-chars`; no thumbnails in the dock (layout must not depend on content).
|
||||||
|
- No new overlay child; the page uses `slideover_add_page(App&, id, title, widget)`; the page id is `events`.
|
||||||
|
- "Disable, do not hide": the dock's Events block stays on screen, insensitive, when unavailable. **Availability rule (deviation from the spec's "insensitive outside trigger mode", kept on purpose):** the existing `sync_events_availability` condition (`event_recording || replaying || events_cached`) -- a replay or a group leaves trigger mode, and a block that greys itself under the click that started it is the trap `invariants-trigger-and-pinning.md` records. Document the deviation in README and `docs/claude`.
|
||||||
|
- A handler that rebuilds destroys the widgets it was emitted from: use `outlive_the_rebuild(widget)`. `refresh_events_pane` must keep nulling `events_thumb` before it removes children. `update_group_buttons` keeps its `app.window == nullptr` teardown guard.
|
||||||
|
- `model->signals()` is read only under `model->mutex()`.
|
||||||
|
- The page content is the pane's `events_box`, never the `GtkRevealer` (an unrevealed revealer measures 0x0).
|
||||||
|
- Selection survives marking: `mark_events_on_screen` updates titles in place and now also updates the dock rows' marker; it never rebuilds the page list.
|
||||||
|
- Every new source file carries the SPDX MIT header (this plan adds none).
|
||||||
|
- Baselines: clean build = 1 warning (icons `-Woverlength-strings`), incremental = 0, `gtk warnings: 0`, no `Gtk-CRITICAL`. Probes need a tour-seen config for slide-over page legs (see `.superpowers/sdd/task-5-report.md`, `verification.md`). Mocks: `tools/mock_udpstreamer.py` (8114), `tools/mock_e1608.py` (54211); run serially, kill them.
|
||||||
|
- The session file has no events-pane field (`events_pane_width` is memory only); there is nothing to migrate. README's claim that the width is remembered across sessions is wrong and is removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The Events block in the dock (the left pane still exists)
|
||||||
|
|
||||||
|
Adds the dock block so the app stays whole; nothing is removed.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_dock.cpp`, `src/ui_app.hpp`, `src/ui_events.cpp`, `src/ui.cpp` (dock assembly, availability sync, tick), `src/ui_probe.cpp`, `CMakeLists.txt` (only if a file is added; none expected)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`):
|
||||||
|
- `GtkWidget* build_events_dock(App& app);` -- vertical `GtkBox`: a row with the "Events" label and a "Manage" text `GtkButton` (calls `slideover_open(app, "events")`; a no-op until Task 2 registers the page), then a `GtkListBox` (`app.dock_events_list`, selection mode none, single-click activation) of compact rows, inside a `GtkScrolledWindow` with a fixed `min-content-height`/`max-content-height` (about 4 rows) and `vscrollbar-policy` automatic. Stores `app.dock_events_box`, `app.dock_events_list`, `app.dock_events_manage`.
|
||||||
|
- `void dock_rebuild_events(App& app);` -- clears and refills `dock_events_list` from `app.event_store->list()` (newest first, as the pane does): each row = a small marker label (`dock_event_marker`, see below), the title (ellipsized, same text as `event_row_title`), the time as the pane shows it (ellipsized, dim). With no records: one dim "no events yet" row. Attaches the `EventRecord` to the row as `"udpscope-event"` (same key as the pane). Null guard: a no-op before `build_events_dock` ran or when `app.event_store` is null.
|
||||||
|
- `void dock_mark_events(App& app);` -- updates each dock row's marker/title from the same data `mark_events_on_screen` uses (the event on screen and the group members), without rebuilding rows.
|
||||||
|
- `void dock_events_set_sensitive(App& app, bool available);` -- the one place the block's sensitivity is set; `sync_events_availability` calls it with its own `available` value.
|
||||||
|
- Row activation (single click): calls the existing `replay_event(app, record)` that `on_event_row_activated` (`ui_events.cpp:961`) calls, with the same argument it passes; do not duplicate the replay logic -- extract the shared body if `on_event_row_activated` has any.
|
||||||
|
- Consumes: `App::event_store`, `EventRecord`, `event_row_title()` (`ui_events.cpp:784`), `replay_event`, `mark_events_on_screen`, `slideover_open`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the block.** Write `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive` in `ui_dock.cpp`. Marker: a single-character label -- the on-screen event gets a filled marker, the group members another, others none (read how `mark_events_on_screen` (`ui_events.cpp:796`) decides the on-screen/group titles and reuse its predicate; do not invent a second one). CSS: check `ui_widgets.cpp` for existing `.udpscope-event-*` classes and `dim-label` before adding any.
|
||||||
|
- [ ] **Step 2: Wire it.** Append `build_events_dock` to the dock after the readouts box (and a separator), in `ui.cpp` where `build_readouts_dock` is assembled. Call `dock_rebuild_events(app)` at the end of `refresh_events_pane`'s non-empty and empty paths (every place it finishes) **and** from `record_event` unconditionally (today's `record_event` refreshes the pane only if the revealer is revealed; the dock must follow every latch), and `dock_mark_events` from `mark_events_on_screen`. Call `dock_events_set_sensitive` from `sync_events_availability` (`ui_events.cpp:40-70`) with its `available` result, and once after building (insensitive until available).
|
||||||
|
- [ ] **Step 3: Probe.** In the existing events probe (`events_probe_next`, `UDPSCOPE_PROBE_EVENTS_MS`) print `dock events: rows=<n> pane_rows=<n> same_titles=<0|1> sensitive=<0|1> dock_w=<n>` after at least two latched events (the probe already produces them), where `same_titles` compares each dock title to the pane row's title label (`"udpscope-event-title"`) in order, and again after a replay (`on_screen` marker present on exactly one dock row). Add one click on a dock row to the probe and print `dock replay: clicked=<0|1> replaying=<0|1>` (the dock row click starts a replay as the pane's activate does).
|
||||||
|
- [ ] **Step 4: Build; run** the events probe (UDPS mock), the readouts probes (regression), and check `dock_w` <= 280, `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 5: Commit** (`git add src/ui_dock.cpp src/ui_app.hpp src/ui_events.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact events block in the dock`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: The `events` page; the left pane, divider and header toggle deleted
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_events.cpp`, `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui/header-bar.ui`, `src/ui/events-pane.ui`, `src/ui_app.hpp`, `src/ui_trigger.cpp` (`:597-601`, `:723-724`), `src/ui_layout.cpp` (`:380` comment), `src/ui_widgets.cpp` (`:58-60` comment), `src/ui_probe.cpp`, `src/ui_dock.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive`; `slideover_add_page`, `slideover_open`.
|
||||||
|
- Produces: slide-over page `events` (title "Events") whose content is `events_box` of `events-pane.ui`; `build_events_pane(App&)` now returns that box (not a revealer); `App::events_revealer`, `App::events_divider`, `App::events_pane_width`, `sync_events_divider`, the header `events_toggle` and its handler are gone; `events_cached`/`sync_events_availability` keep driving the dock block's sensitivity (and the page's: set `events_box` insensitive with it).
|
||||||
|
|
||||||
|
- [ ] **Step 1: The page.** `events-pane.ui`: drop the `events_pane` revealer so `events_box` is the root (rename nothing else); keep `events_head`, `events_live`, `events_compare`, `events_accumulate`, `events_list`. `build_events_pane` stops creating `events_revealer`. Register the page next to the `cursors`/`measure` registrations in `ui.cpp`: `slideover_add_page(*app, "events", "Events", app->events_box)` (add the `events_box` member if absent). `record_event`'s refresh condition `gtk_revealer_get_child_revealed` becomes "the `events` page is open" (`slideover_page(app) == "events"`), because the dock refreshes independently; opening the page (the slide-over's open path or the Manage button) must refresh the list once so it is current -- put this in a `slideover_set_on_open`-style hook only if the slide-over already has one; otherwise refresh in the Manage button's handler and in `refresh_events_pane` callers that run while closed (cheap; the list is small) -- state in the report which you chose.
|
||||||
|
- [ ] **Step 2: Delete the pane.** `main-window.ui`: remove `events_divider`; `left_column` becomes the `side_view` content directly (keep every id inside it); fix the header comment (lines ~6, 23, 29). `ui.cpp`: remove `sync_events_divider`, its tick call, the `events_divider` creation (`~5201-5206`), the null guard (`~4998`), the toggle handler (`~4951-4959`) and the toolbar-order probe string entry (`~3010`); `header-bar.ui`: remove `events_toggle`; keep the `udpscope-events-symbolic` icon (used by `preferences.ui`). `ui_app.hpp`: remove the members and declarations (`events_divider`, `events_pane_width`, `events_revealer`, the toggle pointer). `sync_events_availability` loses the toggle/revealer branches, keeps computing `available`, and drives `dock_events_set_sensitive` plus the page's sensitivity. Fix the comments at `ui_trigger.cpp:597-601`, `:723-724`, `ui_layout.cpp:380`, `ui_widgets.cpp:58-60`.
|
||||||
|
- [ ] **Step 3: Probes.** Rewrite `events_probe_next` (`ui_probe.cpp:5590-5860`) to open the page with `slideover_open(*a, "events")` instead of clicking the toggle, then drive selection/Compare/Accumulate exactly as before on the page's list; delete the divider/revealer/`pane_w` reads; rewrite `report_events_availability` (`ui.cpp:5712-5724`) as `[events] available=<0|1> dock_sensitive=<0|1> page_sensitive=<0|1> page=<id>` and the `events pane:` dump (`ui.cpp:6242-6255`) as `events pane: compare= accumulate= list= nested=` (no revealer). Any layout dump or toolbar probe naming the toggle is updated. The plot's width must no longer change when events exist: add `plot_w_stable=<0|1>` comparing the plot area's allocated width before and after the first latched event (it must read 1; the old pane squeezed it).
|
||||||
|
- [ ] **Step 4: Run** the events probe, the trigger probes, the layout/toolbar probes, the readouts probes, one E-1608 run; `gtk warnings: 0`, no `Gtk-CRITICAL`, in particular none from `events_thumb`, `update_group_buttons` at teardown, or the removed revealer. Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains a difference.
|
||||||
|
- [ ] **Step 5: Commit** (`git add` the modified files by name; message `ui: the events list is a slide-over page; the left pane, its divider and the header toggle are gone`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Tour, context help, docs/claude, README
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp` (`:102-110`), `README.md` (97, 841-850, 883, 1032, 1194, 3025-3027), `docs/claude/{architecture,verification,probe-readings,invariants-gtk-and-chrome,invariants-trigger-and-pinning,invariants-plot-and-layout}.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tour and help.** The `dock` tour stop text mentions the Events block (what it lists, that one click replays, that Manage opens the list). The context-help hints `events` (anchored to `&App::events_head`, now on a page) and `events_record` follow how the `cursors`/`measures` hints were handled in step 3 (anchor on the page heading, shown while the page is open; reword "the events pane"). Run the help-figures and tour probes.
|
||||||
|
- [ ] **Step 2: docs/claude.** `architecture.md` (chrome section: the dock now has readouts and events; page `events`; `events` no longer "reserved", ~line 105), `invariants-gtk-and-chrome.md` (paned/revealer traps at ~212-228 lose the events divider; teardown ~281 and `events_thumb` ~297, thumb cache ~309 now say "the page's list"), `invariants-trigger-and-pinning.md` (~441-453: the availability rule drives the dock block's sensitivity; the spec's "outside trigger mode" deviation and why), `invariants-plot-and-layout.md` (no events divider; the plot is never squeezed by events), `verification.md` (~724 and the events probe wording), `probe-readings.md` (`dock events:`, `dock replay:`, the new `[events]` and `events pane:` lines and what each must read; the old `pane_w=240` rows and the `[events]` table near 1660-1675 rewritten).
|
||||||
|
- [ ] **Step 3: README.** Layout/dock description, the header (no Events toggle), the events page, the single-click replay in the dock, deviations: availability rule instead of "outside trigger mode"; the dock list has no thumbnails (the page does); delete the false "remembers the width" claim; update the pending-removal note at ~3025-3027 (it is done).
|
||||||
|
- [ ] **Step 4: Run** the tour and help-figure probes, one UDPS and one E-1608 run; `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 5: Commit** (code: `ui: tour and help describe the events block`; docs: `docs: events in the dock and on its page`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: The Mallard manual and USER-GUIDE sweep for the whole redesign
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `help/C/org.udpscope.app/*.page` (`trigger.page` 169-196 "Events pane on the left", the history button, "Drag the pane's edge"; `overview.page` incl. its layout description and any figure caption; `signals.page`, `math.page`, `sessions.page`, plus any page that mentions the side panel, the sidebar tabs ("Measure", "Signals" tab), the header's sidebar toggle, or the Events toggle), `docs/USER-GUIDE.md`, `README.md` leftovers
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find every stale mention.** `grep -rniE "side panel|sidebar|side pane|tab switcher|\bMeasure tab|Signals tab|events pane|history button|Events toggle|drag.*(edge|divider)" help/C docs/USER-GUIDE.md README.md src/ui_*.cpp` and read each hit in context; also check figures the manual embeds (`help/C/org.udpscope.app/figures/` and how they are generated -- `UDPSCOPE_*` help-figures probe in `verification.md`). List every hit and its disposition in the report.
|
||||||
|
- [ ] **Step 2: Rewrite** each page to the real UI: header bar (time window, Trigger, Pause, Cursors, Dock toggle, menu), the plot, the badge bar (signal badges, `+`, N signals, Trigger, Source), the slide-over pages (trigger, signals, signal:<name>, cursors, measure, events, source), the dock (Readouts: cursors, measurements; Events). Read the code or `docs/claude/architecture.md` to be accurate; do not invent shortcuts (`m` toggles Measurements, `g` the signals page -- check `src/shortcuts.cpp`). Regenerate figures only if the repository already has a script that does so headlessly; otherwise say which figures remain stale.
|
||||||
|
- [ ] **Step 3: Validate** the Mallard pages if `yelp-check` / `xmllint` is available (`xmllint --noout help/C/org.udpscope.app/*.page`); build with `cmake --build build -j` (the help may be compiled/installed by a target).
|
||||||
|
- [ ] **Step 4: Commit** (`docs: the manual and the user guide describe the badge bar, the dock and the slide-over pages`).
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Compact readouts dock -- step 3 of the badge-bar redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The dock shows the cursors and the measurements as compact, read-only readouts (about 280 px wide, always on screen); the full cursors panel and the full measurements panel (mode, reset, add, statistics, edit) become the `cursors` and `measure` slide-over pages, reached by an "Edit" text button in the dock, by the `m` key, and by clicking a measurement's dock line.
|
||||||
|
|
||||||
|
**Architecture:** A new unit `src/ui_dock.cpp` (spec: `ReadoutsDock`) builds the dock's "Readouts" section: a cursors block (a small grid mirroring `App::cursors_lines`) and a measurements block (one line per `App::measures`). It owns no formatting: `update_cursors_pane()` and `update_measures()` already compute the strings, and they now write them to the page's widget *and* to the dock's, from the one string. `cursors_panel` and the measures panel are *reparented* (as the trigger form and the signal list were) into slide-over pages with ids `cursors` and `measure`. The dock's old contents (the two full panels) leave it.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` probes, never a separate harness.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Right dock", "Slide-over panel", rollout step 3). Earlier plans: `2026-11-02-ui-badges-dock-shell.md` (step 1), `2026-11-03-ui-badges-signals.md` (step 2). Branch: `feature/ui-badges-dock` (continue on it).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
Copied from the spec and the repository's rules; every task includes them.
|
||||||
|
|
||||||
|
- **Right dock (spec, verbatim):** "Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals ('disable, do not hide'). It stays an `AdwOverlaySplitView` sidebar ... **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`). An 'Edit' affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row opens that measurement's editor."
|
||||||
|
- **One formatter:** a readout quotes a stored sample and never a value between two (`docs/claude/invariants-readouts-and-cursors.md`); the dock and the page show the *same string*, set from the same variable in the same call -- no second `format_measure()` call site for the same value.
|
||||||
|
- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width (280 px: `min-sidebar-width` = `max-sidebar-width` in `main-window.ui`), every dock label is ellipsized with a fixed `width-chars`/`max-width-chars`, so a long unit or value never widens the dock; the badge bar keeps its fixed height; the slide-over overlays and never resizes the plot.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: this step adds no overlay child.
|
||||||
|
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. This step adds `measure` and `cursors`. One slide-over page at a time; each page has its own scroller (`slideover_add_page` provides it); the open page is not saved.
|
||||||
|
- "Disable, do not hide": with the cursors off, the dock's cursor block stays on screen with `--` cells (the existing blanking in `update_cursors_pane`), insensitive; an empty measurement list shows one dim line, never nothing.
|
||||||
|
- Text buttons only for new controls ("Edit", not an icon). Existing icon buttons that move into the pages are not changed in this step.
|
||||||
|
- The widget-lifetime trap (`invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_measures_pane()` / `rebuild_cursors_pane()` destroys widgets it may have been emitted from; hold the emitter with `outlive_the_rebuild(widget)` first. The dock's lines are rebuilt by those two functions, so a click on a dock line must not be handled by a widget that the rebuild destroys synchronously (open the dialog from the handler, rebuild later).
|
||||||
|
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
|
||||||
|
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
|
||||||
|
- Verification runs are short and serial; kill every mock you start (`tools/mock_udpstreamer.py` port 8114, `tools/mock_e1608.py` port 54211); seed a tour-seen config so the first-run tour does not close slide-over pages (see `.superpowers/sdd/task-5-report.md` for commands). Do not re-run to chase cosmetics.
|
||||||
|
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Deliberate step-3 deviations from the spec (state them in the final report)
|
||||||
|
|
||||||
|
- **The measurement editor stays a dialog window** (`open_measure_dialog`): the `measure` page holds the list, Add, the Statistics switch and each row's edit button, which open that dialog. Moving the dialog's form into the slide-over is not needed for the readouts and is not done here.
|
||||||
|
- The `measure` and `cursors` pages hold the existing panels unchanged (heading, mode dropdown, reset, switch, grid / add, statistics, rows), so their content is not re-designed.
|
||||||
|
- Dock cursor lines carry the same cells as the page's grid (`who`, A, B, delta), at a smaller size; they are not reduced to the time line only, because the modes that read per-row values would then have no readout on screen.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Create/Modify | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/ui_dock.cpp` | Create | The dock's Readouts section: build, rebuild of the cursor lines and the measurement lines, the Edit buttons, the click-to-edit on a measurement line, the `[dock]` probe line. |
|
||||||
|
| `CMakeLists.txt` | Modify | Add `src/ui_dock.cpp` next to `src/ui_badges.cpp`. |
|
||||||
|
| `src/ui_app.hpp` | Modify | `App::DockCursorLine`, `App::dock_cursor_lines`, `App::dock_measure_labels`, `App::dock_readouts` widgets; declarations. |
|
||||||
|
| `src/ui_cursors.cpp` | Modify | `update_cursors_pane()` writes each cell's string to the page and to the dock; the rebuild calls the dock's rebuild. |
|
||||||
|
| `src/ui_measure.cpp` | Modify | `update_measures()` writes each reading to the page's label and to the dock's; `rebuild_measures_pane()` rebuilds the dock lines. |
|
||||||
|
| `src/ui.cpp` | Modify | Pages `cursors` / `measure` registered; panels removed from `dock_page`; `dock_show_section()` removed; `ShowMeasure` opens the page; dock assembly. |
|
||||||
|
| `src/ui/main-window.ui` | Modify | Dock width 280 (`min/max-sidebar-width`), `side_column` `width-request` 260, comments. |
|
||||||
|
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Steps / hints that pointed at the cursors and measurements panels in the dock now open the page (as the signals step does). |
|
||||||
|
| `src/ui_probe.cpp` | Modify | Probes that walked, measured or scrolled the panels while they lived in the dock; the `dock:` readings. |
|
||||||
|
| `README.md`, `docs/claude/*.md` | Modify | Task 3. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The compact readouts in the dock (panels still in the dock)
|
||||||
|
|
||||||
|
The Readouts section is added at the top of the dock, above the existing full panels, so the app stays whole: nothing is removed yet. Both read from the same strings.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_dock.cpp`
|
||||||
|
- Modify: `CMakeLists.txt`, `src/ui_app.hpp`, `src/ui_cursors.cpp`, `src/ui_measure.cpp`, `src/ui.cpp` (dock assembly, ~line 5161), `src/ui_probe.cpp` (a `[dock]` line in an existing probe)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`):
|
||||||
|
- `GtkWidget* build_readouts_dock(App& app);` -- the Readouts box (a vertical `GtkBox`: a "Readouts" heading; a cursors block = a row with the "Cursors" label and an "Edit" `GtkButton` (text), then a `GtkGrid` of dock cursor lines; a measurements block = a row with the "Measurements" label and an "Edit" button, then a vertical box of lines). Stores `app.dock_cursor_grid`, `app.dock_measures_box`, `app.dock_cursors_edit`, `app.dock_measures_edit`.
|
||||||
|
- `void dock_rebuild_cursors(App& app);` -- called at the end of `rebuild_cursors_pane()`: rebuilds `app.dock_cursor_lines` (one `DockCursorLine {GtkWidget *label,*a,*b,*d;}` per `app.cursors_lines`, same order) in `dock_cursor_grid`. The first row is the same header `cursors_header()` writes (`who`/A/B/delta), small.
|
||||||
|
- `void dock_rebuild_measures(App& app);` -- called from `rebuild_measures_pane()` after the page's rows: one line per `app.measures` (signal chip, type name ellipsized, value label), pushing the value label to `app.dock_measure_labels` (same index as `app.measure_labels`). With no measurements: one dim line "no measurements".
|
||||||
|
- `void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, const std::string& d);` and `void dock_measure_value(App& app, std::size_t i, const std::string& text);` -- set the dock's labels; no-ops when the index is out of range (mid-rebuild).
|
||||||
|
- Consumes: `App::cursors_lines`, `App::measures`, `App::measure_labels`, `measure_color()`, `chip_markup()`, `signal_color_index()`, `open_measure_dialog(App&, int)`, `slideover_open(App&, const std::string&)` (page ids used in Task 2; in this task the Edit buttons call it and the page may not exist yet -- `slideover_open` of an unknown id is a no-op; Task 2 registers the pages).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Dock widgets and rebuild functions.** Create `ui_dock.cpp` with the functions above. Fixed sizes: dock label `width-chars`/`max-width-chars` of 9 for value cells, ellipsize end, CSS class `caption` and `numeric` (check `ui_widgets.cpp` for the existing CSS before adding any). The whole Readouts box is the first child of `app.dock_page` (insert before the cursors panel with `gtk_box_prepend`).
|
||||||
|
- [ ] **Step 2: One string, two widgets.** In `update_cursors_pane()` (`ui_cursors.cpp`) replace the `put(line.a, va)` etc. by a helper that sets the page cell and calls `dock_cursor_cell(app, index, va, vb, vd)`; do the same for the blanking branch (`"--"`). In `update_measures()` (`ui_measure.cpp`, the two `gtk_label_set_text(label, ...)` sites that also write `app.measure_text[i]`) call `dock_measure_value(app, i, text)` from the same variable. Never format twice.
|
||||||
|
- [ ] **Step 3: Rebuild hooks.** `rebuild_cursors_pane()` (ui.cpp ~1564) ends with `dock_rebuild_cursors(app)`; `rebuild_measures_pane()` calls `dock_rebuild_measures(app)` before its final `update_measures(...)`. Both are no-ops until `build_readouts_dock` ran (null guards).
|
||||||
|
- [ ] **Step 4: Assemble.** In `ui.cpp` where the dock is assembled (after `gtk_box_append(dock_page, measures_panel)`), prepend `build_readouts_dock(*app)` and add its separator. The Edit buttons call `slideover_open(app, "cursors")` / `slideover_open(app, "measure")`; clicking a measurement line (a `GtkGestureClick` on the line box, not on the value label which is selectable) calls `open_measure_dialog(app, i)` with the index stored in the widget data, as `measures_row`'s edit button does.
|
||||||
|
- [ ] **Step 5: Probe.** Add to the existing cursors probe (find it with `grep -n "cursors" src/ui_probe.cpp`) and measures probe a reading proving the dock agrees with the page: print `dock cursors: lines=<n> page_lines=<n> same_text=<0|1>` and `dock measures: lines=<n> page_rows=<n> same_text=<0|1>` where `same_text` compares every dock label's text to the page label's text. Also `dock_w=<allocated width of the dock readouts box>` and assert it does not exceed the sidebar width. Run with 3 measurements and the cursors on in `Both` mode (cursors mode set via `rows->set_cursor_mode`).
|
||||||
|
- [ ] **Step 6: Build, run the probes against `tools/mock_udpstreamer.py`, verify `same_text=1`, `gtk warnings: 0`.**
|
||||||
|
- [ ] **Step 7: Commit** (`git add src/ui_dock.cpp CMakeLists.txt src/ui_app.hpp src/ui_cursors.cpp src/ui_measure.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact cursor and measurement readouts at the top of the dock`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: The `cursors` and `measure` pages; the dock is only readouts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui_probe.cpp`, `src/ui_dock.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `build_readouts_dock`, `slideover_add_page(App&, id, title, widget)`, `slideover_toggle(App&, id)`.
|
||||||
|
- Produces: slide-over pages `cursors` (title "Cursors", content `app.cursors_panel`) and `measure` (title "Measurements", content the measures panel); `AppAction::ShowMeasure` toggles the `measure` page; `dock_show_section()` and its declaration/uses are deleted.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the pages** next to the `trigger` and `signals` registrations (`ui.cpp` ~5156): `slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel)` and `slideover_add_page(*app, "measure", "Measurements", measures_panel)`. Remove both `gtk_box_append(dock_page, ...)` of the panels and the separator between them; the dock keeps only `build_readouts_dock` (and, later, step 4's events).
|
||||||
|
- [ ] **Step 2: Keys and actions.** `AppAction::ShowMeasure` -> `slideover_toggle(app, "measure")`. Delete `dock_show_section()`. Make sure the tour/help code does not call it (`grep -n dock_show_section src`).
|
||||||
|
- [ ] **Step 3: Dock width.** `main-window.ui`: `min-sidebar-width`/`max-sidebar-width` 280, `side_column` `width-request` 260; update its comments.
|
||||||
|
- [ ] **Step 4: Badge/header state.** The page ids join the "open" bookkeeping: `slideover_page(app)` reports them; nothing in the badge bar marks them (no badge). The header's Cursors toggle still mirrors the `cursors_switch` inside the page (both exist).
|
||||||
|
- [ ] **Step 5: Probes.** Fix every probe that read the panels' allocation, sensitivity or scroll position while they sat in the dock (`dock_section_probe`, the `dock:` reading in the layout dump, the `cursors=... in the toolbar, dock=..., reset=... in the cursors pane` line, any that walks `dock_page`): they open the page first or read the widget tree without allocation. Add `[readouts] pages cursors=<0|1> measure=<0|1> dock_children=<n> dock_w=<n>` (pages registered; dock holds only the readouts box; width 280). Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains the difference.
|
||||||
|
- [ ] **Step 6: Run** the UDPS and E-1608 probe runs serially; `gtk warnings: 0`; Esc closes the pages; the measure Add and the measurement dialog still work from the page (the existing measure probes).
|
||||||
|
- [ ] **Step 7: Commit** (`ui: the cursors and measurements panels are slide-over pages; the dock is the readouts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Tour, context help, docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `README.md`, `docs/claude/{architecture,verification,probe-readings,invariants-plot-and-layout,invariants-readouts-and-cursors,invariants-gtk-and-chrome}.md`, `help/C/*.page` mentions of the cursors / measurements panels in the dock or the side panel (the full Mallard sweep and `USER-GUIDE.md` are step 4)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tour.** Steps pointing at `cursors_panel`/`cursors_head`/`measures_head`/`side_box` now target the dock's readouts, or open the `cursors` / `measure` page through the existing "tour opens a page" mechanism (`invariants-gtk-and-chrome.md`), as the signals step does. Rewrite the step text: readouts in the dock, "Edit" opens the page.
|
||||||
|
- [ ] **Step 2: Context help.** Hints attached to `cursors_head` / `measures_head` live in the pages: show only while the page is open, or re-attach to the dock block headings -- whichever the existing layer supports without a second code path (follow how `signals_head` was handled). Run the help-figures probe and the tour probe (`UDPSCOPE_PROBE_*` in `verification.md`) and keep their lines sane.
|
||||||
|
- [ ] **Step 3: Docs.** README (layout, keys, the dock, readouts, deviations listed above), `architecture.md` (chrome section), `verification.md` and `probe-readings.md` (new `[dock]`/`[readouts]` lines, changed `dock:` readings), `invariants-readouts-and-cursors.md` (the dock and the page share one string: rule and why), `invariants-plot-and-layout.md` (dock width 280, ellipsized cells).
|
||||||
|
- [ ] **Step 4: Run** the tour/help-figure probes and one E-1608 run; confirm baselines (build 0 warnings, `gtk warnings: 0`).
|
||||||
|
- [ ] **Step 5: Commit** (`docs: the readouts dock, the cursors and measurement pages`).
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# MCC E-1608 source plugin (sub-project 3 of 4)
|
||||||
|
|
||||||
|
Date: 2026-10-31
|
||||||
|
Branch: `feature/source-e1608` (from `feature/source-plugins`, sub-project 1)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework and ported
|
||||||
|
UDPS onto it. This sub-project adds a second kind, `e1608`: the Measurement Computing E-1608
|
||||||
|
Ethernet DAQ (8 SE / 4 DIFF 16-bit analog inputs, 2 analog outputs, 8 DIO bits, one 32-bit
|
||||||
|
counter), with its own connect form and two side-stack panels.
|
||||||
|
|
||||||
|
References for the wire protocol, both written by the user:
|
||||||
|
|
||||||
|
- Primary: `~/Projects/marte2_projects/MCC_E1608_DataSource`, a MARTe2 DataSource
|
||||||
|
(`E1608DataSource.{h,cpp}`, `E1608ScanFifo`). Its class comment in the header is the
|
||||||
|
authoritative list of parameters and behaviour. It is bound to MARTe2 (`BasicTCPSocket`,
|
||||||
|
`StreamString`, `SingleThreadService`), so it is **ported** to plain C++, not linked.
|
||||||
|
- Cross-check: `~/Projects/uopi/internal/datasource/mcc/{protocol,e1608}.go`, an independent
|
||||||
|
Go implementation of the same protocol.
|
||||||
|
|
||||||
|
No board is available. The mock is the only verification until one is attached.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| DAC / generator | Static level plus a software waveform (sine, square, triangle, ramp), 10 Hz at most. Runs as a timer inside `poll()`; no extra thread. |
|
||||||
|
| Hardware | None available. A protocol mock (`tools/mock_e1608.py`) is the contract. Commands whose layout rests on a single reference (or where the two disagree) are listed as unverified. |
|
||||||
|
| Analog input config | Full: per-channel enable and range, global SE/DIFF, MAC discovery, external clock, trigger, DI/counter poll rate. |
|
||||||
|
| Digital lines and counter | Plot channels the user chooses, off by default, enabled per line in the connect form. |
|
||||||
|
| Acquisition modes | Hardware-paced only (scan rate or external clock). Software-paced mode (`ScanRate = 0`) is not ported. |
|
||||||
|
| Structure | Three layers: link, device, source. The bottom two have no GTK or `DataSource` types. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Files (new, `src/source/e1608/`, plus the mock)
|
||||||
|
|
||||||
|
- `e1608_link.{hpp,cpp}`: UDP discovery (broadcast, or a unicast sweep of a given subnet
|
||||||
|
matched by MAC and the E-1608 product id) and connection-code handshake; TCP command
|
||||||
|
framing with a timeout; the scan socket on `port + 1`. Plain POSIX sockets.
|
||||||
|
- `e1608_device.{hpp,cpp}`: typed commands (AI, AO, DIO read/write/direction, counter
|
||||||
|
read/reset, scan queue, scan start/stop, status), the factory calibration read from the board's
|
||||||
|
calibration memory, and `decode()`, which turns raw interleaved `uint16` scans into calibrated
|
||||||
|
volts (`double`).
|
||||||
|
- `e1608_source.{hpp,cpp}`: the `DataSource`: the `poll()` state machine, `Frame` building, the
|
||||||
|
DI/counter and generator timers, the atomic readouts for the panels.
|
||||||
|
- `e1608_kind.cpp`: the `SourceKind`: the connect form and the two panels.
|
||||||
|
- `tools/mock_e1608.py`: the protocol mock.
|
||||||
|
- `register_builtin_sources()` registers `e1608` after `udps`.
|
||||||
|
|
||||||
|
### 2. Data path and threading
|
||||||
|
|
||||||
|
Everything runs inside `poll()` on the producer thread. The GTK thread reaches the board only
|
||||||
|
through `post(Command)`.
|
||||||
|
|
||||||
|
`poll(timeout)`:
|
||||||
|
|
||||||
|
1. Drains the posted commands (DAC writes, DIO toggles, counter reset, generator edits).
|
||||||
|
2. Runs the connection state machine: optional MAC lookup, handshake, calibration read, DIO
|
||||||
|
direction and initial outputs, scan queue and scan start take Disconnected to Scanning. A
|
||||||
|
link error fires `on_event(Disconnected)` and retries every 2 s. A connect attempt can block
|
||||||
|
`poll()` for up to the command timeout; that delays only the command queue.
|
||||||
|
3. Waits on the scan socket for at most the time to the next timer, reads what arrived, keeps a
|
||||||
|
partial scan for the next call, decodes the complete scans with the calibration and
|
||||||
|
delivers a `Frame`.
|
||||||
|
4. Runs the due timers: the DI/counter poll (`DiPollRate`) is a command round trip; the
|
||||||
|
generator tick (at most 10 Hz) is a DAC write.
|
||||||
|
|
||||||
|
The frame:
|
||||||
|
|
||||||
|
- Signals: the enabled AI channels (unit `V`, `range_min`/`range_max` from the channel's range),
|
||||||
|
the DI lines the user chose (0/1), and the counter if chosen.
|
||||||
|
- All signals are scalar; `num_samples` is the number of scans decoded in that read.
|
||||||
|
- DI and counter values are held across the block (a step trace).
|
||||||
|
- `sampling_rate` is the scan rate on every signal. `counter` is a running frame count.
|
||||||
|
- The aggregate rate (enabled channels times scan rate) must stay at or below 250 kS/s. The
|
||||||
|
form refuses a configuration above it.
|
||||||
|
|
||||||
|
Overrun: if the board reports a scan overrun the source fires `on_event(Error)` and restarts
|
||||||
|
the scan when `AutoRestart` is set; the next frame carries `lost = 1`. This is approximate,
|
||||||
|
because the board does not report how many scans it dropped.
|
||||||
|
|
||||||
|
Panel readouts: the source keeps the live DIO inputs, the counter, the DAC levels and the
|
||||||
|
generator state in atomics. The panels read them from a GTK timer, so no widget touches the
|
||||||
|
source's internals.
|
||||||
|
|
||||||
|
### 3. Connect form and panels
|
||||||
|
|
||||||
|
Connect form: grouped `AdwExpanderRow`s in the chooser's group. `SourceConfig` stays flat; a
|
||||||
|
per-channel setting is a key such as `ai0=BIP10V` (absent means off).
|
||||||
|
|
||||||
|
- Connection: address, MAC, discovery subnet, port, connection code, timeout. `valid()` needs
|
||||||
|
an address, or a MAC plus a subnet.
|
||||||
|
- Acquisition: scan rate, external clock, trigger mode, auto-restart, calibrate (default on),
|
||||||
|
DI/counter poll rate.
|
||||||
|
- Analog inputs: global SE or DIFF mode and 8 channel rows (enable, range). In DIFF mode rows
|
||||||
|
4 to 7 are insensitive, not hidden. `valid()` also enforces the 250 kS/s aggregate.
|
||||||
|
- Digital I/O: direction per bit (I or O), a "plot" switch per input bit, a "plot counter"
|
||||||
|
switch, the initial DO value and the initial DAC levels.
|
||||||
|
|
||||||
|
`identity()` is the address, or the MAC when there is no address. A session file is named
|
||||||
|
`e1608-<identity>`, port 1, as sub-project 1 defines for non-UDPS kinds. `has_datagrams()` is
|
||||||
|
false, so the capture button is disabled.
|
||||||
|
|
||||||
|
Two side-stack panels, both insensitive while disconnected (disabled, not hidden):
|
||||||
|
|
||||||
|
- Analog out: per DAC, a mode (Level, Sine, Square, Triangle, Ramp), a level or amplitude plus
|
||||||
|
offset, a frequency of 0.1 to 10 Hz, and an on/off switch. It shows the last voltage written
|
||||||
|
and clamps to ±10 V. The generator stops on disconnect and the DACs return to their initial
|
||||||
|
levels on reconnect.
|
||||||
|
- Digital I/O: eight bit rows; an input bit shows a live LED, an output bit has a toggle that
|
||||||
|
posts a write. The counter shows its value and a Reset button. Directions come from the form
|
||||||
|
and are not changed at runtime.
|
||||||
|
|
||||||
|
Tab icons are chosen in the plan under the project's bundled-icon rules.
|
||||||
|
|
||||||
|
### 4. Verification
|
||||||
|
|
||||||
|
Mock (`tools/mock_e1608.py`): UDP discovery with a MAC and the connection-code handshake; TCP
|
||||||
|
commands for AI, AO, DIO, the counter, scan queue, scan start/stop, calibration memory and
|
||||||
|
status; a scan stream on port+1 at the requested rate with a sine per channel; switches to drop
|
||||||
|
the link after N seconds and to report an overrun. Its header lists every command whose layout
|
||||||
|
is unverified (single reference, or the references disagree).
|
||||||
|
|
||||||
|
Probe `UDPSCOPE_PROBE_E1608=<delay ms>`, run against the mock with `--source e1608`, extends the
|
||||||
|
probe/dump in `src/ui.cpp`. It checks:
|
||||||
|
|
||||||
|
- Frames arrive with the expected channel count and sample rate.
|
||||||
|
- The panels are present and insensitive while disconnected.
|
||||||
|
- A DAC write, a DIO toggle and a counter reset each reach the mock.
|
||||||
|
- A forced link drop leads to a reconnect and a resumed stream.
|
||||||
|
- The calibration is applied (the mock serves non-trivial coefficients).
|
||||||
|
|
||||||
|
A decode check needs no mock: a fixed raw buffer decoded against hand-computed volts.
|
||||||
|
|
||||||
|
Baselines are unchanged: the UDPS self-check reads as on `main`, exactly three build warnings,
|
||||||
|
no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
Errors: a rejected handshake (bad connection code), no device found, timeouts and scan-socket
|
||||||
|
loss surface through `on_event(Error)` with a readable message and follow the reconnect cycle.
|
||||||
|
A rejected connection code stops the retries: the source stays in Error with that message until the user reconnects.
|
||||||
|
|
||||||
|
Docs: update `architecture.md`, `build-and-run.md`, `verification.md`, `probe-readings.md` and
|
||||||
|
`README.md`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Software-paced acquisition (`ScanRate = 0`).
|
||||||
|
- A generator faster than 10 Hz, and a dedicated I/O thread.
|
||||||
|
- Changing DIO directions at runtime.
|
||||||
|
- Per-channel SE/DIFF mixing (one global mode).
|
||||||
|
- Verification against a real board.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Source plugins: framework and UDPS port (sub-project 1 of 4)
|
||||||
|
|
||||||
|
Date: 2026-10-31
|
||||||
|
Branch: `feature/source-plugins`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
udpscope reads exactly one kind of source, a UDPStreamer stream through `deps/udpsclient`.
|
||||||
|
The goal is to accept several kinds of data source as plugins, each with its own config
|
||||||
|
dialog and, where the hardware offers it, its own UI (signal generator, digital I/O,
|
||||||
|
multiple timebases).
|
||||||
|
|
||||||
|
The work is split into four sub-projects, each with its own spec, plan and implementation:
|
||||||
|
|
||||||
|
1. **Plugin framework + UDPS client ported onto it** (this document).
|
||||||
|
2. Flat UDP plugin (standard MARTe2 UDPWriter stream; the wire format must be supplied or
|
||||||
|
located before that spec is written -- none was found in `~/workspace` or `~/Projects`).
|
||||||
|
3. MCC E-1608 plugin. The protocol code in `MCC_E1608_DataSource` is bound to MARTe2
|
||||||
|
(`BasicTCPSocket`, `StreamString`, `SingleThreadService`); it is ported to plain C++, not
|
||||||
|
linked against MARTe2. AI streaming, DIO and DAC/generator panels.
|
||||||
|
4. ABScope plugin. Needs a multi-timebase data path and a wire format: the ABScope design
|
||||||
|
only sketches the UDP header and `streamerd` is unwritten.
|
||||||
|
|
||||||
|
Sub-project 1 changes no UDPS behaviour. It is a refactor plus the hosting machinery the
|
||||||
|
later plugins need.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| What "plugin" means | Compiled-in registry; the interface is kept clean enough for a `dlopen` loader to be added later (not built now). |
|
||||||
|
| Threading | `Acquisition` keeps the single producer thread and calls `source->poll()`. UI actions reach the source through a thread-safe command queue drained inside `poll()`. CLAUDE.md's two-thread rule holds. (A per-source I/O thread was considered and deferred.) |
|
||||||
|
| Source UI | Widget factory: a source returns `GtkWidget*` for its config form and each capability panel. |
|
||||||
|
| Timebases in the frame | The neutral frame is single-timebase. Sub-project 4 extends interface and data path together; this may break the interface once, accepted. |
|
||||||
|
| CLI / sessions | `--server/--port/...` keep meaning UDPS; `--source NAME` and `--source-opt k=v` added. Sessions keyed by kind + identity; legacy `host_port.toml` read as `udps`. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. The source interface (new `src/source/`)
|
||||||
|
|
||||||
|
- **`SourceConfig`**: ordered string key/value map with typed getters. It is the stored form
|
||||||
|
(a TOML table), the `--source-opt` form, and what a config widget reads and writes.
|
||||||
|
- **`Frame`**: source-neutral, mirrors `udps_frame_t`: `counter`, `lost`, `late`,
|
||||||
|
`recv_time`, `publish_mode`, `num_samples`, `signals[]` (the existing `SignalInfo`) and
|
||||||
|
`values[]` (doubles). `signals` and `values` are pointers into the source's own buffers,
|
||||||
|
valid only for the duration of `on_data`; `Acquisition` copies what it keeps. The hole, backfill and time-base logic needs `counter/lost/late`; a
|
||||||
|
source with no counter supplies a running counter and `lost = 0`. `Frame::value(sig, k,
|
||||||
|
col)` replaces `udps_frame_value`.
|
||||||
|
- **`DataSource`**: the per-connection object.
|
||||||
|
- `open()`, `poll(timeout_ms)`, `close()`.
|
||||||
|
- `stats()` returns the counters now in `StreamStats`; `connected()` reports link state.
|
||||||
|
- `post(Command)`: thread-safe queue, drained inside `poll()`. `Command` is
|
||||||
|
`std::function<void()>`.
|
||||||
|
- `set_datagram_tap(fn)`: optional; lets UDP-based sources feed the raw-packet capture.
|
||||||
|
- Output goes to a `SourceSink` (`on_config`, `on_data`, `on_event`) called inside
|
||||||
|
`poll()`, as `udps_client` does today.
|
||||||
|
- **`SourceKind`**: factory and UI description of one plugin type: `id`, `display_name`,
|
||||||
|
`identity(cfg)`, `make(cfg)`, `make_config_form()` and `make_panels(source)` (each
|
||||||
|
`{id, title, GtkWidget*}`). `make_config_form()` takes no argument: the chooser calls
|
||||||
|
`form.write(cfg)` to fill the form. A `ConfigForm` is a list of `AdwPreferencesRow` widgets
|
||||||
|
plus `write` / `read` / `valid` / `on_change` closures, so the chooser can place the rows in
|
||||||
|
its own group and gate its Connect button on `valid()`.
|
||||||
|
- **`SourceRegistry`**: filled by an explicit `register_builtin_sources()` call. No
|
||||||
|
static-init self-registration (linkers can drop it). A future `dlopen` loader would only
|
||||||
|
call `register_kind()`.
|
||||||
|
|
||||||
|
### 2. Acquisition and the UDPS plugin
|
||||||
|
|
||||||
|
- `Acquisition` takes a `std::unique_ptr<DataSource>` instead of building a `udps_client_t`
|
||||||
|
and implements `SourceSink`; it creates the source lazily in `start()`, from the kind and
|
||||||
|
`SourceConfig` it was given; the four trampolines go away.
|
||||||
|
- `on_config` / `on_data` change only in the type they read. Time base, holes, backfill,
|
||||||
|
decimation, trigger and math code are untouched.
|
||||||
|
- `run()` calls `source_->poll(50)` and copies `source_->stats()` into
|
||||||
|
`ScopeModel::stats()`. `PacketCapture` stays in `Acquisition`, fed through the datagram
|
||||||
|
tap. Sources without datagrams (E-1608) leave the capture UI disabled, not hidden.
|
||||||
|
- `src/source/udps_source.cpp` wraps `deps/udpsclient` unchanged: it maps `udps_signal_t`
|
||||||
|
to `SignalInfo` and `udps_frame_t` to `Frame`, and carries the existing knobs (server,
|
||||||
|
port, multicast, interface, recv buffer). Identity is `host:port`.
|
||||||
|
- The `udps_*` types are used only in `acquisition.cpp` today, so no other file changes for
|
||||||
|
this step beyond the `Frame` type.
|
||||||
|
|
||||||
|
### 3. UI hosting, config and persistence
|
||||||
|
|
||||||
|
- **Connect dialog** (`source_dialog.cpp`): gains a "Source type" dropdown; choosing a kind
|
||||||
|
swaps in that kind's `make_config_form()`. The UDPS form (server, port, multicast,
|
||||||
|
interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer
|
||||||
|
size, relative time and capture ring size.
|
||||||
|
- **Capability panels**: after the source is created the app calls `make_panels()` and adds
|
||||||
|
each panel as a page of the existing `side_stack` (the side area's page stack, owned by
|
||||||
|
the tab switcher). A panel is insensitive when
|
||||||
|
disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no
|
||||||
|
panels adds no tab; UDPS has none. Panels call `source->post(Command)`, never the source
|
||||||
|
directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO
|
||||||
|
bank, generator form) lives in `src/source/widgets/` but is only added when a plugin uses
|
||||||
|
it, so it arrives with sub-project 3.
|
||||||
|
- **Config / CLI**: `Config` gains `source_kind` (default `udps`) and a `SourceConfig`.
|
||||||
|
`--server/--port/--multicast/--interface` populate the UDPS entries. `--source NAME` and
|
||||||
|
repeatable `--source-opt k=v` work for any kind. `--help` lists the registered kinds.
|
||||||
|
- **Sessions**: the source's config is stored as top-level TOML keys, `source_kind` and
|
||||||
|
`source_opt_<key>`, written only for non-UDPS kinds (a UDPS session file is unchanged, so
|
||||||
|
legacy `host_port.toml` files read as `udps` with nothing migrated). It is not a `[source]`
|
||||||
|
table. A non-UDPS session file is named by a pseudo-host `<kind>-<identity>` with port 1.
|
||||||
|
- **Mocks**: `tools/mock_udpstreamer.py` remains the UDPS mock. Each later plugin ships its
|
||||||
|
own mock under `tools/` so the headless self-check keeps working.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- This is a pure refactor for UDPS: the existing headless self-check must read exactly as on
|
||||||
|
`main` -- exactly three build warnings, no `Gtk-CRITICAL` (see `docs/claude/verification.md`).
|
||||||
|
- New probe `UDPSCOPE_PROBE_SOURCE_KINDS` (`UDPSCOPE_PROBE_SOURCES` is already the chooser
|
||||||
|
discovery probe). Its value is a delay in ms, and its presence also registers the stub
|
||||||
|
`probe` kind. It extends the probe/dump in `src/ui.cpp` rather than
|
||||||
|
adding a harness. It checks that the registry lists its kinds, that a config form
|
||||||
|
round-trips its `SourceConfig`, and -- with a stub test-only kind carrying one panel --
|
||||||
|
that the panel's tab is built, insensitive while disconnected, and that a posted command is
|
||||||
|
drained by `poll()`.
|
||||||
|
- Document the new probe lines in `docs/claude/probe-readings.md`; update `architecture.md`
|
||||||
|
(a `DataSource` replaces the client in the thread description), `build-and-run.md`
|
||||||
|
(new CLI options) and `README.md`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Runtime `.so` loading, and a per-source I/O thread.
|
||||||
|
- Multi-timebase data path (sub-project 4).
|
||||||
|
- The flat UDP, E-1608 and ABScope plugins (sub-projects 2-4).
|
||||||
|
- The DIO / generator helper widgets (arrive with sub-project 3).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# E-1608 network scan -- design
|
||||||
|
|
||||||
|
Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The E-1608 connection form gets a "Scan network" row that finds boards and fills the address or the
|
||||||
|
MAC from a click. Today a user must already know the address, or give a MAC and rely on a lookup.
|
||||||
|
|
||||||
|
## Finding (tested on a real board, 2026-11-02)
|
||||||
|
|
||||||
|
The board was at 169.254.88.75 on `net0`; the default route is `wlan0`. A `D` probe sent to
|
||||||
|
255.255.255.255 with no binding found nothing (it left through `wlan0`). The same probe sent with
|
||||||
|
`SO_BINDTODEVICE net0`, or bound to the interface address, or sent to a directed broadcast, or by
|
||||||
|
unicast, was answered (64 bytes, first byte `D`). So a single limited broadcast is not enough on a
|
||||||
|
host with several interfaces; the existing "find by MAC" lookup has the same flaw.
|
||||||
|
|
||||||
|
## Discovery layer (`e1608_link.{hpp,cpp}`)
|
||||||
|
|
||||||
|
- `class Scan` (non-blocking, one instance per scan): `Scan(port)` enumerates up, non-loopback IPv4
|
||||||
|
interfaces with `IFF_BROADCAST` (`getifaddrs`), opens one UDP socket per interface bound to it
|
||||||
|
(`SO_BINDTODEVICE`, falling back to binding the interface address if the call is refused), enables
|
||||||
|
`SO_BROADCAST`, and sends `D` to 255.255.255.255 on each. `poll()` drains every socket without
|
||||||
|
blocking and returns the devices found so far (E-1608 product id only, one entry per MAC, the same
|
||||||
|
reply parsing as `discover`). The caller owns the clock.
|
||||||
|
- `discover()` keeps its signature. A new overload or helper `discover_broadcast(port, wait_ms)` is
|
||||||
|
`Scan` plus a blocking wait. `E1608Source`'s lookup uses it when the subnet is empty; a given
|
||||||
|
subnet still uses `sweep_targets` + `discover` as today.
|
||||||
|
- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty
|
||||||
|
result.
|
||||||
|
|
||||||
|
## Form (`e1608_kind.cpp`)
|
||||||
|
|
||||||
|
- At the top of the Connection expander, an `AdwActionRow` "Scan network" with a text suffix button
|
||||||
|
"Scan" (widget name `e1608-scan`). The row subtitle reads "Scanning...", "N boards found" or
|
||||||
|
"No board found". No theme icons.
|
||||||
|
- A scan starts a `Scan` on the port in the Port field and a 50 ms `g_timeout_add` poll for about
|
||||||
|
1.5 s. There is no thread: the project has exactly two. The timer is removed when the form is
|
||||||
|
destroyed (`Form::alive` guard plus the destroy hook) and when a new scan starts.
|
||||||
|
- Each device found becomes an `AdwActionRow` in the Connection expander right after the scan row,
|
||||||
|
replacing the previous results. Title: IP. Subtitle: MAC. Activating the row fills Address and
|
||||||
|
clears MAC and Subnet. A suffix text button "Use MAC" (`e1608-use-mac`) fills MAC and clears
|
||||||
|
Address and Subnet. Fill is done under the form's `updating` guard, then `changed` fires once.
|
||||||
|
- Result rows are not read or written by the session: only the filled fields persist.
|
||||||
|
- The description line mentions the scan.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- A scan probe (env var in the existing pattern): starts against `tools/mock_e1608.py` on a
|
||||||
|
loopback-reachable port, presses the scan button, and prints `[e1608] scan: found=N ip=... mac=...
|
||||||
|
fill_addr=1 fill_mac=1 ok=1`, covering both fills and that the results are replaced on a rescan.
|
||||||
|
If the sandbox refuses the broadcast, the line says so and the limitation is documented, not claimed.
|
||||||
|
- A manual run of the scan against the real board, recorded in the README.
|
||||||
|
- Baselines unchanged: 3 warnings clean, `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`README.md` (E-1608 paragraph), `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Remembering scan results across runs; scanning other subnets than the host's interfaces (the
|
||||||
|
`subnet` sweep field remains for that); a worker thread.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Flat UDP source plugin (sub-project 2 of 4)
|
||||||
|
|
||||||
|
Date: 2026-11-02
|
||||||
|
Branch: to be cut from `feature/source-plugins`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework. Sub-project 3
|
||||||
|
added the E-1608. This sub-project adds `flatudp`: a receiver for the MARTe2 `UDPSender`
|
||||||
|
datasource (`MARTe2-components/Source/Components/DataSources/UDP/UDPSender.{h,cpp}`).
|
||||||
|
|
||||||
|
## The wire format
|
||||||
|
|
||||||
|
Read from `UDPSender::Synchronise()` and `MemoryDataSourceI`:
|
||||||
|
|
||||||
|
- One `Write` of the datasource memory per `Synchronise()`: **one datagram is one cycle**.
|
||||||
|
- The memory is the signals concatenated in declaration order, **no padding**, in the sender's
|
||||||
|
native byte order. `signalOffsets[s]` is the running sum of `size * elements`.
|
||||||
|
- There is no header, counter, timestamp or signal description. In IndependentThread mode the
|
||||||
|
first signal is a `uint8` `Trigger`; it is sent like any other signal.
|
||||||
|
- The user must therefore enter the exact signal list by hand.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| Time base | The user declares the nominal cycle rate in Hz (`rate`, required). The existing pipeline stamps the frames: anchored at arrival, samples spaced by the declared cadence, corrected to the measured cadence when the arrivals are more than 25% off it; `--relative-time` gives a purely counted clock. There is no `time_mode` switch: `acquisition.cpp` has no stamping path that ignores the declared cadence, and `sampling_rate = 0` would size the ring and the ingest decimation for 1 kHz (`model.cpp:60`). Loss is never reported (`lost = 0`). |
|
||||||
|
| Signal list entry | A row editor: name entry, type dropdown, element count and a remove button per row, plus an "Add signal" row. |
|
||||||
|
| Network | Port and optional bind address, plus an optional multicast group and interface. No source-address filter. |
|
||||||
|
| Byte order | `little` (default) or `big`. |
|
||||||
|
| Out of scope | Auto-detecting the list, importing a MARTe2 `.cfg`, a source filter, loss detection. |
|
||||||
|
|
||||||
|
## Config keys
|
||||||
|
|
||||||
|
Persisted in the session, settable with `--source-opt`. Kind id: `flatudp`.
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `port` | 44488 | UDP port to listen on (the sender's own default). |
|
||||||
|
| `bind` | empty | Local address to bind; empty is any. |
|
||||||
|
| `multicast` | empty | Group to join; empty is plain unicast. |
|
||||||
|
| `iface` | empty | Local interface address for the join. |
|
||||||
|
| `rate` | none | Nominal cycle rate in Hz. Required; sizes the ring and the decimation and spaces samples within a frame. |
|
||||||
|
| `endian` | `little` | `little` or `big`. |
|
||||||
|
| `signals` | none | `name:type[count],...`, e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A persistence format only: the form builds and parses it. |
|
||||||
|
|
||||||
|
Types: `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`. Count 1 is a scalar signal; N
|
||||||
|
is a vector signal. Expected datagram size is the sum of `size * count` over the rows.
|
||||||
|
|
||||||
|
Validity (form and `--source-opt` alike): at least one signal; names non-empty and unique;
|
||||||
|
count 1-4096; total size at most 65507 bytes; `rate` finite and positive.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
New files under `src/source/flatudp/`:
|
||||||
|
|
||||||
|
- `flatudp_signals.{hpp,cpp}` -- the `signals` string codec: parse, format, validate, size
|
||||||
|
arithmetic. No GTK, so a probe can round-trip it.
|
||||||
|
- `flatudp_source.{hpp,cpp}` -- the `DataSource`.
|
||||||
|
- `flatudp_kind.{hpp,cpp}` -- the `SourceKind`, the form, and the registration in
|
||||||
|
`register_builtin_sources()`.
|
||||||
|
|
||||||
|
Threading is unchanged: the source lives on the producer thread and `poll()` does all the work.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
`poll(timeout_ms)` waits on a non-blocking UDP socket and drains it.
|
||||||
|
|
||||||
|
- A datagram of exactly the expected size is decoded row by row to doubles (byte-swapped when
|
||||||
|
`endian=big`).
|
||||||
|
- **Batching.** When every row is scalar, decoded datagrams are queued and delivered
|
||||||
|
`block` at a time in one `Frame` (`num_samples = block`), where
|
||||||
|
`block = clamp(round(rate / 100), 1, 1024)`, about 100 frames per second as the E-1608 does. A
|
||||||
|
partial block is flushed after 100 ms so a slow or stopped sender does not hold samples back.
|
||||||
|
When any row is a vector, a vector signal carries one sample per `Frame`, so each datagram is
|
||||||
|
its own frame (`num_samples = 1`).
|
||||||
|
- `Frame`: `counter` a running frame count, `lost = 0`, `late = false`, `recv_time` the arrival
|
||||||
|
clock at delivery.
|
||||||
|
- `set_datagram_tap` is honoured for every datagram, valid or not, and `has_datagrams()` is
|
||||||
|
true, so packet capture works.
|
||||||
|
- `on_config` is called from the first `poll()` with one `SignalInfo` per row (`rows = count`,
|
||||||
|
`cols = 1`, `time_mode = kTimePacket`, `sampling_rate = rate`).
|
||||||
|
|
||||||
|
### Errors and state
|
||||||
|
|
||||||
|
- A wrong-size datagram is dropped, never decoded. Each distinct size raises one `Error`
|
||||||
|
event ("datagram of N bytes, expected M, check the signal list") and increments `stale`.
|
||||||
|
- `Connected` is raised on the first valid datagram and `Disconnected` after 2 s of silence; a
|
||||||
|
later datagram raises `Connected` again.
|
||||||
|
- A bind or multicast-join failure raises an `Error` and the source stays disconnected.
|
||||||
|
|
||||||
|
### Connect form
|
||||||
|
|
||||||
|
Built from `AdwPreferencesRow`s, with the destroy-notify guard used by the E-1608 form.
|
||||||
|
|
||||||
|
- Network group: port, bind, multicast, interface.
|
||||||
|
- Timing: a rate spin.
|
||||||
|
- Byte order combo.
|
||||||
|
- Signals group: one row per signal, "Add signal" at the bottom, and a live subtitle with the
|
||||||
|
implied size ("41 bytes per datagram"). `valid()` is false while the list breaks a validity
|
||||||
|
rule.
|
||||||
|
- The `signals` string is rebuilt from the rows on every change; `write()` parses it back.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
No unit suite; the self-check is the contract.
|
||||||
|
|
||||||
|
- `tools/mock_flatudp.py`: emits packed structs for a given signal spec and rate; options for
|
||||||
|
big-endian and for one wrong-size datagram.
|
||||||
|
- `UDPSCOPE_PROBE_FLATUDP` in `src/ui_probe.cpp`: stream names and count; a hand-computed
|
||||||
|
decode of known values including a vector signal and a `uint64`; the declared rate reaching the model; the
|
||||||
|
wrong-size error reported once and counted in `stale`; disconnect after silence; the form's
|
||||||
|
validity rules; the `signals` codec round trip. A multicast loopback check runs if the
|
||||||
|
sandbox allows it.
|
||||||
|
- Baselines unchanged: exactly 3 build warnings, `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
README (the kind and its limits: no loss detection, the signal list is the user's
|
||||||
|
responsibility), `docs/claude/build-and-run.md` (options and mock usage),
|
||||||
|
`docs/claude/architecture.md` (source plugins), `docs/claude/probe-readings.md` and
|
||||||
|
`docs/claude/verification.md` (the new probe lines).
|
||||||
|
|
||||||
|
## Amendment 2026-11-02: how a vector signal is shown (decided with the user during Task 2)
|
||||||
|
|
||||||
|
Task 2 found that treating every vector row as one channel of `count` consecutive samples zero-pads
|
||||||
|
the scalar channels of a mixed list (the model spans a frame by its largest element block, and
|
||||||
|
`Frame::value` is 0 past a scalar's one value) and declares the wrong rate for the vector channel.
|
||||||
|
Both readings of a vector are useful depending on the sender, so each vector row picks one:
|
||||||
|
|
||||||
|
- **`channels` (default).** `Wave:float32[16]` becomes 16 scalar channels named `Wave[0]` .. `Wave[15]`,
|
||||||
|
one sample per cycle each. All such lists are batched like an all-scalar list.
|
||||||
|
- **`waveform`.** `Wave:float32[16]w` is ONE channel receiving a block of `count` consecutive samples per
|
||||||
|
cycle: `SignalInfo{rows=count, cols=1, sampling_rate=rate*count}`, one datagram per `Frame`
|
||||||
|
(`num_samples = 1`).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- The `signals` string takes an optional trailing `w` after the count; `format` emits it only for a waveform row.
|
||||||
|
- A waveform row needs `count > 1`.
|
||||||
|
- A list with a waveform row must contain only waveform rows, all with the same `count`; otherwise it is
|
||||||
|
invalid, with a message the form shows. (No zero-padding, no holding scalars across a block.)
|
||||||
|
- The channel total is capped at 256: scalars count 1, a `channels` row counts `count`, a waveform row counts 1.
|
||||||
|
Over the cap is invalid.
|
||||||
|
- `block` batching applies whenever the list has no waveform row; with a waveform row it is 1.
|
||||||
|
- The form shows a "Vector as" dropdown (`Channels` / `Waveform`) on rows whose count is above 1, and
|
||||||
|
the channel total in the subtitle.
|
||||||
|
- The earlier "Data flow" text saying every vector row forces `num_samples = 1` applies to waveform rows only.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Badge bar, readouts dock and slide-over panel -- design
|
||||||
|
|
||||||
|
Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the sidebar's tab switcher and the separate events pane with a layout built the way bench
|
||||||
|
scopes build theirs: setup is reached by *the object it configures* (a badge under the plot), results
|
||||||
|
are always on screen (a compact dock), and anything bigger than a readout opens in one slide-over
|
||||||
|
panel. The sidebar tabs and the left events pane go away.
|
||||||
|
|
||||||
|
## Why (the problems, as found in the code)
|
||||||
|
|
||||||
|
- **Unreadable tab titles.** `side_view` is pinned at 320 px (`min-sidebar-width` = `max-sidebar-width`
|
||||||
|
in `main-window.ui`), so four text tabs ("Measure / Signals / Analog out / Digital I/O") ellipsize,
|
||||||
|
and a source can add more.
|
||||||
|
- **Hard to find things.** The Signals tab stacks a trigger form of up to 15 rows (plus two
|
||||||
|
expanders) on top of the signal list. Cursors and measurements are in Measure, source controls are
|
||||||
|
in their own tabs, the header is icon-only, and the trigger/cursor toggles live apart from their
|
||||||
|
panels.
|
||||||
|
- **The events pane is a different application.** A `GtkPaned` + `GtkRevealer` on the left with its
|
||||||
|
own bold title and header, a dragged width that is not saved, thumbnails the rest of the UI does
|
||||||
|
not have, and a header toggle that is hidden until events exist.
|
||||||
|
|
||||||
|
## State of the art this follows
|
||||||
|
|
||||||
|
- Tektronix 5/6 Series: a badge bar under the waveform (a badge per channel/math waveform, plus
|
||||||
|
Horizontal, Trigger, Acquisition badges); a tap opens *only that item's* settings; results
|
||||||
|
(measurements, cursors, search) are a separate bar that does not cover the waveform.
|
||||||
|
- Scopy and PicoScope 7: a channel list at the edge and a settings panel that slides in for the
|
||||||
|
selected item (PicoScope lets it overlay or squeeze the trace).
|
||||||
|
- Saleae Logic 2: one side panel at a time; decoded results also in a list beside the waveform.
|
||||||
|
- Keysight InfiniiVision: dialogs that can be undocked so cursor and measurement values show together.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ header: [window 2 s ▾] [Trigger] [Pause] [Cursors] [Dock] [☰] ┐
|
||||||
|
├──────────────────────────────────────────────┬────────────────────┤
|
||||||
|
│ plot rows ┌───────────┐ │ READOUTS │
|
||||||
|
│ │ slide-over│ │ cursors, measures │
|
||||||
|
│ │ settings │ ├────────────────────┤
|
||||||
|
│ └───────────┘ │ EVENTS (n) │
|
||||||
|
├───────────────────────────────────────────────┴────────────────────┤
|
||||||
|
│ [●sine][●square][+] … [Trigger ▸][Source ▸]│
|
||||||
|
├ status line ───────────────────────────────────────────────────────┤
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header bar
|
||||||
|
|
||||||
|
Keeps the time-window control, the Trigger, Pause and Cursors toggles and the overflow menu. The
|
||||||
|
Events toggle is removed (Events is always in the dock). The sidebar toggle becomes the **Dock**
|
||||||
|
toggle, so a narrow window can still hide the dock. There is no "Window" badge: the header's control
|
||||||
|
is the timebase, and a second one would duplicate it.
|
||||||
|
|
||||||
|
### Right dock ("Readouts" and "Events")
|
||||||
|
|
||||||
|
- Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals
|
||||||
|
("disable, do not hide"). It stays an `AdwOverlaySplitView` sidebar, which already gives it its
|
||||||
|
own fixed width, the hide toggle and the overlay behaviour on a narrow window
|
||||||
|
(`invariants-plot-and-layout.md`).
|
||||||
|
- **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per
|
||||||
|
value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`).
|
||||||
|
An "Edit" affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row
|
||||||
|
opens that measurement's editor.
|
||||||
|
- **Events** (bottom): compact rows -- title, time, a small marker; no thumbnails. Insensitive outside
|
||||||
|
trigger mode. A "Manage" affordance opens the slide-over's `events` page.
|
||||||
|
|
||||||
|
### Badge bar
|
||||||
|
|
||||||
|
A fixed-height row between the plot and the status line.
|
||||||
|
|
||||||
|
- **Signals** (left): one badge per enabled signal, up to 6; beyond that a single "N signals" badge.
|
||||||
|
A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which
|
||||||
|
replaces today's drag from the signal list), and opens that signal's page (colour, calibration,
|
||||||
|
row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor
|
||||||
|
entry).
|
||||||
|
- **System** (right): **Trigger** shows its state (off / armed / triggered) and opens the trigger
|
||||||
|
form; **Source** shows the connection state and the source name and opens that source's pages
|
||||||
|
(`SourcePanel` pages, behind a small segmented switch inside the panel when there are several, as
|
||||||
|
E-1608 has Analog out and Digital I/O).
|
||||||
|
|
||||||
|
### Slide-over panel
|
||||||
|
|
||||||
|
- Overlays the plot's right edge (about 360 px wide), never the dock; one page at a time; Esc or a
|
||||||
|
click outside closes it. The plot is not resized by it.
|
||||||
|
- Pages, by id: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`,
|
||||||
|
`source-<id>`. Each page has its own scroller, so a tall page does not leave a shorter one scrolled
|
||||||
|
(the reason the sidebar had a scroller per page).
|
||||||
|
- Content: the trigger form (conditional rows, Advanced, Search the buffer), signal and calibration
|
||||||
|
settings, measurement add/edit/remove and statistics, cursor options, event actions (Live, Compare,
|
||||||
|
Accumulate), the math editor entry, and source pages.
|
||||||
|
|
||||||
|
### Status line
|
||||||
|
|
||||||
|
Unchanged.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
New files, because `ui.cpp` is already very large.
|
||||||
|
|
||||||
|
| Unit | File | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BadgeBar` | `ui_badges.cpp` | Builds signal / Trigger / Source badges from the model; emits "open page X"; owns badge drag. |
|
||||||
|
| `SlideOver` | `ui_slideover.cpp` | A `GtkOverlay` child of the plot area: revealer + page stack keyed by id; Esc/click-away; open/close API. |
|
||||||
|
| `ReadoutsDock` | `ui_dock.cpp` | The compact readouts and the compact events list, reading the same model the panes read today. |
|
||||||
|
|
||||||
|
Reuse, not rewrite:
|
||||||
|
|
||||||
|
- The trigger form, signal list and calibration, measurement editor and cursor options are
|
||||||
|
**reparented** into slide-over pages.
|
||||||
|
- `SourcePanel{id, title, widget}` is unchanged, so the E-1608 and Flat UDP panels need no change.
|
||||||
|
- Only the events *list and actions* of the old pane move; the left `GtkPaned`/`GtkRevealer` and its
|
||||||
|
divider sync are deleted.
|
||||||
|
|
||||||
|
## Invariants kept
|
||||||
|
|
||||||
|
- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width,
|
||||||
|
the badge bar a fixed height, and the slide-over overlays instead of resizing the plot. Rows that
|
||||||
|
appear by trigger condition appear inside the slide-over's scroller.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget
|
||||||
|
aligned to the end edge by margin and width, never a full-area transparent child, so pan, drop and
|
||||||
|
hover on the rest of the plot are untouched.
|
||||||
|
- The header's Trigger/Cursors/Pause toggles keep their two-way mirroring with the panels.
|
||||||
|
|
||||||
|
## State and session file
|
||||||
|
|
||||||
|
- The session file drops `side_tab` and gains the dock's shown state and width. An old file's
|
||||||
|
`side_tab` is ignored (an unknown name is already harmless to `select_side_tab`).
|
||||||
|
- The open slide-over page is not saved.
|
||||||
|
- The tab ids `measure`, `signals` and `source-<id>` stop being a session format; they remain page ids.
|
||||||
|
|
||||||
|
## What must be rewritten around it
|
||||||
|
|
||||||
|
- **Tour and context help:** steps and help-figure surfaces that point at `side_switcher`,
|
||||||
|
`side_stack` and `side_box` (`ui_tour.cpp`, `ui_context_help.cpp`) are re-pointed at the badge bar,
|
||||||
|
slide-over and dock, and the tour text is rewritten.
|
||||||
|
- **Probes:** the `switcher: policy=... pills=... labels=... images=... pages=...` line and
|
||||||
|
`side_tab_probe` become probes that count badges, dock rows and the open slide-over page; the events
|
||||||
|
divider probe goes away with the divider.
|
||||||
|
- **Keyboard:** `SideTabMeasure` / `SideTabSignals` become "open the measure / signals page".
|
||||||
|
- **Docs:** `README.md`, `USER-GUIDE.md`, `docs/claude/*` (architecture, verification,
|
||||||
|
probe-readings, the plot-and-layout and gtk-and-chrome invariants), and the Mallard manual's
|
||||||
|
figures.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Four steps, each its own plan, each leaving the app building, passing the probes and usable.
|
||||||
|
|
||||||
|
1. **Shell.** The plot overlay, the badge bar with Trigger and Source, the slide-over holding the
|
||||||
|
existing trigger form and source pages. The sidebar tabs are removed; the existing Measure and
|
||||||
|
Signals panels move into a temporary dock so nothing is lost.
|
||||||
|
2. **Signal badges.** Per-signal badges, the `+` and "N signals" overflow, drag to reorder and to
|
||||||
|
move to a row.
|
||||||
|
3. **Compact readouts.** Read-only cursors and measurements in the dock; their editors in the
|
||||||
|
slide-over.
|
||||||
|
4. **Events.** The compact events section in the dock, the left pane and divider deleted, then the
|
||||||
|
tour / help / docs sweep.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Overlay above the GL plot.** The panel must composite and take input correctly above the
|
||||||
|
plotter's GL area. The layout editor already places overlay children over the plot, so this is
|
||||||
|
expected to work; step 1 confirms it before anything else is built on it.
|
||||||
|
- **Reparenting the trigger form.** Its two-way mirroring with the header toggle, `sync_trigger_rows`
|
||||||
|
and the locked summary view must keep working from a hidden-by-default page. Step 1 verifies it with
|
||||||
|
the existing trigger probes.
|
||||||
|
- **Many signals.** The badge bar's six-badge limit and horizontal behaviour are the one place the
|
||||||
|
E-1608 (up to about 17 signals) stresses the design; step 2 includes a run on it.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
User-arranged or undockable panels, saved layouts of the panels themselves, a touch-specific mode,
|
||||||
|
and any change to the plot, trigger, measurement or source engines. Per-source tab names do not
|
||||||
|
change.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Horizontal plot split -- design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let each row of the stacked plots be split into several plots side by side. All plots share one
|
||||||
|
time window (one x link, one zoom/pan, one crosshair). A split only arranges more plots on screen; it
|
||||||
|
is not an independent time window and not an XY plot.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Shared time window.** Cells in a row, and rows in the figure, are all members of the one x link
|
||||||
|
group. No new time semantics.
|
||||||
|
2. **Per-row split.** Each figure row holds 1..4 cells, independently of the other rows.
|
||||||
|
3. **Draggable cell widths**, saved with the session, mirroring row heights (`weight`).
|
||||||
|
4. **Caps.** At most 4 cells per row and at most 8 plots in total (`RowSet::kMaxRows` keeps its value
|
||||||
|
and now bounds plots, not figure rows). Every per-plot structure sized for 8 stays as is.
|
||||||
|
5. **Approach.** Extend the vendored plotter's `Figure` so a figure row holds several plots; the host
|
||||||
|
treats each cell as one plot. Rejected: several `Figure`/`PlotArea`s linked by hand (cannot express a
|
||||||
|
per-row split, reintroduces manual x/crosshair sync) and a general tiling tree (not asked for).
|
||||||
|
|
||||||
|
## Plotter (`deps/plotter`)
|
||||||
|
|
||||||
|
- `Figure` is constructed from cells-per-row (e.g. `{1,2,1}`). `Figure(u32 rows)` stays and means one
|
||||||
|
cell per row, so existing behaviour and `tests/test_figure.cpp` are unchanged.
|
||||||
|
- `plot(i)` indexes the flattened cells, top to bottom, left to right. `sync_x_links()` and the
|
||||||
|
crosshair already work on any set of plots and need no change.
|
||||||
|
- New: `set_cell_weights(row, weights)` and `set_col_gap(px)`. Widths use the height scheme: a floor
|
||||||
|
per cell, the weights share the rest, the last cell closes the row exactly (no seam).
|
||||||
|
- `FigureLayout` gains `cells` (one `Rect` per plot, flattened). `rows` stays the full-width rect per
|
||||||
|
figure row.
|
||||||
|
- Only the bottom figure row's cells carry x labels and the x title; other cells get `XStrip::None`.
|
||||||
|
- The left y gutter stays one common maximum for the whole figure with the same reserved floor. A
|
||||||
|
per-column gutter would make cell width depend on content, which the layout rule forbids.
|
||||||
|
- A figure too narrow for the floor widths shrinks the cells proportionally and drops the column gap,
|
||||||
|
as heights do; no rect is ever negative.
|
||||||
|
- Pointer hit-testing finds the plot under the cursor by its cell rect.
|
||||||
|
- The change is documented in `docs/claude/build-and-run.md` with the other vendored changes.
|
||||||
|
|
||||||
|
## Host (`src/plot_host.*`, `src/ui_layout.cpp`, `src/layout_store.*`, `src/ui.cpp`)
|
||||||
|
|
||||||
|
- Everything keyed by a plot index keeps meaning "flattened plot index": cursors, measurements,
|
||||||
|
`bind()`, readouts, persistence layers, `cursors_pane_bound`.
|
||||||
|
- `RowSet` gains `cells_per_row_` and per-figure-row cell weights, re-applied after every rebuild like
|
||||||
|
`row_weights_`. `split_cell(i)` / `remove_cell(i)` change a row's cell count; a removed cell drops its
|
||||||
|
traces, a new cell starts empty. `insert_row` / `remove_row` act on figure rows and carry their cells.
|
||||||
|
- `row_count()` stays the plot count; `figure_row_count()` is added. The plan may rename if this reads
|
||||||
|
badly.
|
||||||
|
- Cursors: the vertical pair is one shared time drawn in every cell; horizontal rulers are per cell.
|
||||||
|
The crosshair is one x on every cell, the hovered cell shows its y.
|
||||||
|
- Y axes are per cell (auto or manual); fit-all fits each; wheel and axis drags act on the cell under
|
||||||
|
the pointer; an x gesture anywhere moves the shared window.
|
||||||
|
- A signal dropped on a cell lands in that cell with the same slot rule as a row. The signals page's
|
||||||
|
"place on row" choice lists cells ("row 2, left") only for split rows.
|
||||||
|
- Persistence layers fill free slots of the same cell.
|
||||||
|
|
||||||
|
## Layout editor
|
||||||
|
|
||||||
|
Mirrors the row controls:
|
||||||
|
|
||||||
|
- Each vertical boundary between two cells gets a `+` (insert a cell) and a drag arrow (resize the two
|
||||||
|
neighbours). Each row's left and right edge gets a `+`, so a one-cell row can be split from either side.
|
||||||
|
- Each cell keeps its top-left `×`: in a multi-cell row it removes the cell; in a one-cell row it
|
||||||
|
removes the row, as today. Insensitive when it would leave no plot.
|
||||||
|
- Cell `+` is insensitive at 4 cells in the row or 8 plots in total; the row `+` is insensitive at 8
|
||||||
|
plots in total.
|
||||||
|
- A divider drag moves the two neighbouring widths, each floored at about 96 px logical, relative to the
|
||||||
|
weights at drag start (as the row drag is). Saved on drag end.
|
||||||
|
- Outside the editor nothing changes except the extra plots.
|
||||||
|
|
||||||
|
## Session file
|
||||||
|
|
||||||
|
`[[plot]]` tables gain `row` (the figure row) and `width` (the cell's weight). A session without `row`
|
||||||
|
loads as one cell per row, so existing sessions open unchanged. Row `weight` stays per figure row (carried
|
||||||
|
on the row's first cell).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/test_figure.cpp`: `{1}` identical to today; `{1,2}` tiles exactly; weights and floors respected;
|
||||||
|
a narrow figure degrades without negative rects; x link and crosshair span all cells.
|
||||||
|
- New headless probe `UDPSCOPE_PROBE_SPLIT_MS`: split a row, drop a signal in each cell, drag a divider;
|
||||||
|
check the cells tile the width, the shared x range is identical, both vertical cursors line up in every
|
||||||
|
cell, widths survive a session round-trip, and a session without `row` still loads. Expect
|
||||||
|
`gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
- Regression: layout, cursors, measurements, readouts and E-1608 probes read as before; the baseline file
|
||||||
|
is re-baselined only where this feature explains a difference.
|
||||||
|
- Layout dumps and probes that print per-row layout gain a cells list and a column-weights line.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`,
|
||||||
|
`docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md` (cell rules),
|
||||||
|
`docs/claude/build-and-run.md` (plotter change), and `ui_context_help.cpp` where the layout editor is described.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Split rows share one y axis -- design
|
||||||
|
|
||||||
|
Amends `2026-11-03-horizontal-split-design.md`. Where the two disagree, this one governs.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The cells of a split row share one y range and draw one y axis, the way the rows of the figure share
|
||||||
|
one x window and draw one x axis. A split row reads as one wide plot with dividers.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Always shared, no option.** All cells of a figure row have one y range. An unsplit row is
|
||||||
|
unaffected.
|
||||||
|
2. **One y axis per row.** Only the leftmost cell has a y gutter and y labels. The other cells have
|
||||||
|
none, as only the bottom row carries x labels.
|
||||||
|
3. **Rulers are per row.** The two horizontal rulers (cursor pair) belong to the figure row and are
|
||||||
|
drawn at the same y in every cell. A drag in any cell moves them.
|
||||||
|
4. **Approach.** Extend the vendored plotter with a y link, symmetric to the x link. Rejected: syncing
|
||||||
|
the ranges from the host every frame (the plotter's own per-plot autoscale and the controller's
|
||||||
|
gestures would fight it, which is the problem the x link already solves).
|
||||||
|
|
||||||
|
## Plotter (`deps/plotter`)
|
||||||
|
|
||||||
|
- `Figure::set_share_row_y(bool)`, default off so existing figures and `tests/test_figure.cpp` are
|
||||||
|
unchanged. When on, the cells of each figure row form an implicit y link group. The group is rebuilt
|
||||||
|
with the cells.
|
||||||
|
- A y link syncs like the x link: in `layout()` a new `sync_y_links()` adopts a member's changed
|
||||||
|
`y_range` as the group range and pushes it to the other members. Wheel, pan, box zoom and axis drag
|
||||||
|
go through `set_y_range`, so they need no controller change.
|
||||||
|
- Auto scale: a member's own autoscale is suppressed (as `Axis::link_member` already blocks `Live`).
|
||||||
|
The group owns the auto flag and fits to the union of its members' visible traces; cells with no
|
||||||
|
trace are ignored; a log axis takes the union in axis space. `fit_all` and double-click fit the
|
||||||
|
group, not one cell. A manual `set_y_range` on a member clears the group's auto flag.
|
||||||
|
- `YStrip {Both, None}` and `Plot::set_y_strip_mode`, mirroring `XStrip`. `None` forces the y label
|
||||||
|
width, title width and reserved floor to 0. `Figure::layout` sets `None` on every cell but the
|
||||||
|
first of its row, structurally from the column index, never from content. `None` cells are skipped
|
||||||
|
in the common-gutter maximum.
|
||||||
|
- Cell widths: the row's y gutter is taken off the row width first, then the cells share the rest by
|
||||||
|
the existing `floor + extra*w/total` rule. Equal weights therefore give equal plot areas; the first
|
||||||
|
cell's rect is gutter plus its plot width, and the cell rects still tile the row exactly. The
|
||||||
|
floors apply to plot widths.
|
||||||
|
- A y axis-drag can only start in the leftmost cell (the others have no label area).
|
||||||
|
- Documented in `docs/claude/build-and-run.md` with the other vendored changes.
|
||||||
|
|
||||||
|
## Host (`src/plot_host.*`, `src/ui*.cpp`)
|
||||||
|
|
||||||
|
- `set_y_auto`, `set_y_range`, `fit_all`, and the Auto/Fixed dialog act on the figure row of the plot
|
||||||
|
they are given. The dialog reads the row's range.
|
||||||
|
- Rulers: one pair per figure row, stored on the row's first plot index and placed in every cell by
|
||||||
|
`place_cursors`; `sync_cursors` reads the dragged one back; `reset_cursors` and the measure level
|
||||||
|
marks use the row's range. The cursors pane lists the pair once per figure row.
|
||||||
|
- `row_unit` joins the units over all cells of the row, "" if mixed.
|
||||||
|
- `sync_compare_axes` is unchanged: it writes x and y once per `RowSet` row. The comparison panes are flat single-cell rows, so that is once per figure row and no y sharing is involved.
|
||||||
|
- `rebuild_all` turns the sharing on for the new `Figure` before the cells are populated.
|
||||||
|
- Readouts and measurements stay per trace and per plot index; only the axis is shared.
|
||||||
|
- Session file: unchanged (y ranges were never persisted).
|
||||||
|
|
||||||
|
## Layout editor
|
||||||
|
|
||||||
|
Divider drag maps the pointer to weights through plot widths, not cell widths: for the first cell,
|
||||||
|
subtract the row's gutter from the left rect before applying `frac = (want_left-64)/(pair-128)`. The
|
||||||
|
96 px logical floor applies to the plot area. Nothing else changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/test_figure.cpp`: with sharing on, a `{2}` row has equal y ranges after a `set_y_range` on
|
||||||
|
either cell; a second cell has zero y gutter and the first the common maximum; widths tile exactly and
|
||||||
|
equal weights give equal plot widths; group autoscale covers the union and ignores empty cells; sharing
|
||||||
|
off reproduces today's layout; `{1}` rows unaffected.
|
||||||
|
- Split probe gains: after a y gesture on one cell the other's range is identical; non-first cells have
|
||||||
|
no y gutter; fit-all covers both cells' traces; both rulers line up across cells; a divider drag still
|
||||||
|
tracks the pointer within 1 px.
|
||||||
|
- Regression: layout, cursors, measurements, readouts, E-1608 probes read as before; re-baseline only
|
||||||
|
what this feature explains. `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`,
|
||||||
|
`docs/claude/invariants-plot-and-layout.md` (the "one gutter, not per column" rule and the per-cell y
|
||||||
|
rule), `docs/claude/build-and-run.md`, `ui_context_help.cpp`, `CLAUDE.md` if it mentions per-cell y.
|
||||||
@@ -22,11 +22,13 @@
|
|||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<steps>
|
<steps>
|
||||||
<item>
|
<item>
|
||||||
<p>Press the cursors button in the toolbar, or <key>c</key>.</p>
|
<p>Press the cursors button in the header bar, or <key>c</key>. It switches the cursors on
|
||||||
|
and off.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p>The pane below the plot shows the readings and owns a <gui>Reset</gui> button.
|
<p>The readings are in the dock (a read-only copy) and on the <gui>Cursors</gui> page, which
|
||||||
Drag a marker to move it.</p>
|
<gui>Edit</gui> beside <gui>Cursors</gui> opens over the plot and which owns the mode
|
||||||
|
and a <gui>Reset</gui> button. Drag a marker to move it.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p>The mode dropdown offers four modes: <gui>vertical</gui> (the shared time pair),
|
<p>The mode dropdown offers four modes: <gui>vertical</gui> (the shared time pair),
|
||||||
@@ -42,7 +44,8 @@
|
|||||||
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
|
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
|
||||||
rows.</p></item>
|
rows.</p></item>
|
||||||
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
|
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
|
||||||
independent.</p></item>
|
independent. The plots of a split row are one row here: they share their y axis, so the
|
||||||
|
rulers are at the same values in each.</p></item>
|
||||||
<item><p><gui>mix</gui> is the reading mode: one line per <em>bound</em> trace, with the
|
<item><p><gui>mix</gui> is the reading mode: one line per <em>bound</em> trace, with the
|
||||||
trace's own values at each marker and their difference. A slot with a name but no
|
trace's own values at each marker and their difference. A slot with a name but no
|
||||||
channel behind it is not a trace and gets no line.</p></item>
|
channel behind it is not a trace and gets no line.</p></item>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 129 KiB |
@@ -5,8 +5,8 @@
|
|||||||
<info>
|
<info>
|
||||||
<link type="guide" xref="index#main"/>
|
<link type="guide" xref="index#main"/>
|
||||||
<link type="seealso" xref="signals"/>
|
<link type="seealso" xref="signals"/>
|
||||||
<desc>How the figure's rows and traces are arranged, how to drop a signal onto a row, and
|
<desc>How the figure's rows, plots and traces are arranged, how to drop a signal onto a plot, and
|
||||||
how the layout editor adds and removes rows.</desc>
|
how the layout editor adds and removes rows and splits a row into plots side by side.</desc>
|
||||||
<credit type="author copyright">
|
<credit type="author copyright">
|
||||||
<name>Martino Ferrari</name>
|
<name>Martino Ferrari</name>
|
||||||
</credit>
|
</credit>
|
||||||
@@ -14,9 +14,10 @@
|
|||||||
|
|
||||||
<title>Rows, layout and drag & drop</title>
|
<title>Rows, layout and drag & drop</title>
|
||||||
|
|
||||||
<p>The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share
|
<p>The figure is a stack of rows holding 1 to 8 plots in all, and a row can be split into up
|
||||||
one time axis and one pair of side margins, so their y labels line up, their data areas
|
to 4 plots side by side. Each plot holds up to 4 traces. Every plot shares one time axis and
|
||||||
start and end on the same two columns, and their windows cannot drift apart. The left
|
the figure has one pair of side margins, so the y labels line up, the data areas start and
|
||||||
|
end on the same two columns, and the windows cannot drift apart. The left
|
||||||
margin holds the y labels; the right one is empty, and is there so the newest tick's label
|
margin holds the y labels; the right one is empty, and is there so the newest tick's label
|
||||||
and the newest sample's ink have somewhere to land instead of sitting on the frame.</p>
|
and the newest sample's ink have somewhere to land instead of sitting on the frame.</p>
|
||||||
|
|
||||||
@@ -24,12 +25,12 @@
|
|||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<steps>
|
<steps>
|
||||||
<item>
|
<item>
|
||||||
<p>Drag a signal's chip out of the side panel (<key>s</key>) and drop it on a row to
|
<p>Drag a signal's badge from the badge bar onto a row to
|
||||||
add a trace.</p>
|
add a trace.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p>Press <key>l</key>, or pick <gui>Layout editor</gui> from the overflow menu, to add
|
<p>Press <key>l</key>, or pick <gui>Layout editor</gui> from the overflow menu, to add
|
||||||
and remove rows: a <gui>×</gui> appears at the top left of each row and a <gui>+</gui>
|
and remove rows: a <gui>×</gui> appears at the top left of each plot and a <gui>+</gui>
|
||||||
on each boundary between rows, including above the first and below the last.</p>
|
on each boundary between rows, including above the first and below the last.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
@@ -38,19 +39,45 @@
|
|||||||
</steps>
|
</steps>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="split">
|
||||||
|
<title>Splitting a row</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>In the layout editor, click the <gui>+</gui> at a row's left or right edge, or the
|
||||||
|
<gui>+</gui> between two plots, to add a plot to that row. The new plot starts empty.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Drag the double-headed arrow on the boundary between two plots to resize them. Neither plot can be
|
||||||
|
made narrower than about 96 px, and the widths are saved with the session.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Click the <gui>×</gui> of a plot in a split row to remove that plot and its traces.
|
||||||
|
In a row with a single plot the <gui>×</gui> removes the row instead.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
<p>All the plots of a row, and of the whole figure, share one time window: zooming or
|
||||||
|
panning along time in any of them moves all of them, and the vertical cursors are drawn in
|
||||||
|
every plot. The plots of a split row also share one y range: the y labels are drawn at the
|
||||||
|
left of the first plot only, the horizontal cursors sit at the same values in every plot, and a
|
||||||
|
zoom, pan, fit or fixed range in any of them applies to the whole row. The time labels
|
||||||
|
sit under the bottom row, and under any row above it whose columns differ from the row below. A row holds at most 4 plots and the figure at most 8
|
||||||
|
in total; the <gui>+</gui> buttons go grey at the limit.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="knowing">
|
<section id="knowing">
|
||||||
<title>Worth knowing</title>
|
<title>Worth knowing</title>
|
||||||
<list>
|
<list>
|
||||||
<item><p>A plot's own <gui>right-click menu</gui> (the same compact rows as the app's
|
<item><p>A plot's own <gui>right-click menu</gui> (the same compact rows as the app's
|
||||||
menu) holds the y-axis scale, the two fits, and <gui>Remove signal</gui>, which swaps
|
menu) holds the y-axis scale (for the whole row), the two fits, and <gui>Remove signal</gui>, which swaps
|
||||||
the menu to that plot's traces plus <gui>All traces on this plot</gui> — with a
|
the menu to that plot's traces plus <gui>All traces on this plot</gui> — with a
|
||||||
<gui>Back</gui> row to return. That is how a trace comes off a row without going through
|
<gui>Back</gui> row to return. That is how a trace comes off a row without going through
|
||||||
the layout editor.</p></item>
|
the layout editor.</p></item>
|
||||||
<item><p>A drop <em>adds</em> a trace to the row's next free slot; it does not replace
|
<item><p>A drop <em>adds</em> 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.</p></item>
|
one. With four traces already on a plot the drop is refused.</p></item>
|
||||||
<item><p>Inserting a row moves the rows below it <em>with their contents</em>. Inserting
|
<item><p>Inserting a row moves the rows below it <em>with their contents</em>, split
|
||||||
above row 1 puts what row 1 plotted onto row 2 and leaves the new row empty — the rows
|
rows included. Inserting above row 1 puts what row 1 plotted onto row 2 and leaves the
|
||||||
are not renumbered under you.</p></item>
|
new row empty — the rows are not renumbered under you. A split row's plots are named
|
||||||
|
<em>2a</em>, <em>2b</em> and so on.</p></item>
|
||||||
<item><p>A trace is remembered by the signal's <em>name</em>, not by its channel number,
|
<item><p>A trace is remembered by the signal's <em>name</em>, not by its channel number,
|
||||||
because a new CONFIG or an enable renumbers the channels and an index would silently
|
because a new CONFIG or an enable renumbers the channels and an index would silently
|
||||||
re-point the trace.</p></item>
|
re-point the trace.</p></item>
|
||||||
@@ -62,10 +89,11 @@
|
|||||||
|
|
||||||
<section id="example">
|
<section id="example">
|
||||||
<title>Example</title>
|
<title>Example</title>
|
||||||
<p>With three traces on row 1, pressing <key>l</key> shows 3 buttons (one <gui>×</gui>,
|
<p>With three traces on row 1, pressing <key>l</key> shows 7 buttons: the <gui>×</gui>,
|
||||||
two <gui>+</gui>). Clicking the <gui>+</gui> above row 1 gives 2 rows and 5 buttons, with
|
the two <gui>+</gui> above and below the row with their two resize arrows, and the two
|
||||||
the three traces now on row 2 — the layout probe reports exactly this as
|
<gui>+</gui> at the row's left and right edges. Clicking the <gui>+</gui> above row 1 gives 2 rows and 12 buttons,
|
||||||
<code>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</code>.</p>
|
with the three traces now on row 2 — the layout probe reports exactly this as
|
||||||
|
<code>rows=2 buttons=12 traces=[0:-|1:sine,ramp,noise]</code>.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</page>
|
</page>
|
||||||
@@ -22,10 +22,12 @@
|
|||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<steps>
|
<steps>
|
||||||
<item>
|
<item>
|
||||||
<p>Press the math button in the toolbar, or <key>g</key>.</p>
|
<p>Open the <gui>Signals</gui> page (the <gui>+</gui> badge, or <key>g</key>). Math channels
|
||||||
|
are listed under a <gui>MATH</gui> heading.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p>The pane's <gui>+</gui> adds a channel, and opening one shows the graph editor:
|
<p>The heading's <gui>+</gui> adds a channel. <gui>Edit graph</gui>, on a channel's page,
|
||||||
|
shows the graph editor:
|
||||||
nodes as boxes, click a box to select it and edit its parameters beside the canvas.</p>
|
nodes as boxes, click a box to select it and edit its parameters beside the canvas.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
@@ -53,7 +55,7 @@
|
|||||||
<em>above</em> it. A wire drawn right to left re-sorts the list silently so the graph
|
<em>above</em> it. A wire drawn right to left re-sorts the list silently so the graph
|
||||||
stays valid. A cycle is unrepresentable: the connection is refused rather than created,
|
stays valid. A cycle is unrepresentable: the connection is refused rather than created,
|
||||||
and the line beneath the form says why — a loop, or a node asked to read itself.</p></item>
|
and the line beneath the form says why — a loop, or a node asked to read itself.</p></item>
|
||||||
<item><p>A derived channel <em>is</em> a channel. It appears in the signal panel, can be
|
<item><p>A derived channel <em>is</em> a channel. It is listed with the signals, gets a badge once computed, can be
|
||||||
plotted, measured, exported, and used as a source for another math channel.</p></item>
|
plotted, measured, exported, and used as a source for another math channel.</p></item>
|
||||||
<item><p>The math runs on the <em>stored</em> samples — decimated and averaged — so a
|
<item><p>The math runs on the <em>stored</em> samples — decimated and averaged — so a
|
||||||
cutoff you type in Hz means the rate the filter will actually run at. Change the window
|
cutoff you type in Hz means the rate the filter will actually run at. Change the window
|
||||||
|
|||||||
@@ -28,10 +28,12 @@
|
|||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<steps>
|
<steps>
|
||||||
<item>
|
<item>
|
||||||
<p>Press the measurements button in the toolbar, or <key>m</key>.</p>
|
<p>Press <key>m</key>, or <gui>Edit</gui> beside <gui>Measurements</gui> in the dock. Either opens the
|
||||||
|
<gui>Measurements</gui> page over the plot; the dock itself shows each measurement's value,
|
||||||
|
and a click on one opens its dialog.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p>The pane's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
|
<p>The page's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
|
||||||
the type, the threshold share or shares and the on-plot marks are set, with
|
the type, the threshold share or shares and the on-plot marks are set, with
|
||||||
<gui>Save</gui> and <gui>Delete</gui>.</p>
|
<gui>Save</gui> and <gui>Delete</gui>.</p>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<info>
|
<info>
|
||||||
<link type="guide" xref="index#main"/>
|
<link type="guide" xref="index#main"/>
|
||||||
<desc>What udpscope plots, the four parts of the window, and one pass through the
|
<desc>What udpscope plots, the parts of the window, and one pass through the
|
||||||
application from connecting to saving.</desc>
|
application from connecting to saving.</desc>
|
||||||
<credit type="author copyright">
|
<credit type="author copyright">
|
||||||
<name>Martino Ferrari</name>
|
<name>Martino Ferrari</name>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<p>udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
|
<p>udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
|
||||||
each with up to four traces, and every row shares one time axis, so the whole figure
|
each with up to four traces, and every row shares one time axis, so the whole figure
|
||||||
scrolls together. Acquisition runs on its own thread and writes a ring buffer sized from
|
scrolls together. Acquisition runs on its own thread and writes a ring buffer sized from
|
||||||
<cmd>--buffer-mb</cmd>; the drawing thread reads whatever that ring holds when a frame
|
<cmd>--buffer-samples</cmd>; the drawing thread reads whatever that ring holds when a frame
|
||||||
starts, which is why a trace never makes the window stutter.</p>
|
starts, which is why a trace never makes the window stutter.</p>
|
||||||
|
|
||||||
<p>The stream is the clock. Every interval you read on screen is measured between
|
<p>The stream is the clock. Every interval you read on screen is measured between
|
||||||
@@ -34,11 +34,12 @@
|
|||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<title>The udpscope window</title>
|
<title>The udpscope window</title>
|
||||||
<desc>The header bar above the plots, the side panel on the right, and the status bar
|
<desc>The header bar above the plots, the badge bar and the status bar under them, and
|
||||||
along the bottom.</desc>
|
the dock on the right.</desc>
|
||||||
<media type="image" mime="image/png" src="figures/main-window.png" width="1280" height="800">
|
<media type="image" mime="image/png" src="figures/main-window.png" width="1280" height="800">
|
||||||
<p>One figure of three rows with four signals enabled, the Measure tab open in the
|
<p>One figure of three rows with three signals plotted, a badge for each in the badge
|
||||||
side panel, and the status bar reading <gui>live</gui>.</p>
|
bar, the dock with its readouts and its events block, and the status bar reading
|
||||||
|
<gui>live</gui>.</p>
|
||||||
</media>
|
</media>
|
||||||
</figure>
|
</figure>
|
||||||
</section>
|
</section>
|
||||||
@@ -55,7 +56,20 @@
|
|||||||
|
|
||||||
<p>The signals arrive <em>disabled</em>. Nothing is acquired until you turn one on in the
|
<p>The signals arrive <em>disabled</em>. Nothing is acquired until you turn one on in the
|
||||||
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
|
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
|
||||||
idle side panel is working correctly, not failing to connect.</p>
|
empty badge bar is working correctly, not failing to connect. For the same reason a start
|
||||||
|
that has enabled nothing prints no <code>buffer allocated:</code> line on stderr.</p>
|
||||||
|
|
||||||
|
<note style="warning">
|
||||||
|
<p>Over SSH with X11 forwarding the window may never appear, and the cure is
|
||||||
|
<cmd>udpscope --renderer cairo</cmd>. Two symptoms tell the two halves apart: a
|
||||||
|
<code>vkCreateSwapchainKHR() ... VK_ERROR_INITIALIZATION_FAILED</code> warning is
|
||||||
|
GTK's default Vulkan renderer failing to make a swapchain and is what the option's
|
||||||
|
<code>GSK_RENDERER=cairo</code> answers; a window whose allocation stays zero
|
||||||
|
(<code>window_button_w=0</code>) means the plot's GL area has no context either, and
|
||||||
|
that is what its <code>LIBGL_ALWAYS_SOFTWARE=1</code> answers. Both variables are
|
||||||
|
exported before GTK initialises, because they are read when the renderer and the GL
|
||||||
|
context are created. <cmd>--renderer gl</cmd> is faster when indirect GL works.</p>
|
||||||
|
</note>
|
||||||
|
|
||||||
<note style="tip">
|
<note style="tip">
|
||||||
<p>See <link xref="source">Choosing a source</link> for the chooser and the multicast
|
<p>See <link xref="source">Choosing a source</link> for the chooser and the multicast
|
||||||
@@ -68,30 +82,49 @@
|
|||||||
<terms>
|
<terms>
|
||||||
<item>
|
<item>
|
||||||
<title>The header bar</title>
|
<title>The header bar</title>
|
||||||
<p>Left to right: the time window (<gui>2 s</gui>), the trigger and the pause, the
|
<p>Left to right: the time window (<gui>2 s</gui>), the trigger and the pause; then, on
|
||||||
events, cursors and side-panel switches, and the menu, which holds
|
the right, the cursors switch, the dock switch and the menu, which holds
|
||||||
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export</gui>,
|
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export visible range</gui>,
|
||||||
<gui>Keyboard shortcuts</gui>, <gui>Help</gui> and <gui>About</gui>.</p>
|
<gui>Configurations</gui>, <gui>Help</gui>, <gui>Preferences</gui> and
|
||||||
</item>
|
<gui>About</gui>. The trigger button turns trigger mode on and off; the trigger's
|
||||||
<item>
|
settings are in the panel its badge opens.</p>
|
||||||
<title>The side panel</title>
|
|
||||||
<p>Two tabs. <gui>Measure</gui> holds the cursors readout and the measurements
|
|
||||||
derived from it; <gui>Signals</gui> holds the trigger and the channel list, where a
|
|
||||||
math channel is a row of that one list under a <gui>MATH</gui> heading whose
|
|
||||||
<gui>+</gui> creates one. One row per channel: its colour, an enable switch, its name
|
|
||||||
and its unit. Channels are dragged from here onto a plot row.</p>
|
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>The plot</title>
|
<title>The plot</title>
|
||||||
<p>Up to eight rows, each with its own y axis and up to four traces, all on one shared
|
<p>Up to eight rows, each with its own y axis and up to four traces, all on one shared
|
||||||
time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).</p>
|
time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).</p>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The badge bar</title>
|
||||||
|
<p>The strip under the plot. On the left, one badge for each signal you have switched
|
||||||
|
on, then a <gui>+</gui>; past six signals they fold into a single <gui>N signals</gui>
|
||||||
|
badge. On the right, <gui>Trigger</gui>, which names the trigger's state, and <gui>Source</gui>, which is there only when the
|
||||||
|
source has controls of its own. A badge opens its page in the panel described next;
|
||||||
|
a signal's badge is also what you drag onto a row.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The side panel over the plot</title>
|
||||||
|
<p>One panel, 360 px wide, slides over the plot's right edge and shows one page at a
|
||||||
|
time: the trigger, a signal's colour, calibration and rows, the list of signals to
|
||||||
|
add, the cursors, the measurements, the events, or a source's own controls. It closes
|
||||||
|
with a second click on the badge that opened it, with <key>Esc</key>, with
|
||||||
|
<gui>Close</gui>, or with a click on the plot, and it never resizes the plot.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The dock</title>
|
||||||
|
<p>The column on the right, which the dock button in the header bar (<key>s</key>)
|
||||||
|
shows and hides. Its <gui>Readouts</gui> are a read-only copy of the cursors and of
|
||||||
|
every measurement; <gui>Edit</gui> beside each heading opens that instrument's full
|
||||||
|
page. Under them, <gui>Events</gui> lists the captured events, newest first; a click
|
||||||
|
replays one and <gui>Manage</gui> opens the full list. While the dock is hidden, or the window is narrow and the
|
||||||
|
dock slides over the plot, the readouts show in a strip under the plot instead, as long as the cursors are on or there is a measurement.</p>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>The status bar</title>
|
<title>The status bar</title>
|
||||||
<p>Directly above the plot's bottom edge — the state word, the stored sample rate and
|
<p>The last line, under the badge bar — the state word, the stored sample rate and
|
||||||
the window, and the ⓘ beside it for packets, gaps and the buffer's own figures. The
|
the window, and the ⓘ beside it for packets, gaps and the buffer's own figures. The
|
||||||
second screenshot below is what a held capture looks like: <gui>event held</gui>, the
|
second screenshot below is what a capture looks like: the trigger marker at the edge
|
||||||
axis pinned to the event, and the trigger marker at the edge.</p>
|
and the axis pinned to the event.</p>
|
||||||
</item>
|
</item>
|
||||||
</terms>
|
</terms>
|
||||||
|
|
||||||
@@ -114,12 +147,12 @@
|
|||||||
and port.</p>
|
and port.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p><em>Enable a signal</em> in the signal list. The plot stays empty for the first
|
<p><em>Enable a signal</em> in the <gui>Signals</gui> page (the <gui>+</gui> badge, or <key>g</key>). The plot stays empty for the first
|
||||||
fraction of a second while the first window fills — the buffer holds only what has
|
fraction of a second while the first window fills — the buffer holds only what has
|
||||||
arrived.</p>
|
arrived.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p><em>Drag it onto a row.</em> Drop a second signal on another row, or the same
|
<p><em>Drag its badge onto a row.</em> Drop a second signal on another row, or the same
|
||||||
signal again for a second view of it. The colour is per signal, so it is the same one
|
signal again for a second view of it. The colour is per signal, so it is the same one
|
||||||
wherever it appears.</p>
|
wherever it appears.</p>
|
||||||
</item>
|
</item>
|
||||||
@@ -139,8 +172,10 @@
|
|||||||
got.</p>
|
got.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p><em>Measure it</em> — the measurements pane computes a statistic of the visible
|
<p><em>Measure it</em> — press <key>m</key>, or <gui>Edit</gui> beside
|
||||||
window, and <gui>show on plot</gui> marks it as a ruler or a pair of markers.</p>
|
<gui>Measurements</gui> in the dock. A measurement is a statistic of the visible
|
||||||
|
window, its value is shown in the dock, and <gui>show on plot</gui> marks it as a
|
||||||
|
ruler or a pair of markers.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p><em>Save the data</em> with <gui>Export visible range</gui>, which writes the
|
<p><em>Save the data</em> with <gui>Export visible range</gui>, which writes the
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<steps>
|
<steps>
|
||||||
<item>
|
<item>
|
||||||
<p>Press the pause button in the toolbar, or <key>Space</key>.</p>
|
<p>Press the pause button in the header bar, or <key>Space</key>.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p>Press it again to resume, which rebinds the live ring and puts the window back on
|
<p>Press it again to resume, which rebinds the live ring and puts the window back on
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
they open.</p>
|
they open.</p>
|
||||||
|
|
||||||
<p>A configuration also carries the settings that can only be decided when the buffers are
|
<p>A configuration also carries the settings that can only be decided when the buffers are
|
||||||
allocated: the <gui>buffer size</gui> (<cmd>--buffer-mb</cmd>), relative time, and the
|
allocated: the <gui>samples per signal</gui> (<cmd>--buffer-samples</cmd>), relative time, and the
|
||||||
packet capture's ring size. Opening a configuration from the chooser applies all three;
|
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
|
switching inside a running app applies the rest and says in the status line that these take
|
||||||
effect on the next start.</p>
|
effect on the next start.</p>
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
<list>
|
<list>
|
||||||
<item><p>What a configuration persists: the time window, the rows and each row's slot
|
<item><p>What a configuration persists: the time window, the rows and each row's slot
|
||||||
signals, the calibration, the trace colours, which signals were enabled, the math
|
signals, the calibration, the trace colours, which signals were enabled, the math
|
||||||
channels with their graphs, the measurements pane — and the startup settings: buffer
|
channels with their graphs, the measurements — and the startup settings: samples
|
||||||
size, relative time and the packet capture's ring size.</p></item>
|
per signal, relative time and the packet capture's ring size.</p></item>
|
||||||
<item><p>Signal identity is the <em>name</em> throughout, because a new CONFIG renumbers
|
<item><p>Signal identity is the <em>name</em> throughout, because a new CONFIG renumbers
|
||||||
the channels.</p></item>
|
the channels.</p></item>
|
||||||
<item><p>A file written by a run against a source whose streamer was down would name no
|
<item><p>A file written by a run against a source whose streamer was down would name no
|
||||||
|
|||||||
@@ -23,11 +23,17 @@
|
|||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<steps>
|
<steps>
|
||||||
<item>
|
<item>
|
||||||
<p>Open the side panel (<key>s</key>), which lists every signal with a switch.</p>
|
<p>Click the <gui>+</gui> badge in the badge bar, or press <key>g</key>. The
|
||||||
|
<gui>Signals</gui> page lists what can be added — the signals the source declares that
|
||||||
|
are not acquired yet, each with a tick that acquires it. Past six acquired signals the
|
||||||
|
badges fold into one <gui>N signals</gui> badge, and the page then lists every signal.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p>The <gui>Signal list</gui> item in the overflow menu opens the editor where the
|
<p>Once a signal is acquired its badge appears. Click it to open that signal's page: its
|
||||||
unit and the calibration live.</p>
|
details directly under its name — <gui>Unit</gui>, <gui>Gain</gui> and <gui>Offset</gui>,
|
||||||
|
each with a pencil button that edits that one value, then the type, size, range, sample
|
||||||
|
rate and timing the stream declared — its colour and <gui>Plot on</gui> buttons for the
|
||||||
|
rows.</p>
|
||||||
</item>
|
</item>
|
||||||
</steps>
|
</steps>
|
||||||
</section>
|
</section>
|
||||||
@@ -38,10 +44,13 @@
|
|||||||
<item><p>Signals start disabled, and enabling one is what starts acquiring it. A trigger
|
<item><p>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
|
on a signal nobody acquires can never fire, so arming a trigger enables its signal for
|
||||||
you.</p></item>
|
you.</p></item>
|
||||||
<item><p><gui>Calibrate</gui> in a signal's row menu opens the gain and offset it is
|
<item><p>A signal's page shows the gain and offset it is <em>currently</em> drawn
|
||||||
<em>currently</em> drawn through, so a trace already scaled by a gain of 2 shows 2 —
|
through, so a trace already scaled by a gain of 2 reads 2. The pencil beside a number opens
|
||||||
|
an editor for that number only, at its current value —
|
||||||
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
|
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
|
||||||
calibrated shows the identity, 1 and 0.</p></item>
|
calibrated shows the identity, 1 and 0. The unit can be renamed the same way (an empty
|
||||||
|
entry restores the one the stream declares); the new name is used in the readouts, the
|
||||||
|
measurements and the export, and is remembered with the session.</p></item>
|
||||||
<item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer
|
<item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer
|
||||||
and drops the history. So does enabling or disabling a signal, and so does a new
|
and drops the 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
|
CONFIG. An empty plot for a second after one of those is the ring refilling, not a
|
||||||
@@ -51,12 +60,12 @@
|
|||||||
refuses to put it in a row rather than binding a trace that would read nothing for
|
refuses to put it in a row rather than binding a trace that would read nothing for
|
||||||
ever.</p></item>
|
ever.</p></item>
|
||||||
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
|
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
|
||||||
on two rows is one colour and the side panel's chip identifies it unambiguously. A
|
on two rows is one colour, and the dot on its badge shows it. A signal on no row gets a
|
||||||
signal on no row gets a grey chip rather than none, so the panel's rows keep their
|
grey dot rather than none.</p></item>
|
||||||
height.</p></item>
|
<item><p><em>Change a signal's colour</em> with <gui>Change colour</gui> on its page; a
|
||||||
<item><p><em>Click a coloured chip to change that signal's colour.</em> A grey chip
|
signal on no row has no trace to colour, so the button is dimmed. The colour is saved
|
||||||
names no trace, so it is not clickable. The colour is saved with the session;
|
with the session; <gui>Default colour</gui> on the same page gives the palette entry
|
||||||
<gui>Default colour</gui> in the signal's ⋯ menu gives the palette entry back.</p></item>
|
back.</p></item>
|
||||||
</list>
|
</list>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
|
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
|
||||||
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
|
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
|
||||||
address <em>and</em> the configuration it belongs to — its name and note, the
|
address <em>and</em> the configuration it belongs to — its name and note, the
|
||||||
<gui>buffer size</gui>, and whether time is relative or absolute. A row's pencil
|
<gui>samples per signal</gui>, 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
|
button opens the same form on that configuration, pre-filled, and changes to its name
|
||||||
or address move it (the old file is deleted).</p>
|
or address move it (the old file is deleted).</p>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<section id="reach">
|
<section id="reach">
|
||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<p>The bar is always visible under the plot. Click <gui>ⓘ</gui> for the detail popover.</p>
|
<p>The bar is always visible at the foot of the window, under the badge bar. Click <gui>ⓘ</gui> for the detail popover.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="knowing">
|
<section id="knowing">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<info>
|
<info>
|
||||||
<link type="guide" xref="index#viewing"/>
|
<link type="guide" xref="index#viewing"/>
|
||||||
<link type="seealso" xref="pause"/>
|
<link type="seealso" xref="pause"/>
|
||||||
<desc>The trigger's four shapes, arming and hold-off, the event cache with its replay,
|
<desc>The trigger's four shapes, arming, forcing an acquisition and hold-off, the event cache with its replay,
|
||||||
compare and accumulate, and searching the buffer for a condition.</desc>
|
compare and accumulate, and searching the buffer for a condition.</desc>
|
||||||
<credit type="author copyright">
|
<credit type="author copyright">
|
||||||
<name>Martino Ferrari</name>
|
<name>Martino Ferrari</name>
|
||||||
@@ -23,18 +23,30 @@
|
|||||||
<title>How to reach it</title>
|
<title>How to reach it</title>
|
||||||
<steps>
|
<steps>
|
||||||
<item>
|
<item>
|
||||||
<p>The trigger button in the toolbar (<key>t</key>) reveals the trigger panel: the
|
<p>Click the <gui>Trigger</gui> badge at the right of the badge bar to open the
|
||||||
signal, the level, the shape, the edge (rising, falling, either), the mode (Normal or
|
trigger page: the signal, the level, the shape, the edge (rising, falling, either), the
|
||||||
Single) and the pre-trigger share.</p>
|
mode (Normal or Single) and the pre-trigger share. The trigger button in the header bar
|
||||||
|
(<key>t</key>) turns trigger mode on and off; it does not open the page.</p>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p><gui>Arm</gui> starts watching; <key>a</key> arms and disarms from the keyboard.
|
<p><gui>Arm</gui> starts watching; <key>a</key> arms and disarms from the keyboard.
|
||||||
When the pane is locked with its <gui>[edit]</gui> button — so that it states what the
|
When the page is locked with its edit button — so that it states what the
|
||||||
trigger is set to instead of showing the controls — <gui>Arm/Disarm</gui> is in its
|
trigger is set to instead of showing the controls — <gui>Arm/Disarm</gui> is in its
|
||||||
heading, and the trigger can be armed or disarmed without unfolding the panel. It is
|
heading, and the trigger can be armed or disarmed without unfolding the page. It is
|
||||||
offered only while trigger mode is on, because a trigger that is off has no engine to
|
offered only while trigger mode is on, because a trigger that is off has no engine to
|
||||||
arm.</p>
|
arm.</p>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><gui>Force</gui> takes one acquisition <em>now</em>, with the event window above,
|
||||||
|
without waiting for the condition to hold — for when the event you are waiting for is not
|
||||||
|
coming and you want to see what the signal is doing this instant. The capture runs out its
|
||||||
|
post-trigger share and then freezes, exactly like a latch: the marker sits at the instant
|
||||||
|
you pressed. In <gui>Normal</gui> mode the trigger goes back to waiting afterwards; in
|
||||||
|
<gui>Single</gui> mode it disarms, because one acquisition is the whole of what that mode
|
||||||
|
means either way. It needs a signal to be acquired — the trigger cannot watch one that is
|
||||||
|
switched off — and pressing it while a capture is already filling leaves that capture
|
||||||
|
alone, since it is the acquisition you asked for.</p>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<p><gui>Search the buffer</gui> is at the foot of the same panel.</p>
|
<p><gui>Search the buffer</gui> is at the foot of the same panel.</p>
|
||||||
</item>
|
</item>
|
||||||
@@ -82,34 +94,109 @@
|
|||||||
offers a pulse width to a window trigger.</p>
|
offers a pulse width to a window trigger.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="multiple-events">
|
||||||
|
<title>Multiple events: keeping the last few acquisitions</title>
|
||||||
|
<p><gui>multiple events</gui> decides how many of the most recent triggered acquisitions stay on
|
||||||
|
screen at once. It is <em>1</em> by default, which is the event alone — the display you get
|
||||||
|
when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn
|
||||||
|
at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned
|
||||||
|
on their own trigger edge, so the pre-trigger part of the event lines up from acquisition to
|
||||||
|
acquisition and whatever changes between them — jitter, ringing, the occasional glitch — shows
|
||||||
|
as a faint ghost behind the current trace. That is what a phosphor display does with
|
||||||
|
successive sweeps.</p>
|
||||||
|
|
||||||
|
<p>The older acquisitions are drawn <em>behind</em> the current one and nothing else changes
|
||||||
|
because of them: the axis still frames the event on screen, the trigger marker still marks
|
||||||
|
<em>its</em> edge, and the measurements still describe the current frame. Lowering
|
||||||
|
<gui>multiple events</gui> back to 1 remembers nothing and frees the retained acquisitions
|
||||||
|
immediately.</p>
|
||||||
|
|
||||||
|
<p>Two things bound how many are drawn, and the panel tells you when one of them bites.</p>
|
||||||
|
<list>
|
||||||
|
<item><p><em>Room in the plot.</em> Each older acquisition is an extra trace in the row that
|
||||||
|
plots its signal, so it needs a free trace slot there. A row holding four signals — the
|
||||||
|
layout the app starts with — has room for one older acquisition of each of them; a row per
|
||||||
|
signal has room for all seven.</p></item>
|
||||||
|
<item><p><em>Memory.</em> A retained acquisition is a whole window copied out of the ring,
|
||||||
|
so persistence may hold at most as much again as the buffer takes (see <cmd>--buffer-samples</cmd> and <cmd>--memory-limit-mb</cmd>).
|
||||||
|
On a long window at a high sample rate that bound is reached before the eighth layer.</p></item>
|
||||||
|
</list>
|
||||||
|
<section id="persistence">
|
||||||
|
<title>Persistence: how often the trace has been here</title>
|
||||||
|
<p><gui>persistence</gui> — the switch in the trigger panel's <gui>Advanced</gui> block — keeps
|
||||||
|
an <em>image</em> of the last acquisitions instead of their traces. Every acquisition is
|
||||||
|
accumulated into it, the one on screen included, so a cell's brightness means how many of them
|
||||||
|
passed through it: the sweep on screen is the brightest, the ones behind it add on top, and
|
||||||
|
where several agree the ink is brighter still. There is no stroke drawn over the image — the
|
||||||
|
acquisition on screen <em>is</em> its newest ink — so it fades with the rest in both modes. Each
|
||||||
|
signal is kept as its own image in that signal's own colour, so where two signals cross the same
|
||||||
|
place you see both, not one blended line. It is off by default.</p>
|
||||||
|
|
||||||
|
<p>Two things make it different from <gui>multiple events</gui> beside it, and the panel offers
|
||||||
|
both because they answer different questions. <em>It never runs out of room</em>: each signal's
|
||||||
|
image is sized to the plot on screen and capped, so a display of a thousand acquisitions costs
|
||||||
|
what a display of one costs — one image per signal, whatever the plot's trace slots, and a very
|
||||||
|
wide window does not make it grow — and there is no count to choose. <em>And it fades</em>, in
|
||||||
|
one of two ways — <gui>fades by</gui> picks them:</p>
|
||||||
|
<list>
|
||||||
|
<item><p><gui>acquisitions</gui>: every acquisition keeps
|
||||||
|
<gui>per acquisition</gui> of what was there before it (0.5, the default, is the schedule a
|
||||||
|
phosphor's afterglow has: 1, 0.5, 0.25, …). The display then shows the last few sweeps
|
||||||
|
whatever the trigger rate.</p></item>
|
||||||
|
<item><p><gui>time</gui>: the image fades by <gui>time constant s</gui> as the display runs —
|
||||||
|
one constant is the time for it to fall to a third of its ink — so a slow trigger leaves
|
||||||
|
faint sweeps and a fast one leaves bright ones, and the picture is the same whichever the
|
||||||
|
stream is doing.</p></item>
|
||||||
|
</list>
|
||||||
|
|
||||||
|
<p>Persistence is a <em>trigger mode</em> display, like the trigger itself: it is drawn while a
|
||||||
|
captured acquisition is on screen and goes away with the trigger, on a pause, and when you open
|
||||||
|
a stored waveform. Nothing about it moves the framing — the axis is still the current event's,
|
||||||
|
the older sweeps only add ink — and it reserves no space in the plot and cannot be clicked, so
|
||||||
|
it is never in the way of a cursor or a drop.</p>
|
||||||
|
|
||||||
|
<p>Both displays live in the trigger panel's <gui>Advanced</gui> block, together with the
|
||||||
|
trigger's other settings that are set up once and left alone.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<p>If a depth cannot be shown in full, a notice says how many layers fit, and the panel's
|
||||||
|
tooltip carries the same rule. Persistence is a display of what has already been captured: it
|
||||||
|
changes neither what is acquired nor what is measured, and it does nothing while the trigger
|
||||||
|
is off.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="cache">
|
<section id="cache">
|
||||||
<title>The event cache, replay and compare</title>
|
<title>The event cache, replay and compare</title>
|
||||||
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
|
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
|
||||||
(<file>~/.cache/udpscope/events/<source>/</file>) as a <file>.npy</file> of the
|
(<file>~/.cache/udpscope/events/<source>/</file>) as a <file>.npy</file> of the
|
||||||
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
|
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
|
||||||
samples. The <gui>Events</gui> pane on the left — opened from the toolbar's
|
samples. The <gui>Events</gui> block at the foot of the dock lists them, newest first, one
|
||||||
event-history button, which appears while recording is enabled, and collapsed until you
|
short row each: a marker for the event on screen, its title and its number. It is dimmed
|
||||||
ask for it — lists the last 32 newest first (time, duration, signals, rate), each row with
|
until recording is on, an event is stored or one is being replayed. <gui>Manage</gui>
|
||||||
its own picture beside the numbers.</p>
|
beside its heading opens the <gui>Events</gui> page over the plot, which keeps the last 32
|
||||||
|
(time, duration, signals, rate), each row with its own picture beside the numbers.</p>
|
||||||
|
|
||||||
<p>Clicking a row <em>replays</em> it: the stored event is loaded exactly like an exported
|
<p>Clicking a row in the dock <em>replays</em> it: the stored event is loaded exactly like an exported
|
||||||
file, <em>without changing the layout</em> — the rows, the colours, the window, the
|
file, <em>without changing the layout</em> — the rows, the colours, the window, the
|
||||||
cursors and the measurements stay where they are and read the stored data instead. A
|
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
|
replay leaves trigger mode, because a file has no edge to wait for. <gui>Live</gui>, on the
|
||||||
open. Drag the pane's edge to resize it. <gui>Live</gui> goes back to the stream; the
|
<gui>Events</gui> page, goes back to the stream; the status bar names the file until
|
||||||
status bar names the file until you do.</p>
|
you do.</p>
|
||||||
|
|
||||||
<p>Select two or more events in the list and the two buttons under the header act on them:
|
<p>On the <gui>Events</gui> page, select two or more events in the list and the two buttons
|
||||||
|
under its header act on them:
|
||||||
<gui>Compare</gui> puts two events in two plots side by side, keeping the layout you have
|
<gui>Compare</gui> 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
|
— one plot per event, the axes synchronised, so zooming a row in one zooms the same row in
|
||||||
the other — and <gui>Accumulate</gui> overlays up to four of them in the same traces,
|
the other — and <gui>Accumulate</gui> 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
|
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
|
are in both. They need events with the same signals and the same length. On this page a
|
||||||
selects, a <em>double</em> click replays.</p>
|
single click selects and a <em>double</em> click replays.</p>
|
||||||
|
|
||||||
<p><gui>Record events</gui>, in the trigger panel with the trigger's other options, is
|
<p><gui>Record events</gui>, on the trigger page with the trigger's other options, is
|
||||||
what decides whether anything is kept. It is <em>off by default</em> — nothing is written
|
what decides whether anything is kept. It is <em>off by default</em> — nothing is written
|
||||||
to disk until you ask — and with it off the history button goes with it.</p>
|
to disk until you ask — and with it off, and nothing stored, the dock's events block and
|
||||||
|
the page stay dimmed.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="search">
|
<section id="search">
|
||||||
@@ -178,7 +265,10 @@
|
|||||||
by live data.</p></item>
|
by live data.</p></item>
|
||||||
<item><p>The first edge after arming is only accepted once the history reaches back a
|
<item><p>The first edge after arming is only accepted once the history reaches back a
|
||||||
full pre-trigger window, so a capture may legitimately be shorter than you asked for.
|
full pre-trigger window, so a capture may legitimately be shorter than you asked for.
|
||||||
The axis is pinned to what the capture actually got.</p></item>
|
The axis is pinned to what the capture actually got. <gui>Force</gui> is the one
|
||||||
|
acquisition that skips that gate — it captures the history that exists, because "now"
|
||||||
|
is the whole of what you asked for — so on a ring that has just started filling its
|
||||||
|
left side is short even though the axis still shows only what it got.</p></item>
|
||||||
<item><p>Disarming <em>during</em> a capture publishes what the capture got to, so the
|
<item><p>Disarming <em>during</em> a capture publishes what the capture got to, so the
|
||||||
picture on screen at that moment stays on screen. No event fired, so the event count
|
picture on screen at that moment stays on screen. No event fired, so the event count
|
||||||
does not move.</p></item>
|
does not move.</p></item>
|
||||||
|
|||||||
@@ -56,11 +56,10 @@
|
|||||||
|
|
||||||
<section id="example">
|
<section id="example">
|
||||||
<title>Example</title>
|
<title>Example</title>
|
||||||
<p>1 MS/s with 200 MB of buffer. A 2 s window stores every sample
|
<p>1 MS/s with the default buffer of 8 388 608 samples per signal (8.39 s). A 2 s window
|
||||||
(<code>time x1</code>). Widen it to 600 s and the stride becomes <code>x16</code>: 625 S/s
|
stores every sample (<code>time x1</code>). Widen it to 600 s and the stride becomes
|
||||||
stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads
|
<code>x128</code>: 7.8 kS/s stored, anti-aliased at 3.9 kHz. A 5 kHz sine seen through that
|
||||||
<code>y=[-0.059, 0.079]</code> — attenuated to about 6%, not folded down to a
|
window is attenuated by the filter, not folded down to a full-amplitude low-frequency one.</p>
|
||||||
full-amplitude 125 Hz one.</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</page>
|
</page>
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
|
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
#include <udps_client.h>
|
|
||||||
|
|
||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
|
||||||
#include <glib/gstdio.h>
|
#include <glib/gstdio.h>
|
||||||
|
|
||||||
@@ -29,22 +28,6 @@ namespace {
|
|||||||
/// ring to make room for samples that are not coming.
|
/// ring to make room for samples that are not coming.
|
||||||
constexpr double kGlitchPackets = 256.0;
|
constexpr double kGlitchPackets = 256.0;
|
||||||
|
|
||||||
SignalInfo to_info(const udps_signal_t& s) {
|
|
||||||
SignalInfo info;
|
|
||||||
info.name = s.name;
|
|
||||||
info.unit = s.unit;
|
|
||||||
info.type_code = s.type_code;
|
|
||||||
info.quant_type = s.quant_type;
|
|
||||||
info.rows = s.num_rows;
|
|
||||||
info.cols = s.num_cols;
|
|
||||||
info.range_min = s.range_min;
|
|
||||||
info.range_max = s.range_max;
|
|
||||||
info.time_mode = s.time_mode;
|
|
||||||
info.sampling_rate = s.sampling_rate;
|
|
||||||
info.time_signal_idx = s.time_signal_idx;
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {}
|
Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {}
|
||||||
@@ -52,31 +35,21 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model),
|
|||||||
Acquisition::~Acquisition() { stop(); }
|
Acquisition::~Acquisition() { stop(); }
|
||||||
|
|
||||||
void Acquisition::start() {
|
void Acquisition::start() {
|
||||||
// A started -- or *restarted* -- client is a new stream. Going live from a file view is the restart that
|
// A started -- or *restarted* -- source is a new stream. Going live from a file view is the restart that
|
||||||
// matters: the store still holds the event's samples, and the new stream's clock can sit behind their
|
// matters: the store still holds the event's samples, and the new stream's clock can sit behind their
|
||||||
// stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing
|
// stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing
|
||||||
// was the symptom -- every packet pinned flat at the file view's last stamp.
|
// was the symptom -- every packet pinned flat at the file view's last stamp.
|
||||||
model_.reset_stream();
|
model_.reset_stream();
|
||||||
last_stamp_ = -std::numeric_limits<double>::infinity();
|
last_stamp_ = -std::numeric_limits<double>::infinity();
|
||||||
udps_client_config_t c;
|
if (!source_) {
|
||||||
udps_client_config_init(&c);
|
SourceKind* kind = find_kind(cfg_.source_kind);
|
||||||
c.server_addr = cfg_.server.c_str();
|
if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind);
|
||||||
c.server_port = cfg_.port;
|
source_ = kind->make(cfg_.source_config());
|
||||||
c.multicast_group = cfg_.multicast.empty() ? nullptr : cfg_.multicast.c_str();
|
|
||||||
c.interface_addr = cfg_.interface_addr.empty() ? nullptr : cfg_.interface_addr.c_str();
|
|
||||||
c.recv_buffer_bytes = static_cast<std::uint32_t>(cfg_.recv_buffer_bytes);
|
|
||||||
c.silence_timeout_s = 2.0;
|
|
||||||
c.reconnect_delay_s = 0.5;
|
|
||||||
c.keepalive_interval_s = 1.0;
|
|
||||||
|
|
||||||
client_ = udps_client_create(&c);
|
|
||||||
if (client_ == nullptr) {
|
|
||||||
throw std::runtime_error("could not create the UDPS client for " + cfg_.server + ":" +
|
|
||||||
std::to_string(cfg_.port));
|
|
||||||
}
|
}
|
||||||
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
|
source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) {
|
||||||
&Acquisition::event_trampoline, this);
|
on_datagram(bytes, len, recv_time);
|
||||||
udps_client_set_datagram_cb(client_, &Acquisition::datagram_trampoline, this);
|
});
|
||||||
|
source_->open(*this);
|
||||||
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
|
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
|
||||||
// its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
|
// 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.
|
// The open itself still happens on the producer thread, at the first datagram.
|
||||||
@@ -90,35 +63,24 @@ void Acquisition::start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Acquisition::stop() {
|
void Acquisition::stop() {
|
||||||
if (!running_.exchange(false, std::memory_order_acq_rel)) {
|
if (running_.exchange(false, std::memory_order_acq_rel) && thread_.joinable()) thread_.join();
|
||||||
if (client_ != nullptr) {
|
if (source_) source_->close();
|
||||||
udps_client_destroy(client_);
|
|
||||||
client_ = nullptr;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (thread_.joinable()) thread_.join();
|
|
||||||
if (client_ != nullptr) {
|
|
||||||
udps_client_destroy(client_);
|
|
||||||
client_ = nullptr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Acquisition::run() {
|
void Acquisition::run() {
|
||||||
while (running_.load(std::memory_order_acquire)) {
|
while (running_.load(std::memory_order_acquire)) {
|
||||||
udps_client_poll(client_, 50);
|
source_->poll(50);
|
||||||
udps_stats_t s;
|
const SourceStats s = source_->stats();
|
||||||
udps_client_stats(client_, &s);
|
|
||||||
StreamStats& out = model_.stats();
|
StreamStats& out = model_.stats();
|
||||||
out.packets.store(s.packets_received, std::memory_order_relaxed);
|
out.packets.store(s.packets, std::memory_order_relaxed);
|
||||||
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
|
out.frames.store(s.frames, std::memory_order_relaxed);
|
||||||
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
|
out.gaps.store(s.gaps, std::memory_order_relaxed);
|
||||||
out.resequenced.store(s.late_resequenced, std::memory_order_relaxed);
|
out.resequenced.store(s.resequenced, std::memory_order_relaxed);
|
||||||
out.late.store(s.late_delivered, std::memory_order_relaxed);
|
out.late.store(s.late, std::memory_order_relaxed);
|
||||||
out.stale.store(s.stale_packets, std::memory_order_relaxed);
|
out.stale.store(s.stale, std::memory_order_relaxed);
|
||||||
out.overflows.store(s.reorder_overflows, std::memory_order_relaxed);
|
out.overflows.store(s.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(s.connected, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,19 +89,25 @@ void Acquisition::set_capture_want(bool want, std::uint32_t slots) {
|
|||||||
if (slots > 0) capture_slots_ = slots;
|
if (slots > 0) capture_slots_ = slots;
|
||||||
if (capture_path_.empty()) {
|
if (capture_path_.empty()) {
|
||||||
// Keyed on the source: one path per source, the previous capture kept one deep.
|
// Keyed on the source: one path per source, the previous capture kept one deep.
|
||||||
std::string host = cfg_.server;
|
std::string host = cfg_.session_host();
|
||||||
for (char& ch : host) {
|
for (char& ch : host) {
|
||||||
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
|
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
|
||||||
}
|
}
|
||||||
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.port);
|
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.session_port());
|
||||||
g_mkdir_with_parents(dir.c_str(), 0700);
|
g_mkdir_with_parents(dir.c_str(), 0700);
|
||||||
capture_path_ = dir + "/capture.bin";
|
capture_path_ = dir + "/capture.bin";
|
||||||
std::uint32_t addr = 0;
|
std::uint32_t addr = 0;
|
||||||
struct ::in_addr a;
|
std::uint16_t peer_port = 0;
|
||||||
if (::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) {
|
if (cfg_.source_kind == "udps") {
|
||||||
addr = a.s_addr; // already network order, which is what the header wants
|
// The source config, not cfg_.server/port: --source-opt overrides only the former.
|
||||||
|
const SourceConfig sc = cfg_.source_config();
|
||||||
|
struct ::in_addr a;
|
||||||
|
if (::inet_pton(AF_INET, sc.get("server", "127.0.0.1").c_str(), &a) == 1) {
|
||||||
|
addr = a.s_addr; // already network order, which is what the header wants
|
||||||
|
}
|
||||||
|
peer_port = static_cast<std::uint16_t>(sc.get_int("port", 8000));
|
||||||
}
|
}
|
||||||
capture_.set_peer(addr, cfg_.port);
|
capture_.set_peer(addr, peer_port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
capture_want_.store(want, std::memory_order_release);
|
capture_want_.store(want, std::memory_order_release);
|
||||||
@@ -171,29 +139,20 @@ void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Acquisition::datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
|
void Acquisition::on_config(const std::vector<SignalInfo>& info, std::uint8_t publish_mode) {
|
||||||
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,
|
|
||||||
std::uint8_t publish_mode) {
|
|
||||||
std::vector<SignalInfo> info;
|
|
||||||
info.reserve(count);
|
|
||||||
for (std::uint32_t i = 0; i < count; ++i) info.push_back(to_info(signals[i]));
|
|
||||||
|
|
||||||
// One line per CONFIG on stderr: what the stream says it is. This is the first
|
// One line per CONFIG on stderr: what the stream says it is. This is the first
|
||||||
// thing to look at when a signal refuses to plot.
|
// thing to look at when a signal refuses to plot.
|
||||||
static const char* const kModes[] = {"strict", "accumulate", "decimate"};
|
static const char* const kModes[] = {"strict", "accumulate", "decimate"};
|
||||||
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n", count,
|
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n",
|
||||||
|
static_cast<unsigned>(info.size()),
|
||||||
publish_mode < 3 ? kModes[publish_mode] : "?");
|
publish_mode < 3 ? kModes[publish_mode] : "?");
|
||||||
for (const SignalInfo& sig : info) {
|
for (const SignalInfo& sig : info) {
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n",
|
"[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n",
|
||||||
sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate,
|
sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate,
|
||||||
sig.time_mode == UDPS_TIME_PACKET ? "packet"
|
sig.time_mode == kTimePacket ? "packet"
|
||||||
: sig.time_mode == UDPS_TIME_FULL_ARRAY ? "full-array"
|
: sig.time_mode == kTimeFullArray ? "full-array"
|
||||||
: sig.time_mode == UDPS_TIME_FIRST_SAMPLE ? "first-sample"
|
: sig.time_mode == kTimeFirstSample ? "first-sample"
|
||||||
: "last-sample",
|
: "last-sample",
|
||||||
sig.time_signal_idx, sig.unit.c_str());
|
sig.time_signal_idx, sig.unit.c_str());
|
||||||
}
|
}
|
||||||
@@ -213,14 +172,14 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
|||||||
time_source_ = 0;
|
time_source_ = 0;
|
||||||
reset_session_state();
|
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(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Acquisition::on_event(int event, const char* detail) {
|
void Acquisition::on_event(SourceEvent event, const char* detail) {
|
||||||
(void)detail;
|
(void)detail;
|
||||||
if (event == UDPS_EVENT_CONNECTED) {
|
if (event == SourceEvent::Connected) {
|
||||||
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 == SourceEvent::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*
|
// 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
|
// session's numbering and clocks is now a lie about the next one. Clearing it here
|
||||||
@@ -300,15 +259,15 @@ bool Acquisition::gap_plausible(std::uint32_t missing, double elapsed, const Par
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal,
|
std::uint32_t Acquisition::build_stamps(const Frame& frame, int time_signal,
|
||||||
bool counter_gap_refused) {
|
bool counter_gap_refused) {
|
||||||
// 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
|
||||||
// element block. The largest of those is how many base ticks this frame spans --
|
// element block. The largest of those is how many base ticks this frame spans --
|
||||||
// in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples.
|
// in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples.
|
||||||
std::uint32_t n = frame->num_samples;
|
std::uint32_t n = frame.num_samples;
|
||||||
for (std::uint32_t i = 0; i < frame->num_signals; ++i) {
|
for (std::uint32_t i = 0; i < frame.num_signals; ++i) {
|
||||||
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
|
n = std::max(n, frame.signals[i].elements());
|
||||||
}
|
}
|
||||||
stamps_.assign(n, 0.0);
|
stamps_.assign(n, 0.0);
|
||||||
abs_stamps_.assign(n, 0.0);
|
abs_stamps_.assign(n, 0.0);
|
||||||
@@ -359,9 +318,9 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
// refused, because the advance and the reservation are the *same* claim about the wire:
|
// refused, because the advance and the reservation are the *same* claim about the wire:
|
||||||
// believing a phantom gap here would stretch the timeline by exactly the loss that did not
|
// believing a phantom gap here would stretch the timeline by exactly the loss that did not
|
||||||
// happen.
|
// happen.
|
||||||
if (!frame->late && frame->lost > 0 && batch_ > 0 && !counter_gap_refused &&
|
if (!frame.late && frame.lost > 0 && batch_ > 0 && !counter_gap_refused &&
|
||||||
double(frame->lost) <= kGlitchPackets) {
|
double(frame.lost) <= kGlitchPackets) {
|
||||||
rel_base_s_ += double(frame->lost) * double(batch_) * dt;
|
rel_base_s_ += double(frame.lost) * double(batch_) * dt;
|
||||||
}
|
}
|
||||||
for (std::uint32_t k = 0; k < n; ++k) {
|
for (std::uint32_t k = 0; k < n; ++k) {
|
||||||
stamps_[k] = rel_base_s_ + double(k) * dt;
|
stamps_[k] = rel_base_s_ + double(k) * dt;
|
||||||
@@ -370,14 +329,14 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
// The absolute clock is still read, for export only. When the stream carries no usable
|
// 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
|
// 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.
|
// 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)) {
|
if (time_signal >= 0 && time_signal < static_cast<int>(frame.num_signals)) {
|
||||||
const udps_signal_t& ts = frame->signals[time_signal];
|
const SignalInfo& ts = frame.signals[time_signal];
|
||||||
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
|
const bool scalar_time = ts.elements() == 1;
|
||||||
for (std::uint32_t k = 0; k < n; ++k) {
|
for (std::uint32_t k = 0; k < n; ++k) {
|
||||||
abs_stamps_[k] =
|
abs_stamps_[k] =
|
||||||
scalar_time
|
scalar_time
|
||||||
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
|
? frame.value(static_cast<std::uint32_t>(time_signal), k, 0)
|
||||||
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
|
: frame.value(static_cast<std::uint32_t>(time_signal), 0, k);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
||||||
@@ -411,27 +370,27 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
// arrival clock for the rest of its life (until a CONFIG says otherwise): letting
|
// arrival clock for the rest of its life (until a CONFIG says otherwise): letting
|
||||||
// the decision flip per frame is what interleaved two timelines in one ring.
|
// the decision flip per frame is what interleaved two timelines in one ring.
|
||||||
if (time_source_ != 2 && time_signal >= 0 &&
|
if (time_source_ != 2 && time_signal >= 0 &&
|
||||||
time_signal < static_cast<int>(frame->num_signals)) {
|
time_signal < static_cast<int>(frame.num_signals)) {
|
||||||
const udps_signal_t& ts = frame->signals[time_signal];
|
const SignalInfo& ts = frame.signals[time_signal];
|
||||||
// How the client indexes a signal's values depends on its shape
|
// How a frame indexes a signal's values depends on its shape
|
||||||
// (`udps_frame_value`): a scalar carries one value per batched cycle, so the
|
// (`Frame::value`): a scalar carries one value per batched cycle, so the
|
||||||
// stamp of sample k is (sample=k, elem=0); a multi-element signal carries one
|
// stamp of sample k is (sample=k, elem=0); a multi-element signal carries one
|
||||||
// *sample* of `elements` values per packet, so the stamp of sample k is
|
// *sample* of `elements` values per packet, so the stamp of sample k is
|
||||||
// (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time
|
// (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time
|
||||||
// signals -- the only mode that carries a stamp per element -- read as
|
// signals -- the only mode that carries a stamp per element -- read as
|
||||||
// element 0 for every sample and were then judged degenerate.
|
// element 0 for every sample and were then judged degenerate.
|
||||||
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
|
const bool scalar_time = ts.elements() == 1;
|
||||||
auto value_of = [&](std::uint32_t k) {
|
auto value_of = [&](std::uint32_t k) {
|
||||||
return scalar_time
|
return scalar_time
|
||||||
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
|
? frame.value(static_cast<std::uint32_t>(time_signal), k, 0)
|
||||||
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
|
: frame.value(static_cast<std::uint32_t>(time_signal), 0, k);
|
||||||
};
|
};
|
||||||
switch (ts.time_mode) {
|
switch (ts.time_mode) {
|
||||||
case UDPS_TIME_FULL_ARRAY:
|
case kTimeFullArray:
|
||||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k);
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k);
|
||||||
used_stream_time = true;
|
used_stream_time = true;
|
||||||
break;
|
break;
|
||||||
case UDPS_TIME_FIRST_SAMPLE: {
|
case kTimeFirstSample: {
|
||||||
// Only element 0 is meaningful here: the producer stamps the batch's first
|
// Only element 0 is meaningful here: the producer stamps the batch's first
|
||||||
// sample and says nothing about the rest, so the cadence has to space them --
|
// sample and says nothing about the rest, so the cadence has to space them --
|
||||||
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
|
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
|
||||||
@@ -445,7 +404,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
used_stream_time = true;
|
used_stream_time = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case UDPS_TIME_LAST_SAMPLE: {
|
case kTimeLastSample: {
|
||||||
const double anchor = value_of(n > 0 ? n - 1 : 0);
|
const double anchor = value_of(n > 0 ? n - 1 : 0);
|
||||||
for (std::uint32_t k = 0; k < n; ++k)
|
for (std::uint32_t k = 0; k < n; ++k)
|
||||||
stamps_[k] = anchor - double(n - 1 - k) * cadence;
|
stamps_[k] = anchor - double(n - 1 - k) * cadence;
|
||||||
@@ -549,7 +508,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
// was produced, and the earlier ones are dated backwards. A gap (`lost`)
|
// was produced, and the earlier ones are dated backwards. A gap (`lost`)
|
||||||
// shows up as extra elapsed time between arrivals, which is exactly the
|
// shows up as extra elapsed time between arrivals, which is exactly the
|
||||||
// spacing the samples had.
|
// spacing the samples had.
|
||||||
const double end = frame->recv_time;
|
const double end = frame.recv_time;
|
||||||
if (!have_arrival_epoch_) {
|
if (!have_arrival_epoch_) {
|
||||||
arrival_epoch_ = end;
|
arrival_epoch_ = end;
|
||||||
have_arrival_epoch_ = true;
|
have_arrival_epoch_ = true;
|
||||||
@@ -605,7 +564,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
// to a dead clock; they do not, and the wipe is what made a single glitching packet
|
// to a dead clock; they do not, and the wipe is what made a single glitching packet
|
||||||
// look like the stream restarting from zero.
|
// 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;
|
||||||
}
|
}
|
||||||
@@ -625,7 +584,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Acquisition::on_data(const udps_frame_t* frame) {
|
void Acquisition::on_data(const Frame& 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;
|
||||||
@@ -633,8 +592,8 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// The counter is this frame's identity and the hole's address. A *late* frame's counter is
|
// 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
|
// behind the sequence, so it must not move the mark -- the hole it belongs in was addressed
|
||||||
// from the counter that revealed it.
|
// from the counter that revealed it.
|
||||||
if (!have_counter_ || static_cast<std::int32_t>(frame->counter - last_counter_) > 0) {
|
if (!have_counter_ || static_cast<std::int32_t>(frame.counter - last_counter_) > 0) {
|
||||||
last_counter_ = frame->counter;
|
last_counter_ = frame.counter;
|
||||||
have_counter_ = true;
|
have_counter_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +627,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// Placed here and not earlier because `part`/`channels` are not in scope above, and not
|
// 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
|
// 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.
|
// backwards and can throw the whole stream onto the arrival clock.
|
||||||
if (frame->late) {
|
if (frame.late) {
|
||||||
backfill_from(frame, part, channels, model_.live(), part.time_stride);
|
backfill_from(frame, part, channels, model_.live(), part.time_stride);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -688,7 +647,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// (full-array, more than one element, no declared rate).
|
// (full-array, more than one element, no declared rate).
|
||||||
for (std::size_t i = 0; i < model_.signals().size(); ++i) {
|
for (std::size_t i = 0; i < model_.signals().size(); ++i) {
|
||||||
const SignalInfo& sig = model_.signals()[i];
|
const SignalInfo& sig = model_.signals()[i];
|
||||||
if (sig.time_mode == UDPS_TIME_FULL_ARRAY && sig.elements() > 1 &&
|
if (sig.time_mode == kTimeFullArray && sig.elements() > 1 &&
|
||||||
sig.sampling_rate == 0.0) {
|
sig.sampling_rate == 0.0) {
|
||||||
time_signal = static_cast<int>(i);
|
time_signal = static_cast<int>(i);
|
||||||
break;
|
break;
|
||||||
@@ -702,11 +661,11 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage
|
// let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage
|
||||||
// smooths the interval to 40 ms and then refuses itself.
|
// smooths the interval to 40 ms and then refuses itself.
|
||||||
const bool first_frame = !have_prev_frame_recv_;
|
const bool first_frame = !have_prev_frame_recv_;
|
||||||
const double since_prev = first_frame ? 0.0 : frame->recv_time - prev_frame_recv_;
|
const double since_prev = first_frame ? 0.0 : frame.recv_time - prev_frame_recv_;
|
||||||
const std::uint32_t missing = frame->lost;
|
const std::uint32_t missing = frame.lost;
|
||||||
const bool gap_refused =
|
const bool gap_refused =
|
||||||
missing > 0 && !gap_plausible(missing, since_prev, part, part.time_stride);
|
missing > 0 && !gap_plausible(missing, since_prev, part, part.time_stride);
|
||||||
note_frame_arrival(frame->recv_time);
|
note_frame_arrival(frame.recv_time);
|
||||||
|
|
||||||
const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
|
const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
|
||||||
if (n == 0) return;
|
if (n == 0) return;
|
||||||
@@ -721,7 +680,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
epoch_adopted_ = true;
|
epoch_adopted_ = true;
|
||||||
model_.set_epoch_if_unset(stamps_[0]);
|
model_.set_epoch_if_unset(stamps_[0]);
|
||||||
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
|
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
|
||||||
model_.epoch(), frame->recv_time, n);
|
model_.epoch(), frame.recv_time, n);
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
}
|
}
|
||||||
const double epoch = model_.epoch();
|
const double epoch = model_.epoch();
|
||||||
@@ -930,7 +889,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0,
|
edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0,
|
||||||
snap ? snap->post_s : 0.0,
|
snap ? snap->post_s : 0.0,
|
||||||
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0),
|
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0),
|
||||||
frame->recv_time, static_cast<unsigned long long>(tick_),
|
frame.recv_time, static_cast<unsigned long long>(tick_),
|
||||||
std::chrono::duration<double>(std::chrono::steady_clock::now() - started_)
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - started_)
|
||||||
.count());
|
.count());
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
@@ -1012,9 +971,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// the cycle's sample at element `k`. Either way element 0 is the channel.
|
// the cycle's sample at element `k`. Either way element 0 is the channel.
|
||||||
const double raw =
|
const double raw =
|
||||||
ch.elements > 1
|
ch.elements > 1
|
||||||
? udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), 0,
|
? frame.value(static_cast<std::uint32_t>(ch.signal), 0,
|
||||||
std::min<std::uint32_t>(k, ch.elements - 1))
|
std::min<std::uint32_t>(k, ch.elements - 1))
|
||||||
: udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), k, 0);
|
: frame.value(static_cast<std::uint32_t>(ch.signal), k, 0);
|
||||||
// Every sample is read and accumulated even when only one in `period` is stored:
|
// Every sample is read and accumulated even when only one in `period` is stored:
|
||||||
// that is the whole point of the filter. Skipping the read -- which is what the
|
// that is the whole point of the filter. Skipping the read -- which is what the
|
||||||
// old stride did -- is what made decimation alias.
|
// old stride did -- is what made decimation alias.
|
||||||
@@ -1051,6 +1010,38 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
}
|
}
|
||||||
last_stamp_ = last_stamp_used;
|
last_stamp_ = last_stamp_used;
|
||||||
|
|
||||||
|
// A Force pressed since the last packet: one acquisition, at the present moment, without waiting
|
||||||
|
// for the condition to hold. Read *here* -- the end of the packet, past the channel loop -- for
|
||||||
|
// the reason the latch itself is deferred out of that loop: `last_stamp_` is the newest stored
|
||||||
|
// sample by now, so the capture opens on the moment the user pressed, and every channel has
|
||||||
|
// pushed that sample, so the cut the latch goes on to make cannot clamp it away
|
||||||
|
// (`publish_event()` clamps to each value ring's `produced()`).
|
||||||
|
//
|
||||||
|
// It opens the capture and nothing else. The latch below -- reached through `engine.process()`
|
||||||
|
// by an edge, through `engine.force()` here -- is what cuts the window, bumps the latch count,
|
||||||
|
// sets the trigger time and applies the mode's re-arm rule, so a forced acquisition is framed,
|
||||||
|
// pinned and recorded exactly like one the condition produced. A capture already in flight is
|
||||||
|
// not cancelled: it *is* the acquisition being asked for, and the engine refuses the request
|
||||||
|
// rather than restarting it.
|
||||||
|
if (force_want_.exchange(false, std::memory_order_acq_rel)) {
|
||||||
|
if (engine.force(last_stamp_)) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] trigger: forced acquisition, edge at %.3f s -- want "
|
||||||
|
"[-%.4f..%.4f] s, %.4f s of history behind it, mode=%s\n",
|
||||||
|
last_stamp_, event_span - post_want, post_want, last_stamp_ - oldest_s,
|
||||||
|
tc.mode == TriggerMode::Single ? "single (disarms at the latch)" : "normal");
|
||||||
|
std::fflush(stderr);
|
||||||
|
} else {
|
||||||
|
// Nothing to force. Every cause is a state the user can see from the panel -- a trigger that
|
||||||
|
// is off, a signal that is not acquired, the engine disarmed, a capture already filling --
|
||||||
|
// so it is named on stderr rather than swallowed.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] trigger: force ignored (enabled=%d armed=%d ch=%d pending=%d)\n",
|
||||||
|
int(tc.enabled), int(engine.armed()), trig_channel, int(engine.pending()));
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
|
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
|
||||||
// The edge goes out before the flag that makes the UI read it (release/acquire on
|
// The edge goes out before the flag that makes the UI read it (release/acquire on
|
||||||
// `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A
|
// `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A
|
||||||
@@ -1059,7 +1050,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
if (engine.pending()) model_.set_capture_edge(engine.last_trigger_time());
|
if (engine.pending()) model_.set_capture_edge(engine.last_trigger_time());
|
||||||
model_.set_trigger_pending(engine.pending());
|
model_.set_trigger_pending(engine.pending());
|
||||||
static std::int64_t next_engine_log = 0;
|
static std::int64_t next_engine_log = 0;
|
||||||
const std::int64_t now_e = static_cast<std::int64_t>(frame->recv_time * 1.0e6);
|
const std::int64_t now_e = static_cast<std::int64_t>(frame.recv_time * 1.0e6);
|
||||||
if (now_e >= next_engine_log) {
|
if (now_e >= next_engine_log) {
|
||||||
next_engine_log = now_e + 1000000;
|
next_engine_log = now_e + 1000000;
|
||||||
// `elapsed/post_want` is the capture's progress in the only unit that matters now
|
// `elapsed/post_want` is the capture's progress in the only unit that matters now
|
||||||
@@ -1164,7 +1155,7 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
|||||||
model_.stats().hole_slots.fetch_add(h.slot_count, 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,
|
void Acquisition::backfill_from(const Frame& frame, const Partition& part,
|
||||||
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
||||||
u64 g) {
|
u64 g) {
|
||||||
// `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame
|
// `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame
|
||||||
@@ -1181,7 +1172,7 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
|
|||||||
// late packets would be dropped on the next line.
|
// late packets would be dropped on the next line.
|
||||||
Hole* hole = nullptr;
|
Hole* hole = nullptr;
|
||||||
for (Hole& h : model_.holes()) {
|
for (Hole& h : model_.holes()) {
|
||||||
const std::int32_t into = static_cast<std::int32_t>(frame->counter - h.counter_first);
|
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)) {
|
if (into >= 0 && into < static_cast<std::int32_t>(h.packets)) {
|
||||||
hole = &h;
|
hole = &h;
|
||||||
break;
|
break;
|
||||||
@@ -1192,7 +1183,7 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
|
|||||||
// The packet's own span in absolute base ticks. `tick_begin` is the hole's first raw sample
|
// 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
|
// and the packets inside a hole are consecutive, so the counter's distance from
|
||||||
// `counter_first` places this one exactly.
|
// `counter_first` places this one exactly.
|
||||||
const u64 lo = hole->tick_begin + u64(frame->counter - hole->counter_first) * u64(batch_);
|
const u64 lo = hole->tick_begin + u64(frame.counter - hole->counter_first) * u64(batch_);
|
||||||
const u64 hi = lo + u64(batch_);
|
const u64 hi = lo + u64(batch_);
|
||||||
|
|
||||||
u64 filled = 0;
|
u64 filled = 0;
|
||||||
@@ -1216,11 +1207,11 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
|
|||||||
const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1
|
const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1
|
||||||
const double raw =
|
const double raw =
|
||||||
channels[i].elements > 1
|
channels[i].elements > 1
|
||||||
? udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal), 0,
|
? frame.value(static_cast<std::uint32_t>(channels[i].signal), 0,
|
||||||
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
|
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
|
||||||
channels[i].elements - 1))
|
channels[i].elements - 1))
|
||||||
: udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal),
|
: frame.value(static_cast<std::uint32_t>(channels[i].signal),
|
||||||
static_cast<std::uint32_t>(k), 0);
|
static_cast<std::uint32_t>(k), 0);
|
||||||
acc += raw * channels[i].gain + channels[i].offset;
|
acc += raw * channels[i].gain + channels[i].offset;
|
||||||
}
|
}
|
||||||
const f32 value = static_cast<f32>(acc / double(period));
|
const f32 value = static_cast<f32>(acc / double(period));
|
||||||
@@ -1242,18 +1233,4 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
|
|||||||
model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed);
|
model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
|
||||||
std::uint8_t publish_mode, void* user) {
|
|
||||||
static_cast<Acquisition*>(user)->on_config(signals, count, publish_mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Acquisition::data_trampoline(const udps_frame_t* frame, void* user) {
|
|
||||||
static_cast<Acquisition*>(user)->on_data(frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Acquisition::event_trampoline(udps_event_t event, const char* detail, void* user) {
|
|
||||||
static_cast<Acquisition*>(user)->on_event(static_cast<int>(event), detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -8,21 +8,22 @@
|
|||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
#include "trigger.hpp"
|
#include "trigger.hpp"
|
||||||
|
|
||||||
#include <udps_client.h>
|
#include "source/data_source.hpp"
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <memory>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
/// Owns the UDPStreamer client and the producer thread that feeds the model's
|
/// Owns the data source and the producer thread that feeds the model's
|
||||||
/// acquisition buffer. Everything it touches from the thread is serialised on
|
/// acquisition buffer. Everything it touches from the thread is serialised on
|
||||||
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
|
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
|
||||||
class Acquisition {
|
class Acquisition : private SourceSink {
|
||||||
public:
|
public:
|
||||||
Acquisition(ScopeModel& model, const Config& cfg);
|
Acquisition(ScopeModel& model, const Config& cfg);
|
||||||
~Acquisition();
|
~Acquisition();
|
||||||
@@ -30,8 +31,8 @@ class Acquisition {
|
|||||||
Acquisition(const Acquisition&) = delete;
|
Acquisition(const Acquisition&) = delete;
|
||||||
Acquisition& operator=(const Acquisition&) = delete;
|
Acquisition& operator=(const Acquisition&) = delete;
|
||||||
|
|
||||||
/// Opens the client (no socket until the first poll) and starts the thread.
|
/// Opens the source (no socket until the first poll) and starts the thread.
|
||||||
/// Throws std::runtime_error when the client cannot be created.
|
/// Throws std::runtime_error when the source cannot be created.
|
||||||
void start();
|
void start();
|
||||||
/// Stops the thread and destroys the client. Idempotent.
|
/// Stops the thread and destroys the client. Idempotent.
|
||||||
void stop();
|
void stop();
|
||||||
@@ -48,22 +49,33 @@ class Acquisition {
|
|||||||
bool capture_want() const noexcept {
|
bool capture_want() const noexcept {
|
||||||
return capture_want_.load(std::memory_order_acquire);
|
return capture_want_.load(std::memory_order_acquire);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks for one **forced trigger acquisition** -- the trigger panel's Force button. The producer
|
||||||
|
/// consumes the request on its next packet and opens a capture at that packet's newest stamp,
|
||||||
|
/// which the engine's ordinary latch then publishes with the configured pre/post window. The
|
||||||
|
/// request is dropped rather than retried when there is nothing to force: trigger mode off, no
|
||||||
|
/// channel resolved, the engine disarmed, or a capture already filling (a capture in flight *is*
|
||||||
|
/// the acquisition being asked for).
|
||||||
|
///
|
||||||
|
/// **Write it under `ScopeModel::mutex()`, together with the `armed` flag the click also sets.**
|
||||||
|
/// The producer holds that lock for a whole packet -- it reads the panel's `armed` at the top of
|
||||||
|
/// one and consumes this request at the bottom -- so a request written between the two would be
|
||||||
|
/// read by a packet whose engine was still disarmed, and then dropped.
|
||||||
|
void request_force() noexcept { force_want_.store(true, std::memory_order_release); }
|
||||||
|
|
||||||
|
/// The source this acquisition drives, for the UI's capability panels: it may only call
|
||||||
|
/// `post()` on it. Null until `start()`.
|
||||||
|
DataSource* source() noexcept { return source_.get(); }
|
||||||
|
|
||||||
/// The capture, for the popover's row and the probe. Counters and errors are read
|
/// The capture, for the popover's row and the probe. Counters and errors are read
|
||||||
/// cross-thread through PacketCapture's own published fields.
|
/// cross-thread through PacketCapture's own published fields.
|
||||||
const PacketCapture& capture() const noexcept { return capture_; }
|
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 std::vector<SignalInfo>& signals, std::uint8_t publish_mode) override;
|
||||||
void on_data(const udps_frame_t* frame);
|
void on_data(const Frame& frame) override;
|
||||||
void on_event(int event, const char* detail);
|
void on_event(SourceEvent event, const char* detail) override;
|
||||||
|
|
||||||
static void config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
|
||||||
std::uint8_t publish_mode, 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 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
|
/// 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
|
/// 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
|
/// have restarted its numbering; carrying the old session's marks across makes the
|
||||||
@@ -75,9 +87,9 @@ class Acquisition {
|
|||||||
void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time);
|
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 Frame& frame, int time_signal,
|
||||||
bool counter_gap_refused);
|
bool counter_gap_refused);
|
||||||
|
|
||||||
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
|
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
|
||||||
@@ -90,7 +102,7 @@ class Acquisition {
|
|||||||
/// Writes a late frame's values into the NaN-filled holes their counter addresses,
|
/// 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
|
/// amending the value ring directly (producer path) and queuing pyramid amends for
|
||||||
/// `ChannelBuffer::acquire()` to apply on the consumer thread.
|
/// `ChannelBuffer::acquire()` to apply on the consumer thread.
|
||||||
void backfill_from(const udps_frame_t* frame, const Partition& part,
|
void backfill_from(const Frame& frame, const Partition& part,
|
||||||
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||||
|
|
||||||
/// Keeps the wire's own cadence up to date from the arrival times, in the modes where
|
/// Keeps the wire's own cadence up to date from the arrival times, in the modes where
|
||||||
@@ -104,12 +116,15 @@ class Acquisition {
|
|||||||
|
|
||||||
ScopeModel& model_;
|
ScopeModel& model_;
|
||||||
Config cfg_;
|
Config cfg_;
|
||||||
udps_client* client_ = nullptr;
|
std::unique_ptr<DataSource> source_;
|
||||||
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_;
|
PacketCapture capture_;
|
||||||
std::atomic<bool> capture_want_{false};
|
std::atomic<bool> capture_want_{false};
|
||||||
|
/// The pending Force request (see `request_force()`): a flag and not a queue, because two clicks
|
||||||
|
/// inside one packet are one acquisition, which is what a momentary button means.
|
||||||
|
std::atomic<bool> force_want_{false};
|
||||||
std::uint32_t capture_slots_ = 1000;
|
std::uint32_t capture_slots_ = 1000;
|
||||||
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
|
/// 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.
|
/// the producer reads it only after the flag, so no lock is needed.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
|
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
|
||||||
// is what stops the message and the clamp from drifting apart.
|
// is what stops the message and the clamp from drifting apart.
|
||||||
#include "plot_host.hpp"
|
#include "plot_host.hpp"
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -38,17 +39,61 @@ double parse_double(const char* name, const std::string& v) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A count with an optional decimal suffix: `250000`, `64k`, `100M`, `1.5G`. k, M and G are 10^3, 10^6
|
||||||
|
/// and 10^9 -- a *sample* count, not a byte size -- and either case is accepted.
|
||||||
|
std::uint64_t parse_count(const char* name, const std::string& v) {
|
||||||
|
std::string digits = v;
|
||||||
|
double scale = 1.0;
|
||||||
|
if (!digits.empty()) {
|
||||||
|
switch (digits.back()) {
|
||||||
|
case 'k': case 'K': scale = 1.0e3; break;
|
||||||
|
case 'm': case 'M': scale = 1.0e6; break;
|
||||||
|
case 'g': case 'G': scale = 1.0e9; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (scale != 1.0) digits.pop_back();
|
||||||
|
}
|
||||||
|
const double d = parse_double(name, digits) * scale;
|
||||||
|
if (!(d >= 1.0) || d > 1.0e12) fail(std::string(name) + " out of range: " + v);
|
||||||
|
return static_cast<std::uint64_t>(d + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
SourceConfig Config::source_config() const {
|
||||||
|
SourceConfig out;
|
||||||
|
if (source_kind == "udps") {
|
||||||
|
out.set("server", server);
|
||||||
|
out.set("port", std::to_string(port));
|
||||||
|
if (!multicast.empty()) out.set("multicast", multicast);
|
||||||
|
if (!interface_addr.empty()) out.set("interface", interface_addr);
|
||||||
|
out.set("recv_buffer", std::to_string(recv_buffer_bytes));
|
||||||
|
}
|
||||||
|
for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); }
|
||||||
|
std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); }
|
||||||
|
|
||||||
void print_usage(std::FILE* out, const char* argv0) {
|
void print_usage(std::FILE* out, const char* argv0) {
|
||||||
|
std::string names;
|
||||||
|
for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
|
||||||
std::fprintf(out,
|
std::fprintf(out,
|
||||||
"usage: %s --server ADDR --port PORT [options]\n"
|
"usage: %s --server ADDR --port PORT [--source NAME] [options]\n"
|
||||||
"\n"
|
"\n"
|
||||||
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
|
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
|
||||||
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
|
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
|
||||||
" --multicast GROUP join GROUP for DATA instead of unicast\n"
|
" --multicast GROUP join GROUP for DATA instead of unicast\n"
|
||||||
" --interface ADDR local IPv4 of the interface to join on\n"
|
" --interface ADDR local IPv4 of the interface to join on\n"
|
||||||
" --buffer-mb MB size of each of the two sample buffers (default 200)\n"
|
" --source NAME source kind (%s; default udps). --server/--port/\n"
|
||||||
|
" --multicast/--interface configure udps\n"
|
||||||
|
" --source-opt K=V a setting for the source kind; repeatable\n"
|
||||||
|
" --buffer-samples N samples kept per acquired signal, e.g. 100M or 250000 (k, M\n"
|
||||||
|
" and G are decimal; default 10M). Rounded down to a power\n"
|
||||||
|
" of two: 100M gives 67108864\n"
|
||||||
|
" --memory-limit-mb MB hard ceiling on the sample rings, all signals together\n"
|
||||||
|
" (default 8192); the request is halved until it fits\n"
|
||||||
" --max-window SEC largest time window the toolbar offers (default 600)\n"
|
" --max-window SEC largest time window the toolbar offers (default 600)\n"
|
||||||
" --holdoff SEC default trigger hold-off (default 0.010)\n"
|
" --holdoff SEC default trigger hold-off (default 0.010)\n"
|
||||||
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
||||||
@@ -62,8 +107,14 @@ void print_usage(std::FILE* out, const char* argv0) {
|
|||||||
" time is then kept for export only\n"
|
" time is then kept for export only\n"
|
||||||
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
|
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
|
||||||
" signals from its columns, no socket, no acquisition\n"
|
" signals from its columns, no socket, no acquisition\n"
|
||||||
|
" --renderer NAME render with cairo (software), gl or vulkan. cairo also\n"
|
||||||
|
" exports LIBGL_ALWAYS_SOFTWARE=1, because the plot is a\n"
|
||||||
|
" GtkGLArea and still needs a GL context. Over SSH/X11\n"
|
||||||
|
" forwarding use cairo: Vulkan cannot make a swapchain there\n"
|
||||||
|
" and the GL area has no hardware context, so the window\n"
|
||||||
|
" never paints or allocates\n"
|
||||||
" -h, --help this text\n",
|
" -h, --help this text\n",
|
||||||
argv0);
|
argv0, names.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
Config parse_args(int argc, char** argv) {
|
Config parse_args(int argc, char** argv) {
|
||||||
@@ -85,10 +136,28 @@ Config parse_args(int argc, char** argv) {
|
|||||||
c.multicast = value_of(argc, argv, i, "--multicast");
|
c.multicast = value_of(argc, argv, i, "--multicast");
|
||||||
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
|
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
|
||||||
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 == "--source" || a.rfind("--source=", 0) == 0) {
|
||||||
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
|
const std::string v = value_of(argc, argv, i, "--source");
|
||||||
c.buffer_mb_specified = true;
|
if (find_kind(v) == nullptr) {
|
||||||
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
|
std::string names;
|
||||||
|
for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
|
||||||
|
fail("unknown source '" + v + "' (available: " + names + ")");
|
||||||
|
}
|
||||||
|
c.source_kind = v;
|
||||||
|
c.source_specified = true;
|
||||||
|
} else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) {
|
||||||
|
const std::string v = value_of(argc, argv, i, "--source-opt");
|
||||||
|
if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v);
|
||||||
|
} else if (a == "--buffer-samples" || a.rfind("--buffer-samples=", 0) == 0) {
|
||||||
|
c.buffer_samples = parse_count("--buffer-samples", value_of(argc, argv, i, "--buffer-samples"));
|
||||||
|
c.buffer_samples_specified = true;
|
||||||
|
if (c.buffer_samples < 128) fail("--buffer-samples must be at least 128");
|
||||||
|
} else if (a == "--memory-limit-mb" || a.rfind("--memory-limit-mb=", 0) == 0) {
|
||||||
|
const double mb =
|
||||||
|
parse_double("--memory-limit-mb", value_of(argc, argv, i, "--memory-limit-mb"));
|
||||||
|
if (!(mb >= 1.0) || mb > 1.0e9) fail("--memory-limit-mb must be 1..1000000000 (MiB)");
|
||||||
|
c.memory_limit_mb = static_cast<std::uint64_t>(mb);
|
||||||
|
c.memory_limit_specified = true;
|
||||||
} 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"));
|
||||||
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
|
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
|
||||||
@@ -113,6 +182,15 @@ Config parse_args(int argc, char** argv) {
|
|||||||
c.capture_packets = static_cast<std::uint32_t>(n);
|
c.capture_packets = static_cast<std::uint32_t>(n);
|
||||||
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
|
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
|
||||||
c.open_waveform = value_of(argc, argv, i, "--open");
|
c.open_waveform = value_of(argc, argv, i, "--open");
|
||||||
|
} else if (a == "--renderer" || a.rfind("--renderer=", 0) == 0) {
|
||||||
|
const std::string v = value_of(argc, argv, i, "--renderer");
|
||||||
|
// Only the three a source or a forwarded display actually chooses between. Anything
|
||||||
|
// GTK does not know would fall back with a warning and leave the user reading a
|
||||||
|
// renderer failure that is really a typo, so it is refused here instead.
|
||||||
|
if (v != "cairo" && v != "gl" && v != "vulkan") {
|
||||||
|
fail("--renderer must be cairo, gl or vulkan: " + v);
|
||||||
|
}
|
||||||
|
c.renderer = v;
|
||||||
} else if (a == "--relative-time") {
|
} else if (a == "--relative-time") {
|
||||||
c.relative_time = true;
|
c.relative_time = true;
|
||||||
c.relative_time_specified = true;
|
c.relative_time_specified = true;
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
/// Everything the command line can say. Defaults are the ones the spec names
|
/// Everything the command line can say. Defaults are the ones the spec names
|
||||||
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them.
|
/// (10 M samples per signal, 10 ms trigger hold-off) unless the user overrides them.
|
||||||
struct Config {
|
struct Config {
|
||||||
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
|
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
|
||||||
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
|
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
|
||||||
@@ -21,13 +23,38 @@ struct Config {
|
|||||||
/// `server` valid". The default stays a usable value for every other path.
|
/// `server` valid". The default stays a usable value for every other path.
|
||||||
bool server_specified = false;
|
bool server_specified = false;
|
||||||
|
|
||||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
/// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/
|
||||||
/// always allocates two identical buffers (spec item 1).
|
/// --multicast/--interface` keep meaning UDPS.
|
||||||
double buffer_mb = 200.0;
|
std::string source_kind = "udps";
|
||||||
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the
|
/// Whether `--source` was given (the other half of "did the user choose a source").
|
||||||
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
|
bool source_specified = false;
|
||||||
/// question as "asked for 200".
|
/// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above.
|
||||||
bool buffer_mb_specified = false;
|
SourceConfig source_opts;
|
||||||
|
|
||||||
|
/// A source was named on the command line (`--server` or `--source`), so the chooser is skipped.
|
||||||
|
bool has_source() const noexcept { return server_specified || source_specified; }
|
||||||
|
/// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive
|
||||||
|
/// buffer, then `source_opts` on top.
|
||||||
|
SourceConfig source_config() const;
|
||||||
|
/// The (host, port) that name this source's session file; see `session_host()` in the registry.
|
||||||
|
std::string session_host() const;
|
||||||
|
std::uint16_t session_port() const;
|
||||||
|
|
||||||
|
/// The sample budget: how many samples to keep for **every acquired signal**, and for the shared
|
||||||
|
/// time ring. The rings are powers of two, so the request is rounded *down* to one (`buffer_ticks()`
|
||||||
|
/// in model.hpp) -- "up to" this many -- and the UI shows both figures. 10 M asks for 8 388 608.
|
||||||
|
std::uint64_t buffer_samples = 10'000'000;
|
||||||
|
/// Whether `--buffer-samples` was given. A stored configuration's sample budget 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 10 M".
|
||||||
|
bool buffer_samples_specified = false;
|
||||||
|
|
||||||
|
/// The hard ceiling on the ring storage, in MiB, for the whole application rather than per source.
|
||||||
|
/// `plan_partition()` halves the rings until they fit under it, so it can clip `buffer_samples`.
|
||||||
|
/// Default 8 GiB; the preferences carry it between runs and `--memory-limit-mb` overrides it.
|
||||||
|
std::uint64_t memory_limit_mb = 8192;
|
||||||
|
/// Whether `--memory-limit-mb` was given, so the stored preference only fills the gap.
|
||||||
|
bool memory_limit_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).
|
||||||
@@ -60,6 +87,15 @@ struct Config {
|
|||||||
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
||||||
std::string open_waveform;
|
std::string open_waveform;
|
||||||
|
|
||||||
|
/// `--renderer NAME`: the GSK renderer to force, by exporting `GSK_RENDERER` **before any GTK
|
||||||
|
/// call**. Empty leaves GTK's own choice alone. `cairo` is the software preset over SSH/X11
|
||||||
|
/// forwarding: it also exports `LIBGL_ALWAYS_SOFTWARE=1`, because the plot's `GtkGLArea` asks
|
||||||
|
/// for a GL context whatever GSK draws with. `gl` is the faster alternative when indirect GL
|
||||||
|
/// works. The variables are read when the renderer/context is created, so setting them anywhere
|
||||||
|
/// after `gtk_init()`/`adw_init()` is a no-op -- which is why this is a `Config` field applied
|
||||||
|
/// at the top of `main()` and not a preference.
|
||||||
|
std::string renderer;
|
||||||
|
|
||||||
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
|
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
|
||||||
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
|
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
|
||||||
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
|
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
@@ -63,8 +64,30 @@ struct EventSnapshot {
|
|||||||
/// already O(n), and the same channel on two rows shares it instead of building and
|
/// already O(n), and the same channel on two rows shares it instead of building and
|
||||||
/// storing it twice.
|
/// storing it twice.
|
||||||
std::vector<plot::data::MinMaxPyramid> pyramids;
|
std::vector<plot::data::MinMaxPyramid> pyramids;
|
||||||
|
/// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel
|
||||||
|
/// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and
|
||||||
|
/// resolves that name here, exactly as the trigger engine names its channel and for the same
|
||||||
|
/// reason (an index is a position in a list that any enable, disable or CONFIG renumbers).
|
||||||
|
/// Empty on a snapshot nobody re-binds later (a pause view).
|
||||||
|
std::vector<std::string> channel_names;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for
|
||||||
|
/// them and the names above. The number the persistence retention is measured in -- a retained
|
||||||
|
/// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count.
|
||||||
|
inline std::size_t snapshot_bytes(const EventSnapshot& s) {
|
||||||
|
std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) +
|
||||||
|
s.y.size() * sizeof(plot::Range) +
|
||||||
|
s.channel_names.size() * sizeof(std::string);
|
||||||
|
for (const std::vector<f32>& v : s.values) bytes += v.size() * sizeof(f32);
|
||||||
|
// A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of
|
||||||
|
// `kBucket << l` samples -- the whole index is about one byte per sample.
|
||||||
|
for (const plot::data::MinMaxPyramid& p : s.pyramids) {
|
||||||
|
for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
|
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
|
||||||
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
|
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
|
||||||
/// the exact stamp; gather decimates by 2^level like the ring series does.
|
/// the exact stamp; gather decimates by 2^level like the ring series does.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!-- A boundary that can be dragged sideways: a horizontal double-headed arrow, the turned twin of
|
||||||
|
udpscope-resize-rows-symbolic. It says *which* edge the handle moves, which a single chevron (a
|
||||||
|
"go next") 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 y 5..11 and y 7..9. -->
|
||||||
|
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#2e3436" d="M4 7 H12 V9 H4 Z
|
||||||
|
M1 8 L5 5 V11 Z
|
||||||
|
M15 8 L11 11 V5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 818 B |
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- SPDX-License-Identifier: MIT -->
|
|
||||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
|
||||||
<!-- The Measure tab: two cursors beside the ruler they read against. Each cursor is a cap over two
|
|
||||||
bold dashes — the mark the toolbar's cursors button carries, at a weight that survives this size.
|
|
||||||
The ruler is two bands with three marks between them and *no end caps*: a closed frame reads as a
|
|
||||||
rectangle, and it costs a fifth of the ink on two lines that say nothing. Both motifs are centred
|
|
||||||
on the same line and share one weight, and everything is inset two pixels. -->
|
|
||||||
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="#2e3436" d="M2 2 H7 V5 H2 Z
|
|
||||||
M3 6 H6 V10 H3 Z
|
|
||||||
M3 11 H6 V14 H3 Z
|
|
||||||
M9 2 H14 V5 H9 Z
|
|
||||||
M10 6 H13 V10 H10 Z
|
|
||||||
M10 11 H13 V14 H10 Z
|
|
||||||
M17 4 H30 V6 H17 Z
|
|
||||||
M17 10 H30 V12 H17 Z
|
|
||||||
M18 6 H20 V10 H18 Z
|
|
||||||
M22 6 H24 V10 H22 Z
|
|
||||||
M26 6 H28 V10 H26 Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,31 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- SPDX-License-Identifier: MIT -->
|
|
||||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
|
||||||
<!-- The Signals tab: the toolbar's trigger picture — a sine crossing its dashed level — beside the
|
|
||||||
channel list. Sharing that picture is the point: the pill and the button that turns the trigger on
|
|
||||||
should be recognisably about the same thing. A full period and not a hump: the crest and the trough
|
|
||||||
are what make it read as a wave rather than as a hill, and the level's dashes survive between the
|
|
||||||
crossings. Two channel rows on the right, drawn bold like the wave, and everything inset two
|
|
||||||
pixels, because art flush with the cell edge is what a button or a crop clips. -->
|
|
||||||
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="#2e3436" d="M2 6 H3 V10 H2 Z
|
|
||||||
M3 4 H4 V8 H3 Z
|
|
||||||
M4 3 H5 V7 H4 Z
|
|
||||||
M5 2 H6 V6 H5 Z
|
|
||||||
M6 3 H7 V7 H6 Z
|
|
||||||
M7 4 H8 V8 H7 Z
|
|
||||||
M8 6 H9 V10 H8 Z
|
|
||||||
M9 8 H10 V12 H9 Z
|
|
||||||
M10 9 H11 V13 H10 Z
|
|
||||||
M11 10 H12 V14 H11 Z
|
|
||||||
M12 9 H13 V13 H12 Z
|
|
||||||
M13 8 H14 V12 H13 Z
|
|
||||||
M14 6 H15 V10 H14 Z
|
|
||||||
M2 7 H5 V9 H2 Z
|
|
||||||
M7 7 H10 V9 H7 Z
|
|
||||||
M12 7 H15 V9 H12 Z
|
|
||||||
M18 3 H23 V8 H18 Z
|
|
||||||
M24 4 H30 V7 H24 Z
|
|
||||||
M18 10 H23 V15 H18 Z
|
|
||||||
M24 11 H30 V14 H24 Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -20,10 +20,9 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||||
<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-tab-measure-symbolic.svg</file>
|
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-tab-signals-symbolic.svg</file>
|
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-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/actions/udpscope-resize-rows-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-cols-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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -55,12 +56,23 @@ 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) {
|
||||||
if (c == '"' || c == '\\') out.push_back('\\');
|
// The file is read line by line, so a raw newline in a value would end the line early.
|
||||||
out.push_back(c);
|
if (c == '\n') {
|
||||||
|
out += "\\n";
|
||||||
|
} else if (c == '\r') {
|
||||||
|
out += "\\r";
|
||||||
|
} else {
|
||||||
|
if (c == '"' || c == '\\') out.push_back('\\');
|
||||||
|
out.push_back(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out + "\"";
|
return out + "\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The character an escape `\x` stands for: `\n` and `\r` are the two line breaks, anything else
|
||||||
|
/// (the quote, the backslash) stands for itself.
|
||||||
|
char unescaped(char c) { return c == 'n' ? '\n' : c == 'r' ? '\r' : c; }
|
||||||
|
|
||||||
bool unescape(std::string& s) {
|
bool unescape(std::string& s) {
|
||||||
std::string out;
|
std::string out;
|
||||||
for (std::size_t i = 0; i < s.size(); ++i) {
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||||
@@ -69,8 +81,8 @@ bool unescape(std::string& s) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (++i >= s.size()) return false;
|
if (++i >= s.size()) return false;
|
||||||
if (s[i] == '"' || s[i] == '\\') {
|
if (s[i] == '"' || s[i] == '\\' || s[i] == 'n' || s[i] == 'r') {
|
||||||
out.push_back(s[i]);
|
out.push_back(unescaped(s[i]));
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -317,11 +329,15 @@ std::vector<KnownSource> known_sources() {
|
|||||||
s.port = port;
|
s.port = port;
|
||||||
s.config = name;
|
s.config = name;
|
||||||
s.comment = st.comment;
|
s.comment = st.comment;
|
||||||
s.buffer_mb = st.buffer_mb;
|
s.buffer_samples = st.buffer_samples;
|
||||||
s.relative_time = st.relative_time;
|
s.relative_time = st.relative_time;
|
||||||
s.capture_packets = st.capture_packets;
|
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;
|
||||||
|
if (!st.source_kind.empty()) {
|
||||||
|
s.kind = st.source_kind;
|
||||||
|
s.opts = st.source_opts;
|
||||||
|
}
|
||||||
s.rows = static_cast<int>(st.rows.size());
|
s.rows = static_cast<int>(st.rows.size());
|
||||||
for (const LayoutState::Row& row : st.rows) {
|
for (const LayoutState::Row& row : st.rows) {
|
||||||
for (const std::string& sig : row.signals) {
|
for (const std::string& sig : row.signals) {
|
||||||
@@ -332,7 +348,7 @@ std::vector<KnownSource> known_sources() {
|
|||||||
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
|
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
|
||||||
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
|
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
|
||||||
// set up from the chooser's form, and the settings are the whole point of editing one.
|
// 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() ||
|
const bool configured = s.signals > 0 || s.buffer_samples > 0 || !st.comment.empty() ||
|
||||||
st.relative_time || st.capture_packets > 0;
|
st.relative_time || st.capture_packets > 0;
|
||||||
if (!configured) continue;
|
if (!configured) continue;
|
||||||
struct stat fb {};
|
struct stat fb {};
|
||||||
@@ -424,6 +440,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
st.window_s = 0.0;
|
st.window_s = 0.0;
|
||||||
st.rows.clear();
|
st.rows.clear();
|
||||||
st.calibration.clear();
|
st.calibration.clear();
|
||||||
|
st.units.clear();
|
||||||
st.colors.clear();
|
st.colors.clear();
|
||||||
st.enabled.clear();
|
st.enabled.clear();
|
||||||
st.math.clear();
|
st.math.clear();
|
||||||
@@ -431,7 +448,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
|
|
||||||
std::string line;
|
std::string line;
|
||||||
// What the last `[[...]]` header opened, so keys land in the right record.
|
// What the last `[[...]]` header opened, so keys land in the right record.
|
||||||
enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top;
|
enum class Section { Top, Plot, Calibration, Unit, Color, Math, Measure } section = Section::Top;
|
||||||
int plot_count = 0;
|
int plot_count = 0;
|
||||||
bool plot_section_seen = false;
|
bool plot_section_seen = false;
|
||||||
while (std::getline(in, line)) {
|
while (std::getline(in, line)) {
|
||||||
@@ -445,6 +462,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
} else if (t == "[[calibration]]") {
|
} else if (t == "[[calibration]]") {
|
||||||
section = Section::Calibration;
|
section = Section::Calibration;
|
||||||
st.calibration.emplace_back();
|
st.calibration.emplace_back();
|
||||||
|
} else if (t == "[[unit]]") {
|
||||||
|
section = Section::Unit;
|
||||||
|
st.units.emplace_back();
|
||||||
} else if (t == "[[color]]") {
|
} else if (t == "[[color]]") {
|
||||||
section = Section::Color;
|
section = Section::Color;
|
||||||
st.colors.emplace_back();
|
st.colors.emplace_back();
|
||||||
@@ -468,8 +488,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
std::string s = v.substr(1, v.size() - 2);
|
std::string s = v.substr(1, v.size() - 2);
|
||||||
std::string out;
|
std::string out;
|
||||||
for (std::size_t i = 0; i < s.size(); ++i) {
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||||
if (s[i] == '\\' && i + 1 < s.size()) ++i;
|
if (s[i] == '\\' && i + 1 < s.size()) {
|
||||||
out.push_back(s[i]);
|
out.push_back(unescaped(s[++i]));
|
||||||
|
} else {
|
||||||
|
out.push_back(s[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -516,6 +539,18 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
num(t.noise_band);
|
num(t.noise_band);
|
||||||
} else if (k == "hf") {
|
} else if (k == "hf") {
|
||||||
num(t.hf_fc);
|
num(t.hf_fc);
|
||||||
|
} else if (k == "persistence") {
|
||||||
|
double n = 0.0;
|
||||||
|
num(n);
|
||||||
|
t.persistence = static_cast<int>(n);
|
||||||
|
} else if (k == "density") {
|
||||||
|
t.density = v == "true";
|
||||||
|
} else if (k == "density_decay") {
|
||||||
|
t.density_decay = v == "time" ? "time" : "acquisitions";
|
||||||
|
} else if (k == "density_tau") {
|
||||||
|
num(t.density_tau_s);
|
||||||
|
} else if (k == "density_factor") {
|
||||||
|
num(t.density_factor);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (section == Section::Top) {
|
if (section == Section::Top) {
|
||||||
@@ -538,19 +573,26 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
}
|
}
|
||||||
} else if (key == "measure_stats") {
|
} else if (key == "measure_stats") {
|
||||||
st.measure_stats = value == "true";
|
st.measure_stats = value == "true";
|
||||||
} else if (key == "side_tab") {
|
} else if (key == "dock") {
|
||||||
st.side_tab = strip_quote(value);
|
st.dock_shown = value == "true";
|
||||||
} else if (key.rfind("trigger_", 0) == 0) {
|
} else if (key.rfind("trigger_", 0) == 0) {
|
||||||
trigger_key(st.trigger, key.substr(8), strip_quote(value));
|
trigger_key(st.trigger, key.substr(8), strip_quote(value));
|
||||||
} 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 == "source_kind") {
|
||||||
|
st.source_kind = strip_quote(value);
|
||||||
|
} else if (key.rfind("source_opt_", 0) == 0) {
|
||||||
|
const std::string k = key.substr(11);
|
||||||
|
if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value));
|
||||||
} else if (key == "comment") {
|
} else if (key == "comment") {
|
||||||
st.comment = strip_quote(value);
|
st.comment = strip_quote(value);
|
||||||
} else if (key == "buffer_mb") {
|
} else if (key == "buffer_samples") {
|
||||||
try {
|
try {
|
||||||
st.buffer_mb = std::stod(value);
|
// A negative or absurd count is "not stored", not a request: `stoull` wraps a minus sign.
|
||||||
|
const unsigned long long n = value.find('-') == std::string::npos ? std::stoull(value) : 0;
|
||||||
|
st.buffer_samples = n >= 128 && n <= 1000000000000ull ? n : 0;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
}
|
}
|
||||||
} else if (key == "relative_time") {
|
} else if (key == "relative_time") {
|
||||||
@@ -570,6 +612,15 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
st.rows.back().signals = names;
|
st.rows.back().signals = names;
|
||||||
} else if (key == "weight") {
|
} else if (key == "weight") {
|
||||||
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
|
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
|
||||||
|
} else if (key == "row") {
|
||||||
|
try {
|
||||||
|
const int r = std::stoi(value);
|
||||||
|
if (r >= 0) st.rows.back().row = r;
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
} else if (key == "width") {
|
||||||
|
const double w = std::strtod(value.c_str(), nullptr);
|
||||||
|
if (std::isfinite(w) && w > 0.0) st.rows.back().width = w;
|
||||||
}
|
}
|
||||||
} 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();
|
||||||
@@ -649,6 +700,12 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
} catch (...) {
|
} catch (...) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (section == Section::Unit && !st.units.empty()) {
|
||||||
|
if (key == "name") {
|
||||||
|
st.units.back().name = strip_quote(value);
|
||||||
|
} else if (key == "unit") {
|
||||||
|
st.units.back().unit = strip_quote(value);
|
||||||
|
}
|
||||||
} else if (section == Section::Color && !st.colors.empty()) {
|
} else if (section == Section::Color && !st.colors.empty()) {
|
||||||
if (key == "name") {
|
if (key == "name") {
|
||||||
st.colors.back().name = strip_quote(value);
|
st.colors.back().name = strip_quote(value);
|
||||||
@@ -687,6 +744,12 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
if (!state.interface_addr.empty()) {
|
if (!state.interface_addr.empty()) {
|
||||||
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
||||||
}
|
}
|
||||||
|
if (!state.source_kind.empty() && state.source_kind != "udps") {
|
||||||
|
o << "source_kind = " << toml_escape(state.source_kind) << "\n";
|
||||||
|
for (const auto& kv : state.source_opts.items()) {
|
||||||
|
o << "source_opt_" << kv.first << " = " << toml_escape(kv.second) << "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
|
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
|
||||||
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
|
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
|
||||||
// never saw it and a session's enable state was silently lost.
|
// never saw it and a session's enable state was silently lost.
|
||||||
@@ -699,7 +762,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
o << "]\n";
|
o << "]\n";
|
||||||
}
|
}
|
||||||
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
|
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
|
||||||
o << "side_tab = \"" << state.side_tab << "\"\n";
|
o << "dock = " << (state.dock_shown ? "true" : "false") << "\n";
|
||||||
// The trigger, settings and state both. Written even when it is off: that is the point of it.
|
// The trigger, settings and state both. Written even when it is off: that is the point of it.
|
||||||
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
|
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
|
||||||
if (!state.trigger.channel.empty()) {
|
if (!state.trigger.channel.empty()) {
|
||||||
@@ -718,12 +781,24 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
|
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
|
||||||
o << "trigger_band = " << state.trigger.noise_band << "\n";
|
o << "trigger_band = " << state.trigger.noise_band << "\n";
|
||||||
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
|
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
|
||||||
|
o << "trigger_persistence = " << state.trigger.persistence << "\n";
|
||||||
|
// The phosphor density, the display that is an image rather than a set of traces. Its four fields
|
||||||
|
// are written whether or not it is on, like every other trigger setting: a display set up once and
|
||||||
|
// switched off is still the display you come back to.
|
||||||
|
o << "trigger_density = " << (state.trigger.density ? "true" : "false") << "\n";
|
||||||
|
o << "trigger_density_decay = " << toml_escape(state.trigger.density_decay) << "\n";
|
||||||
|
o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n";
|
||||||
|
o << "trigger_density_factor = " << state.trigger.density_factor << "\n";
|
||||||
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\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";
|
if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
|
||||||
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||||
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\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]]\nweight = " << row.weight << "\nsignals = [";
|
o << "\n[[plot]]\n";
|
||||||
|
// A row of -1 is a state built without the split (nothing in the app does that now); leaving the
|
||||||
|
// key out keeps it readable as the one-cell-per-row file it is.
|
||||||
|
if (row.row >= 0) o << "row = " << row.row << "\nwidth = " << row.width << "\n";
|
||||||
|
o << "weight = " << 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]);
|
||||||
@@ -734,6 +809,9 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
|
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
|
||||||
<< "\noffset = " << cal.offset << "\n";
|
<< "\noffset = " << cal.offset << "\n";
|
||||||
}
|
}
|
||||||
|
for (const auto& u : state.units) {
|
||||||
|
o << "\n[[unit]]\nname = " << toml_escape(u.name) << "\nunit = " << toml_escape(u.unit) << "\n";
|
||||||
|
}
|
||||||
for (const auto& color : state.colors) {
|
for (const auto& color : state.colors) {
|
||||||
char rgb[16];
|
char rgb[16];
|
||||||
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
|
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "math_channel.hpp"
|
#include "math_channel.hpp"
|
||||||
#include "measure.hpp"
|
#include "measure.hpp"
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
@@ -23,6 +24,13 @@ struct LayoutState {
|
|||||||
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
|
/// 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.
|
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
|
||||||
double weight = 1.0;
|
double weight = 1.0;
|
||||||
|
/// The figure row this plot sits in; plots of one row are the cells of a horizontal split, in
|
||||||
|
/// order. -1 means the file had no `row` key (a session from before the split), which loads as
|
||||||
|
/// one cell per row.
|
||||||
|
int row = -1;
|
||||||
|
/// This cell's share of its row's width, 1 for equal cells. `weight` is the *row's* and is
|
||||||
|
/// written on every cell of the row; `width` is the cell's own.
|
||||||
|
double width = 1.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
double window_s = 0.0; ///< 0 keeps the default window
|
double window_s = 0.0; ///< 0 keeps the default window
|
||||||
@@ -35,6 +43,13 @@ struct LayoutState {
|
|||||||
};
|
};
|
||||||
std::vector<Calibration> calibration;
|
std::vector<Calibration> calibration;
|
||||||
|
|
||||||
|
/// The unit names the user gave streamed signals, by signal name. Only the overrides.
|
||||||
|
struct Unit {
|
||||||
|
std::string name;
|
||||||
|
std::string unit;
|
||||||
|
};
|
||||||
|
std::vector<Unit> units;
|
||||||
|
|
||||||
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
|
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
|
||||||
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
|
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
|
||||||
/// without a session having to name them. Saved by name like every identity here.
|
/// without a session having to name them. Saved by name like every identity here.
|
||||||
@@ -54,6 +69,12 @@ struct LayoutState {
|
|||||||
/// unicast; empty `interface_addr` is the default route.
|
/// unicast; empty `interface_addr` is the default route.
|
||||||
std::string multicast;
|
std::string multicast;
|
||||||
std::string interface_addr;
|
std::string interface_addr;
|
||||||
|
/// Which source kind this session belongs to. Empty means `udps`: every file written before the
|
||||||
|
/// plugins existed. Only a kind other than `udps` is written, so a UDPS session file is
|
||||||
|
/// byte-identical to what earlier versions wrote.
|
||||||
|
std::string source_kind;
|
||||||
|
/// That kind's settings (`SourceConfig`), stored as top-level `source_opt_<key>` entries.
|
||||||
|
SourceConfig source_opts;
|
||||||
|
|
||||||
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
|
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
|
||||||
/// process. A graph is stored as one string per node so the file stays in this store's TOML
|
/// process. A graph is stored as one string per node so the file stays in this store's TOML
|
||||||
@@ -66,9 +87,10 @@ struct LayoutState {
|
|||||||
/// stream and mean nothing restored against another one.
|
/// stream and mean nothing restored against another one.
|
||||||
bool measure_stats = false;
|
bool measure_stats = false;
|
||||||
|
|
||||||
/// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists --
|
/// The dock's shown state (the user's last word on the toolbar's Dock button). The one thing the dock
|
||||||
/// no revealer states remain, and the 320 px width is the `.ui`'s.
|
/// persists -- its 320 px width is the `.ui`'s. An older file's `side_tab` key is not read: the parser
|
||||||
std::string side_tab = "signals";
|
/// ignores keys it does not know.
|
||||||
|
bool dock_shown = true;
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -97,6 +119,26 @@ struct LayoutState {
|
|||||||
double holdoff_s = 0.010;
|
double holdoff_s = 0.010;
|
||||||
double noise_band = 0.0;
|
double noise_band = 0.0;
|
||||||
double hf_fc = 0.0;
|
double hf_fc = 0.0;
|
||||||
|
/// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event
|
||||||
|
/// alone). Part of the trigger's own setup, like the window it captures and the shape it waits
|
||||||
|
/// for: it is what the display does with the acquisitions, and there is no second switch.
|
||||||
|
///
|
||||||
|
/// This is the **multiple event** display -- N retained acquisitions, drawn as N traces. The
|
||||||
|
/// phosphor density beside it is a different display with a different cost (one bounded image a
|
||||||
|
/// trace against N window copies), which is why it has its own four fields below and why this
|
||||||
|
/// one keeps the key it has always had: an older file's `trigger_persistence` is still the
|
||||||
|
/// multiple-event depth.
|
||||||
|
int persistence = 1;
|
||||||
|
/// The **phosphor density** (`TriggerConfig::density` and the three beside it): whether the
|
||||||
|
/// trigger-mode display accumulates every acquisition into one image, what it fades by, and the
|
||||||
|
/// constant or factor that fade uses. Persisted because it is a display the user set up and
|
||||||
|
/// comes back to -- the trigger settings around it already are.
|
||||||
|
bool density = false;
|
||||||
|
/// "acquisitions" | "time": the same two words the panel's dropdown shows, for the reason the
|
||||||
|
/// shapes above are stored as text -- an index would tie the file to a declaration order.
|
||||||
|
std::string density_decay = "acquisitions";
|
||||||
|
double density_tau_s = 1.0;
|
||||||
|
double density_factor = 0.5;
|
||||||
/// Whether the file said anything about the level. A session that did keeps it; one that did not
|
/// Whether the file said anything about the level. A session that did keeps it; one that did not
|
||||||
/// leaves the panel's own "follow the channel's range" default alone.
|
/// leaves the panel's own "follow the channel's range" default alone.
|
||||||
bool level_given = false;
|
bool level_given = false;
|
||||||
@@ -111,9 +153,12 @@ struct LayoutState {
|
|||||||
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
|
/// 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
|
/// 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
|
/// 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":
|
/// setting gets forgotten. A zero `buffer_samples` and an empty `capture_packets` mean "not stored":
|
||||||
/// the command line's value stands, so an old file cannot silently shrink a buffer.
|
/// the command line's value stands, so an old file cannot silently shrink a buffer. The budget is
|
||||||
double buffer_mb = 0.0;
|
/// samples per acquired signal (the memory limit is the application's, not a source's). A file from
|
||||||
|
/// before that unit carries `buffer_mb`, which is not read: it reads as "not stored" and the
|
||||||
|
/// default stands, rather than a megabyte count being taken for a sample count.
|
||||||
|
std::uint64_t buffer_samples = 0;
|
||||||
bool relative_time = false;
|
bool relative_time = false;
|
||||||
std::uint32_t capture_packets = 0;
|
std::uint32_t capture_packets = 0;
|
||||||
};
|
};
|
||||||
@@ -124,6 +169,8 @@ struct KnownSource {
|
|||||||
std::uint16_t port = 0;
|
std::uint16_t port = 0;
|
||||||
std::string multicast; ///< empty = unicast
|
std::string multicast; ///< empty = unicast
|
||||||
std::string interface_addr; ///< empty = the default route
|
std::string interface_addr; ///< empty = the default route
|
||||||
|
std::string kind = "udps"; ///< the session's source kind
|
||||||
|
SourceConfig opts; ///< that kind's settings (empty for UDPS, whose fields are above)
|
||||||
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
|
||||||
@@ -132,7 +179,7 @@ struct KnownSource {
|
|||||||
std::string config;
|
std::string config;
|
||||||
std::string comment; ///< the configuration's note, shown beside its name
|
std::string comment; ///< the configuration's note, shown beside its name
|
||||||
/// The startup settings this configuration carries, so the chooser can apply them.
|
/// The startup settings this configuration carries, so the chooser can apply them.
|
||||||
double buffer_mb = 0.0;
|
std::uint64_t buffer_samples = 0;
|
||||||
bool relative_time = false;
|
bool relative_time = false;
|
||||||
std::uint32_t capture_packets = 0;
|
std::uint32_t capture_packets = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,36 +7,69 @@
|
|||||||
#include "waveform.hpp"
|
#include "waveform.hpp"
|
||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
#include "plot_host.hpp"
|
#include "plot_host.hpp"
|
||||||
|
#include "source/registry.hpp"
|
||||||
#include "source_dialog.hpp"
|
#include "source_dialog.hpp"
|
||||||
#include "ui.hpp"
|
#include "ui.hpp"
|
||||||
|
|
||||||
|
#include <glib.h>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
udpscope::Config cfg;
|
udpscope::Config cfg;
|
||||||
try {
|
try {
|
||||||
|
udpscope::register_builtin_sources();
|
||||||
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
|
// 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
|
// 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.
|
// 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;
|
const udpscope::Settings prefs = udpscope::load_settings();
|
||||||
|
if (prefs.relative_time) cfg.relative_time = true;
|
||||||
|
// The memory limit is the same kind of statement, the other way round: the flag is the explicit
|
||||||
|
// instruction for this run and the preference only fills in when it was absent.
|
||||||
|
if (!cfg.memory_limit_specified) cfg.memory_limit_mb = static_cast<std::uint64_t>(prefs.memory_limit_mb);
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `--renderer` is exported here and nowhere else, because GSK reads `GSK_RENDERER` when it
|
||||||
|
// *creates* its renderer: inside the first `adw_init()`/`gtk_init()`, which `choose_source()`
|
||||||
|
// below may call. Setting it after that point is a silent no-op, so the one place it can be
|
||||||
|
// honoured is before anything touches GTK. Overwrite: a flag on the command line is a
|
||||||
|
// statement about this run, and the environment is only a default it may override.
|
||||||
|
//
|
||||||
|
// `cairo` is the whole software preset and not just GSK's half: the plot is a `GtkGLArea`,
|
||||||
|
// which asks GDK for a GL context of its own whatever GSK draws with. Over X11 forwarding
|
||||||
|
// that request is what leaves the window unallocated (no `buffer allocated:` line, zero
|
||||||
|
// width) once the Vulkan swapchain failure is out of the way, so `LIBGL_ALWAYS_SOFTWARE`
|
||||||
|
// makes Mesa answer it with llvmpipe. This is the pair `tools/make-help-shots.sh` sets.
|
||||||
|
if (!cfg.renderer.empty()) g_setenv("GSK_RENDERER", cfg.renderer.c_str(), TRUE);
|
||||||
|
if (cfg.renderer == "cairo") g_setenv("LIBGL_ALWAYS_SOFTWARE", "1", TRUE);
|
||||||
|
// The startup half of the renderer probe, printed before GTK exists: the request and the
|
||||||
|
// variables this process actually exported. The window's `[renderer] realized=` line is the
|
||||||
|
// other half and says which renderer GTK built.
|
||||||
|
if (g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr) {
|
||||||
|
const char* gsk = g_getenv("GSK_RENDERER");
|
||||||
|
const char* gl = g_getenv("LIBGL_ALWAYS_SOFTWARE");
|
||||||
|
std::fprintf(stderr, "[renderer] option=%s GSK_RENDERER=%s LIBGL_ALWAYS_SOFTWARE=%s\n",
|
||||||
|
cfg.renderer.empty() ? "(unset)" : cfg.renderer.c_str(),
|
||||||
|
gsk != nullptr ? gsk : "(unset)", gl != nullptr ? gl : "(unset)");
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
// 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".
|
||||||
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
|
// `--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.
|
// fills `open_waveform` in instead of the connection fields.
|
||||||
const bool asked = !cfg.server_specified && cfg.open_waveform.empty();
|
const bool asked = !cfg.has_source() && cfg.open_waveform.empty();
|
||||||
if (asked && !udpscope::choose_source(cfg)) return 0;
|
if (asked && !udpscope::choose_source(cfg)) return 0;
|
||||||
// A source named on the command line skips the chooser, but its *stored* configuration still
|
// 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 --
|
// 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
|
// 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.
|
// 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);
|
if (cfg.has_source()) udpscope::apply_stored_settings(cfg);
|
||||||
|
|
||||||
// Everything from here to `gtk_window_present()` inside `run_ui()` runs with no main loop: the
|
// 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
|
// buffer is allocated, a recording is read, then the whole UI is built. The chooser's window is
|
||||||
|
|||||||
@@ -14,20 +14,6 @@ namespace {
|
|||||||
/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
|
/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
|
||||||
/// records -- an interval's width is the distance from the edge that entered it to the edge that
|
/// records -- an interval's width is the distance from the edge that entered it to the edge that
|
||||||
/// left it, which is the pairing the pulse duration averages.
|
/// left it, which is the pairing the pulse duration averages.
|
||||||
void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
|
||||||
const std::size_t other = 1 - e;
|
|
||||||
if (acc.enters[e] == 0) acc.first_enter[e] = t;
|
|
||||||
acc.last_enter[e] = t;
|
|
||||||
++acc.enters[e];
|
|
||||||
if (acc.open[other]) {
|
|
||||||
acc.width_sum[other] += t - acc.open_at[other];
|
|
||||||
++acc.widths[other];
|
|
||||||
acc.open[other] = false;
|
|
||||||
}
|
|
||||||
acc.open_at[e] = t;
|
|
||||||
acc.open[e] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
|
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
|
||||||
// setting now, and a second copy of the number here would be a second thing to keep in step.
|
// setting now, and a second copy of the number here would be a second thing to keep in step.
|
||||||
constexpr double kPi = 3.14159265358979323846;
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
@@ -240,51 +226,6 @@ void measure_accumulate(const MeasureChunk& chunk,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool measure_accumulate_bucket(const MeasureBucket& k,
|
|
||||||
const std::function<double(std::size_t)>& time_of,
|
|
||||||
MeasureLevel& acc) {
|
|
||||||
if (k.count == 0) return true;
|
|
||||||
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
|
|
||||||
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
|
|
||||||
if (k.count != k.span) return false;
|
|
||||||
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
|
|
||||||
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
|
|
||||||
const bool above = k.lo > acc.level;
|
|
||||||
const bool below = k.hi < acc.level;
|
|
||||||
if (!above && !below) return false;
|
|
||||||
const std::size_t e = above ? 1u : 0u;
|
|
||||||
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
|
|
||||||
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
|
|
||||||
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
|
|
||||||
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
|
|
||||||
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
|
|
||||||
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
|
|
||||||
// whose position is inside the bucket is refused.
|
|
||||||
const double top = acc.level + acc.hysteresis;
|
|
||||||
const double bot = acc.level - acc.hysteresis;
|
|
||||||
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
|
|
||||||
// raw sample inside it decides none, so the walk state is left alone.
|
|
||||||
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
|
|
||||||
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
|
|
||||||
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
|
|
||||||
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
|
|
||||||
// the bucket's first sample, exactly, which is where the raw walk would have found it.
|
|
||||||
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
|
||||||
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
|
||||||
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
|
||||||
// bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
|
|
||||||
// gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
|
|
||||||
// a wrong one is a wrong answer waiting for that window.
|
|
||||||
if (change && !fully_outside) return false;
|
|
||||||
acc.sum[e] += k.sum;
|
|
||||||
acc.count[e] += k.count;
|
|
||||||
if (change) enter_side(acc, e, time_of(k.offset));
|
|
||||||
if (reaches) {
|
|
||||||
acc.side = e != 0;
|
|
||||||
acc.have_side = true;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
|
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
|
||||||
MeasureFlatLevels out;
|
MeasureFlatLevels out;
|
||||||
@@ -375,7 +316,6 @@ int measure_level_pcts(MeasureType type, const MeasureOptions& options, double p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const char* measure_type_name(MeasureType type) {
|
const char* measure_type_name(MeasureType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case MeasureType::Average: return "average";
|
case MeasureType::Average: return "average";
|
||||||
@@ -598,5 +538,4 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const l
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -349,6 +349,66 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
|
|||||||
const std::function<double(std::size_t)>& time_of,
|
const std::function<double(std::size_t)>& time_of,
|
||||||
MeasureLevel& level);
|
MeasureLevel& level);
|
||||||
|
|
||||||
|
inline void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
||||||
|
const std::size_t other = 1 - e;
|
||||||
|
if (acc.enters[e] == 0) acc.first_enter[e] = t;
|
||||||
|
acc.last_enter[e] = t;
|
||||||
|
++acc.enters[e];
|
||||||
|
if (acc.open[other]) {
|
||||||
|
acc.width_sum[other] += t - acc.open_at[other];
|
||||||
|
++acc.widths[other];
|
||||||
|
acc.open[other] = false;
|
||||||
|
}
|
||||||
|
acc.open_at[e] = t;
|
||||||
|
acc.open[e] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool measure_accumulate_bucket(const MeasureBucket& k,
|
||||||
|
const std::function<double(std::size_t)>& time_of,
|
||||||
|
MeasureLevel& acc) {
|
||||||
|
if (k.count == 0) return true;
|
||||||
|
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
|
||||||
|
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
|
||||||
|
if (k.count != k.span) return false;
|
||||||
|
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
|
||||||
|
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
|
||||||
|
const bool above = k.lo > acc.level;
|
||||||
|
const bool below = k.hi < acc.level;
|
||||||
|
if (!above && !below) return false;
|
||||||
|
const std::size_t e = above ? 1u : 0u;
|
||||||
|
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
|
||||||
|
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
|
||||||
|
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
|
||||||
|
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
|
||||||
|
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
|
||||||
|
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
|
||||||
|
// whose position is inside the bucket is refused.
|
||||||
|
const double top = acc.level + acc.hysteresis;
|
||||||
|
const double bot = acc.level - acc.hysteresis;
|
||||||
|
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
|
||||||
|
// raw sample inside it decides none, so the walk state is left alone.
|
||||||
|
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
|
||||||
|
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
|
||||||
|
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
|
||||||
|
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
|
||||||
|
// the bucket's first sample, exactly, which is where the raw walk would have found it.
|
||||||
|
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
||||||
|
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
||||||
|
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
||||||
|
// bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
|
||||||
|
// gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
|
||||||
|
// a wrong one is a wrong answer waiting for that window.
|
||||||
|
if (change && !fully_outside) return false;
|
||||||
|
acc.sum[e] += k.sum;
|
||||||
|
acc.count[e] += k.count;
|
||||||
|
if (change) enter_side(acc, e, time_of(k.offset));
|
||||||
|
if (reaches) {
|
||||||
|
acc.side = e != 0;
|
||||||
|
acc.have_side = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it
|
/// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it
|
||||||
/// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a
|
/// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a
|
||||||
/// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2.
|
/// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
|
|
||||||
#include <udps_client.h>
|
#include "status_text.hpp" // `format_samples()` / `format_bytes()`, for the one line a clipped plan prints
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -42,12 +42,24 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
u64 buffer_ticks(u64 requested_samples) {
|
||||||
double target_span_s, bool with_abs_time) {
|
// 2^40 slots is already 8 TiB of clock alone: a request past it is nonsense and the cap keeps
|
||||||
|
// `bytes_for()` far from overflowing.
|
||||||
|
constexpr u64 kMaxTicks = u64(1) << 40;
|
||||||
|
return std::min(pow2_floor(std::max<u64>(requested_samples, 128)), kMaxTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time) {
|
||||||
|
return ticks * sizeof(f64) * (with_abs_time ? 2u : 1u) + ticks * sizeof(f32) * channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
|
||||||
|
u64 max_bytes, 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);
|
||||||
p.value_capacity.assign(n, 0);
|
p.value_capacity.assign(n, 0);
|
||||||
|
p.requested_samples = samples_per_signal;
|
||||||
if (n == 0) return p;
|
if (n == 0) return p;
|
||||||
|
|
||||||
// The fastest channel drives the shared time base: every other channel is
|
// The fastest channel drives the shared time base: every other channel is
|
||||||
@@ -71,25 +83,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
|
|
||||||
p.abs_time = with_abs_time;
|
p.abs_time = with_abs_time;
|
||||||
|
|
||||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
// The ring is a power of two, so the user's count is a ceiling and the plan takes the largest
|
||||||
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
// power of two under it. The budget is in *samples* now: it is the memory limit, not the request,
|
||||||
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
// that is in bytes -- and a time slot (f64) and a value sample (f32) are not the same size, which
|
||||||
// slices are not interchangeable. Counting both as one f32 is what would make
|
// is why the limit below is weighed with `bytes_for()` and not as a count of slots.
|
||||||
// `--buffer-mb` quietly understate the time ring by its own size.
|
//
|
||||||
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s)
|
// A channel's slots are rounded *down* on the power of two `RingBuffer` will round them to
|
||||||
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
// anyway, so every channel covers at most the time ring's span.
|
||||||
|
|
||||||
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
|
||||||
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
|
||||||
if (time_capacity == 0) {
|
|
||||||
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The target span sets a floor, not a ceiling: the rest of the budget buys a
|
|
||||||
// longer ring, which is strictly more history to scroll back through. Growth
|
|
||||||
// stops before the total would exceed the budget. A channel's slots are rounded
|
|
||||||
// *up* on the power of two `RingBuffer` will round them to anyway, so every
|
|
||||||
// channel covers at least the time ring's span.
|
|
||||||
// Floor, not ceiling: a channel's window must not be *longer* than the time ring
|
// Floor, not ceiling: a channel's window must not be *longer* than the time ring
|
||||||
// it reads its stamps from. Rounding up (with a stride that is not a power of two)
|
// it reads its stamps from. Rounding up (with a stride that is not a power of two)
|
||||||
// gave a channel more samples than the shared timeline covers, and the oldest of
|
// gave a channel more samples than the shared timeline covers, and the oldest of
|
||||||
@@ -105,19 +105,24 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
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;
|
||||||
};
|
};
|
||||||
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
|
u64 time_capacity = buffer_ticks(samples_per_signal);
|
||||||
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
|
const u64 wanted_ticks = time_capacity;
|
||||||
while (bytes_for(time_capacity * 2) <= budget_bytes) {
|
// The memory limit halves the request until the rings fit, never the other way: a bigger plan than
|
||||||
time_capacity *= 2;
|
// asked for is not "up to".
|
||||||
|
while (time_capacity > 128 && bytes_for(time_capacity) > max_bytes) time_capacity /= 2;
|
||||||
|
if (bytes_for(time_capacity) > max_bytes) {
|
||||||
|
throw std::runtime_error("memory limit too small for the sample rings of " +
|
||||||
|
std::to_string(n) + " channels");
|
||||||
}
|
}
|
||||||
|
p.memory_limited = time_capacity < wanted_ticks;
|
||||||
p.time_capacity = time_capacity;
|
p.time_capacity = time_capacity;
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
|
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 request and the memory limit allow, 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 the ring's final length would have made unnecessary. A power of two keeps
|
||||||
// the ring capacities (themselves powers of two) in step and lets the producer's group
|
// the ring capacities (themselves powers of two) in step and lets the producer's group
|
||||||
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
|
// 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
|
// this function chose at runtime is a real 64-bit division, which the test *was* paying
|
||||||
@@ -180,6 +185,7 @@ void BufferStore::acquire_all() {
|
|||||||
// --- ScopeModel -------------------------------------------------------------
|
// --- ScopeModel -------------------------------------------------------------
|
||||||
|
|
||||||
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
|
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
|
||||||
|
memory_limit_bytes_.store(cfg.memory_limit_mb * 1024u * 1024u, std::memory_order_relaxed);
|
||||||
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +232,13 @@ 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());
|
||||||
|
// The renames come off the stream's own units first, so that an identical CONFIG still compares
|
||||||
|
// equal against `signals_` (which holds the renamed ones) and a reconnect keeps its history.
|
||||||
|
declared_unit_.clear();
|
||||||
|
for (SignalInfo& s : signals) {
|
||||||
|
declared_unit_[s.name] = s.unit;
|
||||||
|
if (const auto it = unit_override_.find(s.name); it != unit_override_.end()) s.unit = it->second;
|
||||||
|
}
|
||||||
if (!signals_.empty() && same_stream_signals(signals, signals_)) {
|
if (!signals_.empty() && same_stream_signals(signals, signals_)) {
|
||||||
// A reconnect's identical CONFIG: the honest recovery is *nothing* -- the ring keeps
|
// 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
|
// its history, the trigger keeps its state and its held event, and the outage shows
|
||||||
@@ -569,22 +582,16 @@ void ScopeModel::rebuild_locked() {
|
|||||||
holes_.clear();
|
holes_.clear();
|
||||||
++plan_generation_;
|
++plan_generation_;
|
||||||
|
|
||||||
std::vector<double> dts;
|
if (channels_.empty()) {
|
||||||
dts.reserve(channels_.size());
|
|
||||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
|
||||||
|
|
||||||
if (dts.empty()) {
|
|
||||||
part_ = Partition{};
|
part_ = Partition{};
|
||||||
store_.reset();
|
store_.reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
|
const Partition want = plan_locked(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 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
|
// a store is what costs -- its rings are value-initialised, so it touches all of the
|
||||||
// `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration
|
// planned rings -- 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
|
// 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
|
// 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
|
// meaning (`BufferStore::reset()`): the history is gone either way, which is why
|
||||||
@@ -603,6 +610,17 @@ void ScopeModel::rebuild_locked() {
|
|||||||
// the defect the line exists to name (it was fifteen of them at startup).
|
// 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",
|
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);
|
channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6);
|
||||||
|
if (part_.memory_limited) {
|
||||||
|
// The one line that says the request was not honoured, and why: a smaller ring than the source
|
||||||
|
// form promised is otherwise explained nowhere.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] buffer limited to %s samples per signal (%s requested) by the %s "
|
||||||
|
"memory limit; the rings take %s\n",
|
||||||
|
format_samples(part_.time_capacity).c_str(),
|
||||||
|
format_samples(part_.requested_samples).c_str(),
|
||||||
|
format_bytes(memory_limit_bytes()).c_str(),
|
||||||
|
format_bytes(part_.bytes()).c_str());
|
||||||
|
}
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,6 +628,47 @@ void ScopeModel::rebuild_locked() {
|
|||||||
prepare_math_locked();
|
prepare_math_locked();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Partition ScopeModel::plan_locked(double seconds) const {
|
||||||
|
std::vector<double> dts;
|
||||||
|
dts.reserve(channels_.size());
|
||||||
|
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||||
|
return plan_partition(dts, cfg_.buffer_samples, memory_limit_bytes(), seconds, cfg_.relative_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScopeModel::set_memory_limit_bytes(std::uint64_t bytes, bool replan) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
const std::uint64_t previous = memory_limit_bytes_.load(std::memory_order_relaxed);
|
||||||
|
if (bytes == previous) return false;
|
||||||
|
if (channels_.empty() || !replan) {
|
||||||
|
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
|
||||||
|
Partition want;
|
||||||
|
try {
|
||||||
|
want = plan_locked(target_span_.load(std::memory_order_relaxed));
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
memory_limit_bytes_.store(previous, std::memory_order_relaxed);
|
||||||
|
std::fprintf(stderr, "[udpscope] memory limit %s refused: %s\n", format_bytes(bytes).c_str(),
|
||||||
|
e.what());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (store_ && part_.same_storage_as(want)) {
|
||||||
|
// The same rings: only what the plan *says* about itself moved. Written in place, because the
|
||||||
|
// producer reads `part_` under the lock we hold and nothing here reallocates it.
|
||||||
|
part_.requested_samples = want.requested_samples;
|
||||||
|
part_.memory_limited = want.memory_limited;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "[udpscope] memory limit %s -> %s: buffer re-planned\n",
|
||||||
|
format_bytes(previous).c_str(), format_bytes(bytes).c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
rebuild_locked();
|
||||||
|
generation_.fetch_add(1, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool ScopeModel::set_target_span(double seconds) {
|
bool ScopeModel::set_target_span(double seconds) {
|
||||||
if (!(seconds > 0.0)) return false;
|
if (!(seconds > 0.0)) return false;
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
@@ -618,10 +677,7 @@ bool ScopeModel::set_target_span(double seconds) {
|
|||||||
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
|
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
|
||||||
// reimplementing its arithmetic here -- two expressions for the same decision is how
|
// reimplementing its arithmetic here -- two expressions for the same decision is how
|
||||||
// the buffer and the view end up disagreeing about the stored rate.
|
// the buffer and the view end up disagreeing about the stored rate.
|
||||||
std::vector<double> dts;
|
const Partition want = plan_locked(seconds);
|
||||||
dts.reserve(channels_.size());
|
|
||||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
|
||||||
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 "
|
||||||
@@ -646,7 +702,7 @@ bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
|
|||||||
// signal: it has stamps but no cadence of its own, because it is not data.
|
// signal: it has stamps but no cadence of its own, because it is not data.
|
||||||
// Data channels can also be declared full-array (per-element stamps), and they
|
// Data channels can also be declared full-array (per-element stamps), and they
|
||||||
// keep a declared rate -- treating them as time signals hid every real channel.
|
// keep a declared rate -- treating them as time signals hid every real channel.
|
||||||
if (s.time_mode == UDPS_TIME_FULL_ARRAY && s.elements() > 1 && s.sampling_rate == 0.0) {
|
if (s.time_mode == kTimeFullArray && s.elements() > 1 && s.sampling_rate == 0.0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -721,6 +777,38 @@ void ScopeModel::set_calibration(std::size_t signal, double gain, double offset)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_signal_unit(std::size_t signal, const std::string& unit) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (signal >= signals_.size() || signals_[signal].math) return;
|
||||||
|
const std::string name = signals_[signal].name;
|
||||||
|
std::string shown = unit;
|
||||||
|
if (unit.empty()) {
|
||||||
|
unit_override_.erase(name);
|
||||||
|
const auto it = declared_unit_.find(name);
|
||||||
|
shown = it != declared_unit_.end() ? it->second : std::string();
|
||||||
|
} else {
|
||||||
|
unit_override_[name] = unit;
|
||||||
|
}
|
||||||
|
if (signals_[signal].unit == shown) return;
|
||||||
|
signals_[signal].unit = shown;
|
||||||
|
for (ChannelInfo& c : channels_) {
|
||||||
|
if (c.signal == signal) c.unit = shown;
|
||||||
|
}
|
||||||
|
generation_.fetch_add(1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ScopeModel::declared_unit(std::size_t signal) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (signal >= signals_.size()) return std::string();
|
||||||
|
const auto it = declared_unit_.find(signals_[signal].name);
|
||||||
|
return it != declared_unit_.end() ? it->second : signals_[signal].unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<std::string, std::string>> ScopeModel::unit_overrides() {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
return {unit_override_.begin(), unit_override_.end()};
|
||||||
|
}
|
||||||
|
|
||||||
double ScopeModel::channel_dt(std::size_t channel) const {
|
double ScopeModel::channel_dt(std::size_t channel) const {
|
||||||
return channel < channels_.size() ? channels_[channel].dt : 0.0;
|
return channel < channels_.size() ? channels_[channel].dt : 0.0;
|
||||||
}
|
}
|
||||||
@@ -891,9 +979,11 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
snap->values.resize(channels_.size());
|
snap->values.resize(channels_.size());
|
||||||
|
snap->channel_names.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());
|
||||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
|
snap->channel_names[c] = channels_[c].name;
|
||||||
auto& vals = snap->values[c];
|
auto& vals = snap->values[c];
|
||||||
vals.resize(static_cast<std::size_t>(n));
|
vals.resize(static_cast<std::size_t>(n));
|
||||||
if (n > 0) src.values(c).copy_from(first, n, vals.data());
|
if (n > 0) src.values(c).copy_from(first, n, vals.data());
|
||||||
@@ -918,6 +1008,24 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
|||||||
// capture at the first sample past `post_s` rather than exactly on it.
|
// capture at the first sample past `post_s` rather than exactly on it.
|
||||||
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
|
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
|
||||||
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
|
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
|
||||||
|
// The persistence retention: what was on screen until now becomes the *newest of the older*
|
||||||
|
// acquisitions, and the set is cut back to the depth the panel asks for and to the retention
|
||||||
|
// budget. Nothing is retained at depth 1, so the default costs no memory and no copy at all.
|
||||||
|
if (const int depth = persist_depth_locked(); depth > 1) {
|
||||||
|
std::shared_ptr<const EventSnapshot> prev = event_.load(std::memory_order_relaxed);
|
||||||
|
if (prev != nullptr) {
|
||||||
|
// Only the slots the depth wants are shifted: the ones past it are the prune's business, and
|
||||||
|
// moving an acquisition into a slot nothing can ever read is work for a snapshot about to be
|
||||||
|
// dropped.
|
||||||
|
const std::size_t top = std::min(static_cast<std::size_t>(depth - 1), persist_.size());
|
||||||
|
for (std::size_t i = top; i-- > 1;) {
|
||||||
|
persist_[i].store(persist_[i - 1].load(std::memory_order_relaxed),
|
||||||
|
std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
persist_[0].store(std::move(prev), std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prune_persist_locked(static_cast<std::size_t>(persist_depth_locked() - 1));
|
||||||
event_.store(std::move(snap), std::memory_order_release);
|
event_.store(std::move(snap), std::memory_order_release);
|
||||||
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
|
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
|
||||||
// number sees the snapshot it counts.
|
// number sees the snapshot it counts.
|
||||||
@@ -963,6 +1071,48 @@ void ScopeModel::set_trigger_enabled(bool on) {
|
|||||||
trigger_enabled_.store(on, std::memory_order_relaxed);
|
trigger_enabled_.store(on, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScopeModel::prune_persist_locked(std::size_t keep) {
|
||||||
|
const std::size_t limit = std::min(keep, persist_.size());
|
||||||
|
const std::size_t budget = persist_budget_bytes();
|
||||||
|
std::size_t bytes = 0;
|
||||||
|
std::size_t kept = 0;
|
||||||
|
std::uint64_t dropped = 0;
|
||||||
|
bool full = false;
|
||||||
|
for (std::size_t i = 0; i < persist_.size(); ++i) {
|
||||||
|
const std::shared_ptr<const EventSnapshot> snap = persist_[i].load(std::memory_order_relaxed);
|
||||||
|
if (snap == nullptr) continue;
|
||||||
|
if (i < limit) {
|
||||||
|
// Either bound can cut the set: the depth the panel asks for, and the budget, which a long
|
||||||
|
// window on a fast stream reaches long before the seventh layer.
|
||||||
|
const std::size_t b = snapshot_bytes(*snap);
|
||||||
|
if (!full && bytes + b <= budget) {
|
||||||
|
bytes += b;
|
||||||
|
++kept;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
full = true; // does not fit, and neither does anything older
|
||||||
|
// Only what the *budget* refused is counted: a slot past the depth is not a drop, it is the
|
||||||
|
// depth -- and counting it would make the reading say the memory was the reason when the
|
||||||
|
// panel's own number was.
|
||||||
|
++dropped;
|
||||||
|
}
|
||||||
|
persist_[i].store(nullptr, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
persist_count_.store(kept, std::memory_order_relaxed);
|
||||||
|
persist_bytes_.store(bytes, std::memory_order_relaxed);
|
||||||
|
persist_dropped_.store(persist_dropped_.load(std::memory_order_relaxed) + dropped,
|
||||||
|
std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopeModel::set_persist_depth(int depth) {
|
||||||
|
const int n = std::clamp(depth, 1, kMaxPersistDepth);
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
trigger_.persist_depth = n;
|
||||||
|
// Pruned here and not only at the next latch: lowering the depth is the user asking to stop
|
||||||
|
// paying for the layers, and a trigger that has stopped firing never reaches another publish.
|
||||||
|
prune_persist_locked(static_cast<std::size_t>(n - 1));
|
||||||
|
}
|
||||||
|
|
||||||
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
|
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
|
||||||
if (epoch_ == 0.0) epoch_ = e;
|
if (epoch_ == 0.0) epoch_ = e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
#include <plot/data/ring_buffer.hpp>
|
#include <plot/data/ring_buffer.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -22,6 +24,13 @@
|
|||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// `SignalInfo::time_mode` values. They equal the wire protocol's `UDPS_TIME_*`
|
||||||
|
/// (`udps_source.cpp` static_asserts it), but the model must not name a source's own header.
|
||||||
|
constexpr std::uint8_t kTimePacket = 0;
|
||||||
|
constexpr std::uint8_t kTimeFullArray = 1;
|
||||||
|
constexpr std::uint8_t kTimeFirstSample = 2;
|
||||||
|
constexpr std::uint8_t kTimeLastSample = 3;
|
||||||
|
|
||||||
/// One streamed signal, as the CONFIG packet described it.
|
/// One streamed signal, as the CONFIG packet described it.
|
||||||
struct SignalInfo {
|
struct SignalInfo {
|
||||||
std::string name;
|
std::string name;
|
||||||
@@ -95,9 +104,27 @@ struct Partition {
|
|||||||
|
|
||||||
/// True when a second f64 ring parallel to the clock was planned and paid for in the
|
/// 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
|
/// 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.
|
/// clock and a copy would cost ~22% of the user's sample budget for nothing.
|
||||||
bool abs_time = false;
|
bool abs_time = false;
|
||||||
|
|
||||||
|
/// The samples-per-signal the user asked for, as given (not rounded). `time_capacity` is what
|
||||||
|
/// the plan actually holds: the request rounded down to a power of two, then halved further
|
||||||
|
/// while the rings did not fit the memory limit. These two are readings, not storage -- they are
|
||||||
|
/// not in `same_storage_as()` -- so a re-plan that only moves them does not drop history.
|
||||||
|
u64 requested_samples = 0;
|
||||||
|
/// True when the memory limit, and not the power-of-two rounding, made `time_capacity` smaller than
|
||||||
|
/// the request: the one case the UI has to explain.
|
||||||
|
bool memory_limited = false;
|
||||||
|
|
||||||
|
/// The bytes the rings of this plan occupy: the time ring (twice in relative mode) plus every
|
||||||
|
/// value ring. This is the figure to quote and to budget against; a count of the user's
|
||||||
|
/// request is not, because the rings round and the limit can clip.
|
||||||
|
u64 bytes() const noexcept {
|
||||||
|
u64 total = time_capacity * sizeof(f64) * (abs_time ? 2u : 1u);
|
||||||
|
for (u64 c : value_capacity) total += c * sizeof(f32);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -128,13 +155,24 @@ struct Partition {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
|
/// The ring length a request of `requested_samples` per signal buys: the largest power of two not
|
||||||
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
|
/// above it (at least 128). The rings are powers of two, so the user's number is a ceiling.
|
||||||
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
|
u64 buffer_ticks(u64 requested_samples);
|
||||||
/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget
|
|
||||||
/// and sets `Partition::abs_time`.
|
/// What `ticks` slots cost for `channels` unit-stride channels: the f64 time ring (twice with the
|
||||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
/// absolute clock) plus one f32 ring per channel. The planner's own byte formula, for a caller
|
||||||
double target_span_s, bool with_abs_time = false);
|
/// that has no `Partition` yet (the source form, the probes).
|
||||||
|
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time);
|
||||||
|
|
||||||
|
/// Plans a partition for `channel_dt` cadences: `samples_per_signal` slots for the time ring and for
|
||||||
|
/// every channel, rounded down to a power of two, then halved while the rings (`planned_bytes()`)
|
||||||
|
/// exceed `max_bytes` -- the memory limit. Covers at least `target_span_s` seconds of stream,
|
||||||
|
/// decimating (with the ingest filter) by whatever power of two that takes. Throws
|
||||||
|
/// std::runtime_error when even 128 slots do not fit `max_bytes`. `with_abs_time` adds a second f64
|
||||||
|
/// per time slot to the budget and sets `Partition::abs_time`. `Partition::memory_limited` says
|
||||||
|
/// whether the limit clipped the request.
|
||||||
|
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
|
||||||
|
u64 max_bytes, 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.
|
||||||
@@ -178,7 +216,7 @@ class BufferStore {
|
|||||||
///
|
///
|
||||||
/// Why it exists: constructing a store *is* its cost. A ring is a
|
/// 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
|
/// `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
|
/// byte of the planned buffer (`--buffer-samples`) -- about 300 ms per GB, nearly all of it first-touch
|
||||||
/// page faults. Clearing is a count store per ring. Most structural changes (a
|
/// 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
|
/// 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
|
/// *same* partition, and paying a second full allocation for them is what made a large
|
||||||
@@ -427,6 +465,17 @@ class ScopeModel {
|
|||||||
/// no channel to read it from, and the record is what a dialog must show.
|
/// 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::pair<double, double> calibration(std::size_t signal);
|
||||||
|
|
||||||
|
/// Renames the unit a streamed signal is shown, read and exported in. Stored by the signal's *name*
|
||||||
|
/// like the calibration, and re-applied to every later CONFIG, so a rename survives a reconnect.
|
||||||
|
/// An empty `unit` removes the override and restores the unit the stream declared. A math channel's
|
||||||
|
/// unit belongs to its graph and is ignored here. Bumps `generation()` when it changed something,
|
||||||
|
/// which is what makes the panels re-read the signal list.
|
||||||
|
void set_signal_unit(std::size_t signal, const std::string& unit);
|
||||||
|
/// The unit the stream declared for that signal, whatever the user renamed it to.
|
||||||
|
std::string declared_unit(std::size_t signal);
|
||||||
|
/// The renames in force, `{name, unit}`, for the session file.
|
||||||
|
std::vector<std::pair<std::string, std::string>> unit_overrides();
|
||||||
|
|
||||||
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_; }
|
||||||
|
|
||||||
@@ -511,6 +560,55 @@ class ScopeModel {
|
|||||||
/// while the latch count keeps answering "how many events fired".
|
/// while the latch count keeps answering "how many events fired".
|
||||||
std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); }
|
std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
|
// --- persistence ----------------------------------------------------------
|
||||||
|
/// The acquisitions retained for the persistence display, newest first: `persist_event(1)` is
|
||||||
|
/// the event one latch back, `(2)` the one before that. Null for an age nobody kept.
|
||||||
|
///
|
||||||
|
/// Read **without a lock**, like `event()`: the producer publishes these in `publish_event()`
|
||||||
|
/// with the same atomic-`shared_ptr` discipline, and a snapshot is immutable and shared, so a
|
||||||
|
/// reader holds it alive for as long as it draws it. What the *ring* would have given is not
|
||||||
|
/// needed afterwards -- a retained snapshot is a whole window copied out of it.
|
||||||
|
std::shared_ptr<const EventSnapshot> persist_event(std::size_t age) const noexcept {
|
||||||
|
if (age < 1 || age > persist_.size()) return nullptr;
|
||||||
|
return persist_[age - 1].load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
/// How many acquisitions are retained right now: what the depth asks for, or fewer -- never more
|
||||||
|
/// -- when the retention budget cut the set short. A reading, so it is counted as the slots that
|
||||||
|
/// hold a snapshot rather than derived from the depth.
|
||||||
|
std::size_t persist_count() const noexcept {
|
||||||
|
return persist_count_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
/// What those snapshots hold in bytes, and how many acquisitions the *budget* -- as against the
|
||||||
|
/// depth -- has dropped. Both are producer-written and read as readings.
|
||||||
|
std::size_t persist_bytes() const noexcept {
|
||||||
|
return persist_bytes_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t persist_dropped() const noexcept {
|
||||||
|
return persist_dropped_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
/// The bytes persistence may retain, which is the rings' own planned byte total
|
||||||
|
/// (`Partition::bytes()`): the snapshots are copies *of* what the rings hold, so holding as much
|
||||||
|
/// again as the buffer does is the honest ceiling for a display of it -- and it is the bound that
|
||||||
|
/// cannot be driven into an OOM by a long window on a fast stream, whatever depth the panel asks
|
||||||
|
/// for. Zero until a stream has planned a buffer. Read under `mutex()`, like `partition()`.
|
||||||
|
std::size_t persist_budget_bytes() const noexcept {
|
||||||
|
return static_cast<std::size_t>(part_.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
|
||||||
|
/// prunes the retained set to it **at once**: lowering the depth is a user asking to stop paying
|
||||||
|
/// for the layers, and the next latch -- when the retention would otherwise catch up -- may never
|
||||||
|
/// come on a trigger that has stopped firing.
|
||||||
|
///
|
||||||
|
/// Takes `mutex()` itself; do not call it holding the lock.
|
||||||
|
void set_persist_depth(int depth);
|
||||||
|
|
||||||
|
/// How many acquisitions the persistence display keeps: the depth, clamped to the cap. Reading
|
||||||
|
/// `trigger()` needs the lock, which every caller of this already holds.
|
||||||
|
int persist_depth_locked() const noexcept {
|
||||||
|
return std::clamp(trigger_.persist_depth, 1, kMaxPersistDepth);
|
||||||
|
}
|
||||||
|
|
||||||
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
|
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
|
||||||
void acquire_all();
|
void acquire_all();
|
||||||
|
|
||||||
@@ -613,6 +711,22 @@ class ScopeModel {
|
|||||||
/// `mutex()` itself; do not call it holding the lock.
|
/// `mutex()` itself; do not call it holding the lock.
|
||||||
/// Returns true when the buffer was rebuilt.
|
/// Returns true when the buffer was rebuilt.
|
||||||
bool set_target_span(double seconds);
|
bool set_target_span(double seconds);
|
||||||
|
|
||||||
|
/// The memory limit the planner honours, in bytes. Starts as `Config::memory_limit_mb` and is the
|
||||||
|
/// one planner input that changes while the application runs (the preferences). Atomic so the
|
||||||
|
/// status and the probes can read it without the lock.
|
||||||
|
std::uint64_t memory_limit_bytes() const noexcept {
|
||||||
|
return memory_limit_bytes_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
/// Changes the memory limit and re-plans. The buffer is rebuilt -- and the history dropped -- only
|
||||||
|
/// when the new limit changes the *storage* (`Partition::same_storage_as()`): a limit that still
|
||||||
|
/// leaves room for the plan updates `Partition::memory_limited` in place and costs nothing.
|
||||||
|
/// Returns true when the buffer was rebuilt. Takes `mutex()` itself; do not call it holding the
|
||||||
|
/// lock. A limit too small for even 128 slots per channel is refused (returns false, keeps the old
|
||||||
|
/// limit) and said on stderr.
|
||||||
|
/// With `replan` false the limit is only stored, for the next plan: a file or a replayed event
|
||||||
|
/// lives in the buffer, and re-planning it would clear what the user is looking at.
|
||||||
|
bool set_memory_limit_bytes(std::uint64_t bytes, bool replan = true);
|
||||||
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
|
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
/// Trigger state for the UI, published by the acquisition thread. Atomics
|
/// Trigger state for the UI, published by the acquisition thread. Atomics
|
||||||
@@ -676,7 +790,14 @@ class ScopeModel {
|
|||||||
/// so a window change that moves the decimation re-prepares the filters exactly as an edit does.
|
/// so a window change that moves the decimation re-prepares the filters exactly as an edit does.
|
||||||
void prepare_math_locked();
|
void prepare_math_locked();
|
||||||
|
|
||||||
|
/// Plans the partition for the current channels at `seconds` of window with the current sample
|
||||||
|
/// budget and memory limit. The one place the planner's inputs are assembled, so the re-plan on a
|
||||||
|
/// window change, a structural change and a memory-limit change cannot disagree. Throws what
|
||||||
|
/// `plan_partition()` throws. Caller holds `mutex()`.
|
||||||
|
Partition plan_locked(double seconds) const;
|
||||||
|
|
||||||
Config cfg_;
|
Config cfg_;
|
||||||
|
std::atomic<std::uint64_t> memory_limit_bytes_{0};
|
||||||
std::vector<SignalInfo> signals_;
|
std::vector<SignalInfo> signals_;
|
||||||
std::vector<ChannelInfo> channels_;
|
std::vector<ChannelInfo> channels_;
|
||||||
// --- math channels --------------------------------------------------------
|
// --- math channels --------------------------------------------------------
|
||||||
@@ -742,9 +863,25 @@ class ScopeModel {
|
|||||||
std::atomic<double> capture_edge_{0.0};
|
std::atomic<double> capture_edge_{0.0};
|
||||||
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
||||||
std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause
|
std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause
|
||||||
|
/// The retention behind `persist_event()`: one slot per *previous* acquisition, newest first, so
|
||||||
|
/// slot 0 is the event one latch back. `kMaxPersistDepth - 1` of them, because the acquisition on
|
||||||
|
/// screen is `event_` itself and is not retained a second time. Written by the producer in
|
||||||
|
/// `publish_event()` and by `set_persist_depth()`, both under `mutex()`; read without it.
|
||||||
|
std::array<std::atomic<std::shared_ptr<const EventSnapshot>>, kMaxPersistDepth - 1> persist_{};
|
||||||
|
/// The readings `persist_count()`/`persist_bytes()`/`persist_dropped()` answer with, maintained
|
||||||
|
/// where the slots are written.
|
||||||
|
std::atomic<std::size_t> persist_count_{0};
|
||||||
|
std::atomic<std::size_t> persist_bytes_{0};
|
||||||
|
std::atomic<std::uint64_t> persist_dropped_{0};
|
||||||
|
/// Drops the retained snapshots past `keep` (0 empties the set) and recounts the readings.
|
||||||
|
/// Caller holds `mutex()`.
|
||||||
|
void prune_persist_locked(std::size_t keep);
|
||||||
std::atomic<bool> paused_{false};
|
std::atomic<bool> paused_{false};
|
||||||
/// Per-signal-name calibration (see `set_calibration`).
|
/// Per-signal-name calibration (see `set_calibration`).
|
||||||
std::unordered_map<std::string, std::pair<double, double>> calibration_;
|
std::unordered_map<std::string, std::pair<double, double>> calibration_;
|
||||||
|
/// Per-signal-name unit renames (see `set_signal_unit`) and the units the stream declared.
|
||||||
|
std::unordered_map<std::string, std::string> unit_override_;
|
||||||
|
std::unordered_map<std::string, std::string> declared_unit_;
|
||||||
std::atomic<std::uint64_t> event_seq_{0};
|
std::atomic<std::uint64_t> event_seq_{0};
|
||||||
std::atomic<double> display_epoch_{0.0};
|
std::atomic<double> display_epoch_{0.0};
|
||||||
std::atomic<bool> trigger_armed_{false};
|
std::atomic<bool> trigger_armed_{false};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <span>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -67,9 +68,32 @@ constexpr float kMeasureDashOff = 4.0f;
|
|||||||
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
|
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
|
||||||
/// stacked frames read as one tall plot with a line through it.
|
/// stacked frames read as one tall plot with a line through it.
|
||||||
constexpr plot::f32 kRowGap = 6.0f;
|
constexpr plot::f32 kRowGap = 6.0f;
|
||||||
|
/// The gap between two cells of a split row, device pixels: the same as the rows', so the
|
||||||
|
/// plots read as a grid.
|
||||||
|
constexpr plot::f32 kColGap = 6.0f;
|
||||||
|
|
||||||
|
/// The suffix that names a persistence layer, in the accumulate path's own vocabulary --
|
||||||
|
/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, and the signal list, the
|
||||||
|
/// colour resolver, the measurements and the layout writer all keep seeing one signal where the
|
||||||
|
/// plot draws several traces of it.
|
||||||
|
std::string layer_suffix(int age) {
|
||||||
|
if (age == 1) return " (A)";
|
||||||
|
if (age == 2) return " (B)";
|
||||||
|
return " #" + std::to_string(age + 1);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
float RowSet::persist_alpha(int age) noexcept {
|
||||||
|
// Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what
|
||||||
|
// "exponential decay" means and because it is the one schedule that can be read off a line of
|
||||||
|
// numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer
|
||||||
|
// acquisitions instead of restating the ones already on screen.
|
||||||
|
float a = 1.0f;
|
||||||
|
for (int i = 0; i < age; ++i) a *= 0.5f;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
std::string layer_base_name(const std::string& name) {
|
std::string layer_base_name(const std::string& name) {
|
||||||
const std::size_t a = name.rfind(" (");
|
const std::size_t a = name.rfind(" (");
|
||||||
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
|
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
|
||||||
@@ -133,11 +157,56 @@ void RowSet::clear_signal_color(const std::string& signal) {
|
|||||||
|
|
||||||
RowSet::RowSet() { rebuild_all(); }
|
RowSet::RowSet() { rebuild_all(); }
|
||||||
|
|
||||||
|
int RowSet::cells_in_row(int fr) const {
|
||||||
|
return fr >= 0 && fr < figure_row_count() ? cells_per_row_[static_cast<std::size_t>(fr)] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int RowSet::first_plot_of_row(int fr) const {
|
||||||
|
int p = 0;
|
||||||
|
for (int r = 0; r < fr && r < figure_row_count(); ++r) p += cells_per_row_[static_cast<std::size_t>(r)];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
int RowSet::figure_row_of(int plot) const {
|
||||||
|
if (plot < 0 || plot >= rows_) return -1;
|
||||||
|
int p = 0;
|
||||||
|
for (int r = 0; r < figure_row_count(); ++r) {
|
||||||
|
p += cells_per_row_[static_cast<std::size_t>(r)];
|
||||||
|
if (plot < p) return r;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int RowSet::cell_of(int plot) const {
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
return fr < 0 ? -1 : plot - first_plot_of_row(fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<float>& RowSet::cell_weights(int fr) const {
|
||||||
|
static const std::vector<float> none;
|
||||||
|
return fr >= 0 && fr < static_cast<int>(cell_weights_.size())
|
||||||
|
? cell_weights_[static_cast<std::size_t>(fr)]
|
||||||
|
: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string RowSet::plot_label(int plot) const {
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
if (fr < 0) return std::to_string(plot + 1);
|
||||||
|
if (cells_per_row_[static_cast<std::size_t>(fr)] == 1) return std::to_string(fr + 1);
|
||||||
|
return std::to_string(fr + 1) + static_cast<char>('a' + cell_of(plot));
|
||||||
|
}
|
||||||
|
|
||||||
void RowSet::set_row_count(int rows) {
|
void RowSet::set_row_count(int rows) {
|
||||||
const int want = std::clamp(rows, 1, kMaxRows);
|
const int want = std::clamp(rows, 1, kMaxRows);
|
||||||
if (want == rows_) return;
|
const bool all_single =
|
||||||
|
std::all_of(cells_per_row_.begin(), cells_per_row_.end(), [](int c) { return c == 1; });
|
||||||
|
if (want == rows_ && all_single) return;
|
||||||
|
// `n` single-cell rows. The plots keep their flattened slots (and so their contents), which is
|
||||||
|
// what the old "n rows" meant.
|
||||||
rows_ = want;
|
rows_ = want;
|
||||||
// A `Figure`'s row count is fixed at construction, so a new count is a new figure.
|
cells_per_row_.assign(static_cast<std::size_t>(want), 1);
|
||||||
|
cell_weights_.assign(static_cast<std::size_t>(want), {});
|
||||||
|
// A `Figure`'s shape is fixed at construction, so a new count is a new figure.
|
||||||
rebuild_all();
|
rebuild_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +215,11 @@ void RowSet::carry_row(Row& dst, const Row& src) {
|
|||||||
dst.name = src.name;
|
dst.name = src.name;
|
||||||
dst.channel = src.channel;
|
dst.channel = src.channel;
|
||||||
dst.color_index = src.color_index;
|
dst.color_index = src.color_index;
|
||||||
|
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
|
||||||
|
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
|
||||||
|
// moved under a plan made for the old one.
|
||||||
|
dst.layer = src.layer;
|
||||||
|
dst.slot_alpha = src.slot_alpha;
|
||||||
|
|
||||||
dst.ruler_y = src.ruler_y;
|
dst.ruler_y = src.ruler_y;
|
||||||
dst.ruler_b_y = src.ruler_b_y;
|
dst.ruler_b_y = src.ruler_b_y;
|
||||||
@@ -161,39 +235,152 @@ void RowSet::clear_row(Row& r) {
|
|||||||
r.name = {};
|
r.name = {};
|
||||||
r.channel = {-1, -1, -1, -1};
|
r.channel = {-1, -1, -1, -1};
|
||||||
r.color_index = {0, 1, 2, 3};
|
r.color_index = {0, 1, 2, 3};
|
||||||
|
r.layer = {0, 0, 0, 0};
|
||||||
|
r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
|
||||||
r.ruler_y = 0.0;
|
r.ruler_y = 0.0;
|
||||||
for (SlotSeries& s : r.slots) s.bind(nullptr);
|
for (SlotSeries& s : r.slots) s.bind(nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RowSet::insert_row(int at) {
|
bool RowSet::insert_row(int at) {
|
||||||
if (rows_ >= kMaxRows) return false;
|
if (rows_ >= kMaxRows) return false;
|
||||||
const int pos = std::clamp(at, 0, rows_);
|
const int fr = std::clamp(at, 0, figure_row_count());
|
||||||
|
const int pos = first_plot_of_row(fr);
|
||||||
for (int i = rows_; i > pos; --i) carry_row(row(i), row(i - 1));
|
for (int i = rows_; i > pos; --i) carry_row(row(i), row(i - 1));
|
||||||
clear_row(row(pos));
|
clear_row(row(pos));
|
||||||
++rows_;
|
++rows_;
|
||||||
|
cells_per_row_.insert(cells_per_row_.begin() + fr, 1);
|
||||||
|
cell_weights_.insert(cell_weights_.begin() + fr, std::vector<float>{});
|
||||||
|
// Keep row_weights_ in step with the figure row count: the new row gets the default
|
||||||
|
// weight (1.0) at its position so the existing rows keep theirs.
|
||||||
|
if (!row_weights_.empty()) {
|
||||||
|
row_weights_.resize(static_cast<std::size_t>(figure_row_count() - 1), 1.0f);
|
||||||
|
row_weights_.insert(row_weights_.begin() + fr, 1.0f);
|
||||||
|
}
|
||||||
rebuild_all();
|
rebuild_all();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RowSet::remove_row(int at) {
|
bool RowSet::remove_row(int at) {
|
||||||
if (rows_ <= 1 || at < 0 || at >= rows_) return false;
|
if (figure_row_count() <= 1 || at < 0 || at >= figure_row_count()) return false;
|
||||||
for (int i = at; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1));
|
const int pos = first_plot_of_row(at);
|
||||||
--rows_;
|
const int n = cells_per_row_[static_cast<std::size_t>(at)];
|
||||||
clear_row(row(rows_));
|
// The rows below move up by the removed row's cell count; the `n` slots freed at the end go back
|
||||||
|
// to empty.
|
||||||
|
for (int i = pos; i + n < rows_; ++i) carry_row(row(i), row(i + n));
|
||||||
|
for (int i = rows_ - n; i < rows_; ++i) clear_row(row(i));
|
||||||
|
rows_ -= n;
|
||||||
|
cells_per_row_.erase(cells_per_row_.begin() + at);
|
||||||
|
cell_weights_.erase(cell_weights_.begin() + at);
|
||||||
|
// Keep row_weights_ in step: erase the removed row's weight so the surviving rows keep theirs.
|
||||||
|
if (static_cast<std::size_t>(at) < row_weights_.size()) {
|
||||||
|
row_weights_.erase(row_weights_.begin() + at);
|
||||||
|
}
|
||||||
rebuild_all();
|
rebuild_all();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool RowSet::insert_cell(int fr, int at) {
|
||||||
|
if (fr < 0 || fr >= figure_row_count()) return false;
|
||||||
|
if (cells_per_row_[static_cast<std::size_t>(fr)] >= kMaxCellsPerRow || rows_ >= kMaxRows) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int n = cells_per_row_[static_cast<std::size_t>(fr)];
|
||||||
|
const int k = std::clamp(at, 0, n);
|
||||||
|
const int p = first_plot_of_row(fr) + k;
|
||||||
|
for (int i = rows_; i > p; --i) carry_row(row(i), row(i - 1));
|
||||||
|
clear_row(row(p));
|
||||||
|
// The row's rulers belong to the new cell as well (they are one pair for the whole row): taken from
|
||||||
|
// a neighbour that is not the new cell, which sits at `p` and so cannot be its own source.
|
||||||
|
{
|
||||||
|
const Row& src = row(k == 0 ? p + 1 : first_plot_of_row(fr));
|
||||||
|
row(p).ruler_y = src.ruler_y;
|
||||||
|
row(p).ruler_b_y = src.ruler_b_y;
|
||||||
|
}
|
||||||
|
++rows_;
|
||||||
|
++cells_per_row_[static_cast<std::size_t>(fr)];
|
||||||
|
auto& w = cell_weights_[static_cast<std::size_t>(fr)];
|
||||||
|
if (!w.empty()) {
|
||||||
|
// The new cell takes the mean share, so it neither dwarfs nor vanishes beside its neighbours.
|
||||||
|
float mean = 0.0f;
|
||||||
|
for (const float v : w) mean += v;
|
||||||
|
mean /= static_cast<float>(w.size());
|
||||||
|
w.insert(w.begin() + std::min<int>(k, static_cast<int>(w.size())), mean);
|
||||||
|
}
|
||||||
|
rebuild_all();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RowSet::remove_cell(int plot) {
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
if (fr < 0) return false;
|
||||||
|
if (cells_per_row_[static_cast<std::size_t>(fr)] == 1) {
|
||||||
|
return remove_row(fr); // false when it is the last row
|
||||||
|
}
|
||||||
|
for (int i = plot; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1));
|
||||||
|
--rows_;
|
||||||
|
clear_row(row(rows_));
|
||||||
|
const int k = plot - first_plot_of_row(fr);
|
||||||
|
--cells_per_row_[static_cast<std::size_t>(fr)];
|
||||||
|
auto& w = cell_weights_[static_cast<std::size_t>(fr)];
|
||||||
|
if (cells_per_row_[static_cast<std::size_t>(fr)] < 2) {
|
||||||
|
w.clear();
|
||||||
|
} else if (k < static_cast<int>(w.size())) {
|
||||||
|
w.erase(w.begin() + k);
|
||||||
|
}
|
||||||
|
rebuild_all();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_cell_weights(int fr, std::vector<float> w) {
|
||||||
|
if (fr < 0 || fr >= figure_row_count()) return;
|
||||||
|
cell_weights_[static_cast<std::size_t>(fr)] = std::move(w);
|
||||||
|
if (figure_ != nullptr) apply_cell_weights();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_cells_per_row(std::vector<int> cells) {
|
||||||
|
std::vector<int> next;
|
||||||
|
int total = 0;
|
||||||
|
for (int c : cells) {
|
||||||
|
c = std::clamp(c, 1, kMaxCellsPerRow);
|
||||||
|
if (total + c > kMaxRows) c = kMaxRows - total;
|
||||||
|
if (c < 1) break;
|
||||||
|
next.push_back(c);
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
if (next.empty()) {
|
||||||
|
next.push_back(1);
|
||||||
|
total = 1;
|
||||||
|
}
|
||||||
|
cells_per_row_ = std::move(next);
|
||||||
|
cell_weights_.assign(cells_per_row_.size(), {});
|
||||||
|
rows_ = total;
|
||||||
|
rebuild_all();
|
||||||
|
}
|
||||||
|
|
||||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||||
r.plot = &p;
|
r.plot = &p;
|
||||||
r.axis_x = r.plot->add_x_axis(
|
r.axis_x = r.plot->add_x_axis(
|
||||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||||
r.axis_y = r.plot->add_y_axis(
|
r.axis_y = r.plot->add_y_axis(
|
||||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
// Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the
|
||||||
|
// *dimmest* traces are created first and a signal's own trace last, so the newest acquisition is
|
||||||
|
// the ink on top. A layer drawn over the event would take its colour out of the event's own trace
|
||||||
|
// wherever the two agree -- the opposite of what a persistence display shows.
|
||||||
|
std::array<int, kSlotsPerRow> order{};
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast<std::size_t>(s)] = s;
|
||||||
|
std::stable_sort(order.begin(), order.end(), [&r](int a, int b) {
|
||||||
|
return r.slot_alpha[static_cast<std::size_t>(a)] < r.slot_alpha[static_cast<std::size_t>(b)];
|
||||||
|
});
|
||||||
|
for (const int s : order) {
|
||||||
const std::size_t i = static_cast<std::size_t>(s);
|
const std::size_t i = static_cast<std::size_t>(s);
|
||||||
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
|
plot::Color color = signal_color(r.signal_name[i], r.color_index[i]);
|
||||||
r.axis_x, r.axis_y, r.name[i]});
|
// The slot's own brightness times the figure-wide one the group views set: a persistence
|
||||||
|
// layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()`
|
||||||
|
// is what reads it here. It was set and never read before, which is why an accumulation's
|
||||||
|
// traces were opaque.
|
||||||
|
color.a = r.slot_alpha[i] * trace_alpha_;
|
||||||
|
r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]});
|
||||||
}
|
}
|
||||||
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
||||||
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
||||||
@@ -261,14 +448,40 @@ void RowSet::set_row_weights(std::vector<float> weights) {
|
|||||||
|
|
||||||
void RowSet::apply_row_weights() {
|
void RowSet::apply_row_weights() {
|
||||||
if (row_weights_.empty() || figure_ == nullptr) return;
|
if (row_weights_.empty() || figure_ == nullptr) return;
|
||||||
row_weights_.resize(static_cast<std::size_t>(rows_), 1.0f);
|
row_weights_.resize(static_cast<std::size_t>(figure_row_count()), 1.0f);
|
||||||
(void)figure_->set_row_weights(row_weights_);
|
(void)figure_->set_row_weights(row_weights_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RowSet::apply_cell_weights() {
|
||||||
|
if (figure_ == nullptr) return;
|
||||||
|
cell_weights_.resize(cells_per_row_.size());
|
||||||
|
for (int fr = 0; fr < figure_row_count(); ++fr) {
|
||||||
|
auto& w = cell_weights_[static_cast<std::size_t>(fr)];
|
||||||
|
const int n = cells_per_row_[static_cast<std::size_t>(fr)];
|
||||||
|
if (n < 2) {
|
||||||
|
w.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (w.empty()) {
|
||||||
|
figure_->clear_cell_weights(static_cast<plot::u32>(fr));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
w.resize(static_cast<std::size_t>(n), 1.0f);
|
||||||
|
(void)figure_->set_cell_weights(static_cast<plot::u32>(fr), w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void RowSet::rebuild_all() {
|
void RowSet::rebuild_all() {
|
||||||
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
const std::vector<plot::u32> cells(cells_per_row_.begin(), cells_per_row_.end());
|
||||||
|
figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(cells));
|
||||||
figure_->set_row_gap(kRowGap);
|
figure_->set_row_gap(kRowGap);
|
||||||
|
figure_->set_col_gap(kColGap);
|
||||||
|
// The cells of a split row are one plot with a vertical divider, not unrelated plots: they share
|
||||||
|
// one y range and auto state, and only the first draws the y axis. Before `install()`, which adds
|
||||||
|
// the axes the figure enrols; a row of one cell is never a group, so nothing changes for it.
|
||||||
|
figure_->set_share_row_y(true);
|
||||||
apply_row_weights();
|
apply_row_weights();
|
||||||
|
apply_cell_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);
|
||||||
@@ -278,6 +491,13 @@ void RowSet::rebuild_all() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
install(r, i, figure_->plot(static_cast<plot::u32>(i)));
|
install(r, i, figure_->plot(static_cast<plot::u32>(i)));
|
||||||
|
// A ruler is the row's (its y axis is shared), so every cell of a split row holds the first
|
||||||
|
// cell's values -- including a cell a restructure put next to one it had nothing to do with.
|
||||||
|
if (const int fr = figure_row_of(i); fr >= 0 && cell_of(i) > 0) {
|
||||||
|
const Row& first = row(first_plot_of_row(fr));
|
||||||
|
r.ruler_y = first.ruler_y;
|
||||||
|
r.ruler_b_y = first.ruler_b_y;
|
||||||
|
}
|
||||||
place_cursors(r);
|
place_cursors(r);
|
||||||
if (trigger_marker_on_) {
|
if (trigger_marker_on_) {
|
||||||
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
|
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
|
||||||
@@ -320,9 +540,132 @@ void RowSet::assign(int row_index, int slot, std::string signal_name,
|
|||||||
r.channel[s] = -1; // re-resolved by the next bind()
|
r.channel[s] = -1; // re-resolved by the next bind()
|
||||||
r.name[s] = std::move(display_name);
|
r.name[s] = std::move(display_name);
|
||||||
r.color_index[s] = color_index;
|
r.color_index[s] = color_index;
|
||||||
|
// Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer
|
||||||
|
// was using is the user's arrangement, and the tick's plan moves the layers around it next frame.
|
||||||
|
r.layer[s] = 0;
|
||||||
|
r.slot_alpha[s] = 1.0f;
|
||||||
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RowSet::assign_layer(int row_index, int slot, std::string signal_name,
|
||||||
|
std::string display_name, int color_index, int age, float alpha) {
|
||||||
|
assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index);
|
||||||
|
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
|
||||||
|
Row& r = row(row_index);
|
||||||
|
const std::size_t s = static_cast<std::size_t>(slot);
|
||||||
|
r.layer[s] = age;
|
||||||
|
r.slot_alpha[s] = alpha;
|
||||||
|
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::clear_layers() {
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
const std::size_t si = static_cast<std::size_t>(s);
|
||||||
|
if (r.layer[si] == 0) continue;
|
||||||
|
assign(i, s, "", "", r.color_index[si]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int RowSet::sync_layers(int depth, int* want) {
|
||||||
|
const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1;
|
||||||
|
// What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age.
|
||||||
|
std::array<std::array<int, kSlotsPerRow>, kMaxRows> plan{};
|
||||||
|
std::array<std::array<std::string, kSlotsPerRow>, kMaxRows> names{};
|
||||||
|
int wanted = 0;
|
||||||
|
for (int r = 0; r < rows_; ++r) {
|
||||||
|
Row& row_state = row(r);
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
const std::size_t si = static_cast<std::size_t>(s);
|
||||||
|
// A layer slot is free for re-planning; a signal's own trace is not.
|
||||||
|
plan[static_cast<std::size_t>(r)][si] =
|
||||||
|
row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2,
|
||||||
|
// so a row with room for two layers draws the *sharper* one for each of its signals rather than
|
||||||
|
// four ages of the first one.
|
||||||
|
for (int age = 1; age <= ages; ++age) {
|
||||||
|
for (int r = 0; r < rows_; ++r) {
|
||||||
|
const std::size_t ri = static_cast<std::size_t>(r);
|
||||||
|
Row& row_state = row(r);
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
const std::size_t si = static_cast<std::size_t>(s);
|
||||||
|
if (plan[ri][si] != 0) continue;
|
||||||
|
++wanted;
|
||||||
|
int target = -1;
|
||||||
|
for (int t = 0; t < kSlotsPerRow; ++t) {
|
||||||
|
if (plan[ri][static_cast<std::size_t>(t)] == -1) {
|
||||||
|
target = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target < 0) continue; // this row is full: it persists as far as it can
|
||||||
|
const std::size_t ti = static_cast<std::size_t>(target);
|
||||||
|
plan[ri][ti] = age;
|
||||||
|
names[ri][ti] = row_state.signal_name[si] + layer_suffix(age);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int placed = 0;
|
||||||
|
for (int r = 0; r < rows_; ++r) {
|
||||||
|
const std::size_t ri = static_cast<std::size_t>(r);
|
||||||
|
Row& row_state = row(r);
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
const std::size_t si = static_cast<std::size_t>(s);
|
||||||
|
const int now = plan[ri][si];
|
||||||
|
const int had = row_state.layer[si];
|
||||||
|
if (now > 0) {
|
||||||
|
++placed;
|
||||||
|
if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right
|
||||||
|
assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now,
|
||||||
|
persist_alpha(now));
|
||||||
|
} else if (had > 0) {
|
||||||
|
assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (want != nullptr) *want = wanted;
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
|
||||||
|
int RowSet::layer_of(int row_index, int slot) const {
|
||||||
|
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0;
|
||||||
|
return row(row_index).layer[static_cast<std::size_t>(slot)];
|
||||||
|
}
|
||||||
|
|
||||||
|
float RowSet::slot_alpha_of(int row_index, int slot) const {
|
||||||
|
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f;
|
||||||
|
return row(row_index).slot_alpha[static_cast<std::size_t>(slot)];
|
||||||
|
}
|
||||||
|
|
||||||
|
int RowSet::layer_slots() const {
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < kMaxRows; ++i) {
|
||||||
|
const Row& r = row(i);
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
if (r.layer[static_cast<std::size_t>(s)] > 0) ++n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<int, float>> RowSet::row_layers(int row_index) const {
|
||||||
|
std::vector<std::pair<int, float>> out;
|
||||||
|
if (row_index < 0 || row_index >= kMaxRows) return out;
|
||||||
|
const Row& r = row(row_index);
|
||||||
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
const std::size_t si = static_cast<std::size_t>(s);
|
||||||
|
if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]});
|
||||||
|
}
|
||||||
|
// Oldest first: the order the decay has to read in.
|
||||||
|
std::sort(out.begin(), out.end(),
|
||||||
|
[](const auto& a, const auto& b) { return a.first > b.first; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
bool RowSet::slot_bound(int row_index, int slot) const {
|
bool RowSet::slot_bound(int row_index, int slot) const {
|
||||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
|
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
|
||||||
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
|
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
|
||||||
@@ -348,9 +691,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
|
|||||||
// Resolve the stable identity every frame: the channel list is rebuilt (and
|
// Resolve the stable identity every frame: the channel list is rebuilt (and
|
||||||
// renumbered) whenever a signal is enabled, disabled or re-described, and a
|
// renumbered) whenever a signal is enabled, disabled or re-described, and a
|
||||||
// stored index would then point at a different signal's channel.
|
// stored index would then point at a different signal's channel.
|
||||||
|
//
|
||||||
|
// A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one
|
||||||
|
// acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its
|
||||||
|
// signal, drawn in its colour, and counted by nothing that counts signals.
|
||||||
|
const std::string base_name =
|
||||||
|
r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si];
|
||||||
int signal_index = -1;
|
int signal_index = -1;
|
||||||
for (std::size_t k = 0; k < signals.size(); ++k) {
|
for (std::size_t k = 0; k < signals.size(); ++k) {
|
||||||
if (signals[k].name == r.signal_name[si]) {
|
if (signals[k].name == base_name) {
|
||||||
signal_index = static_cast<int>(k);
|
signal_index = static_cast<int>(k);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -364,6 +713,36 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (r.layer[si] > 0) {
|
||||||
|
// A layer holds no channel: every reader that asks "is this slot a trace of a signal"
|
||||||
|
// -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout
|
||||||
|
// writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction.
|
||||||
|
r.channel[si] = -1;
|
||||||
|
// It draws only while a *snapshot* is on screen, and only if its signal is still acquired
|
||||||
|
// (switching a signal off takes its traces off every plot, layers included), and it
|
||||||
|
// resolves its channel **by name inside the snapshot**: the channel list may have been
|
||||||
|
// rebuilt and renumbered many times since that acquisition was cut, which is exactly why
|
||||||
|
// the trigger engine names its channel too.
|
||||||
|
std::shared_ptr<const EventSnapshot> snap;
|
||||||
|
if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) {
|
||||||
|
snap = model.persist_event(static_cast<std::size_t>(r.layer[si]));
|
||||||
|
}
|
||||||
|
std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0;
|
||||||
|
if (snap != nullptr) {
|
||||||
|
for (std::size_t c = 0; c < snap->channel_names.size(); ++c) {
|
||||||
|
if (snap->channel_names[c] == base_name) {
|
||||||
|
layer_channel = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snap != nullptr && layer_channel < snap->values.size()) {
|
||||||
|
r.slots[si].bind_event(std::move(snap), layer_channel);
|
||||||
|
} else {
|
||||||
|
r.slots[si].bind(nullptr);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
r.channel[si] = channel;
|
r.channel[si] = channel;
|
||||||
if (channel < 0) {
|
if (channel < 0) {
|
||||||
// Its signal is present but not acquired: keep the identity (so enabling it
|
// Its signal is present but not acquired: keep the identity (so enabling it
|
||||||
@@ -715,10 +1094,11 @@ bool RowSet::update_time_labels() {
|
|||||||
// millisecond-scale window shows. `x_origin_` is read back below for the caption.
|
// millisecond-scale window shows. `x_origin_` is read back below for the caption.
|
||||||
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
||||||
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
||||||
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
// The caption belongs with the labels, so it goes on every plot whose x strip draws them: the
|
||||||
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
|
// bottom row's, and any row above it that carries a strip of its own (a split row whose columns
|
||||||
const bool bottom = i == rows_ - 1;
|
// differ from the row below's; see `Figure`'s x-axis rule). The plots without labels get an empty
|
||||||
if (!relative_x_ || !bottom) {
|
// one, which draws nothing.
|
||||||
|
if (!relative_x_ || !plot::x_strip_has_labels(r.plot->x_strip_mode())) {
|
||||||
r.plot->set_x_note("");
|
r.plot->set_x_note("");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -753,11 +1133,21 @@ void RowSet::hold_pinned_window() {
|
|||||||
// statement, which is what keeps a fresh pin (`pin_event_window()`, `pin_pause_window()`, both
|
// statement, which is what keeps a fresh pin (`pin_event_window()`, `pin_pause_window()`, both
|
||||||
// of which write the group in this same tick) from being reverted to the member's stale window.
|
// of which write the group in this same tick) from being reverted to the member's stale window.
|
||||||
plot::Range now = figure_->x_link_range(x_group_);
|
plot::Range now = figure_->x_link_range(x_group_);
|
||||||
if (rows_ > 0 && row(0).plot != nullptr) {
|
// Any plot's member may carry the gesture: the controller writes the plot under the pointer, and an x
|
||||||
const plot::Range member = row(0).plot->x_range(row(0).axis_x);
|
// label strip is a plot's own (the bottom row's, or a row above it whose columns differ), so reading
|
||||||
|
// plot 0 alone made a zoom or pan on every other plot's axis vanish when the next statement of the pin
|
||||||
|
// went over it. Every member was last written with `pinned_stated_` (the group push), so the first
|
||||||
|
// one that differs is the gesture; the same clamp below applies whichever plot it came from.
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (r.plot == nullptr) continue;
|
||||||
|
const plot::Range member = r.plot->x_range(r.axis_x);
|
||||||
const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
|
const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
|
||||||
std::abs(member.max - pinned_stated_.max) < 1e-12;
|
std::abs(member.max - pinned_stated_.max) < 1e-12;
|
||||||
if (!member_is_ours) now = member;
|
if (!member_is_ours) {
|
||||||
|
now = member;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const double pin_span = pin.span();
|
const double pin_span = pin.span();
|
||||||
// **A zoom *in* is allowed.** The rule is that the view may not leave the time window, and a
|
// **A zoom *in* is allowed.** The rule is that the view may not leave the time window, and a
|
||||||
@@ -818,6 +1208,11 @@ bool RowSet::event_data_window(double& lo, double& hi) const {
|
|||||||
const Row& r = row(i);
|
const Row& r = row(i);
|
||||||
if (!r.plot) continue;
|
if (!r.plot) continue;
|
||||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
|
// A persistence layer is bound to an event series too, and it is the *past*: letting one
|
||||||
|
// widen this window would move the pin -- and with it the framing and the numbers a pinned
|
||||||
|
// measurement reads -- every time an older acquisition happened to be wider than the one on
|
||||||
|
// screen. What is pinned is the current event's window, and only that.
|
||||||
|
if (r.layer[static_cast<std::size_t>(s)] > 0) continue;
|
||||||
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
|
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
|
||||||
if (ev == nullptr || ev->size() < 2) continue;
|
if (ev == nullptr || ev->size() < 2) continue;
|
||||||
const plot::Range b = ev->x_bounds();
|
const plot::Range b = ev->x_bounds();
|
||||||
@@ -884,6 +1279,18 @@ void RowSet::sync_cursors(int drag_row) {
|
|||||||
const double yb = r.plot->cursor(r.ruler_b).y;
|
const double yb = r.plot->cursor(r.ruler_b).y;
|
||||||
if (y > kParked * 0.5) r.ruler_y = y;
|
if (y > kParked * 0.5) r.ruler_y = y;
|
||||||
if (yb > kParked * 0.5) r.ruler_b_y = yb;
|
if (yb > kParked * 0.5) r.ruler_b_y = yb;
|
||||||
|
// The row's cells share one y axis, so the dragged ruler is the row's: every other cell of the
|
||||||
|
// figure row takes the values and draws them (`Free`: the dragged cell's own value just came
|
||||||
|
// out of its plot and must not be snapped).
|
||||||
|
const int fr = figure_row_of(drag_row);
|
||||||
|
const int first = first_plot_of_row(fr);
|
||||||
|
for (int i = first; i < first + cells_in_row(fr); ++i) {
|
||||||
|
if (i == drag_row) continue;
|
||||||
|
Row& mate = row(i);
|
||||||
|
mate.ruler_y = r.ruler_y;
|
||||||
|
mate.ruler_b_y = r.ruler_b_y;
|
||||||
|
place_cursors(mate, plot::CursorPlacement::Free);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
bool push = false;
|
bool push = false;
|
||||||
if (cursor_mode_ != CursorMode::Horizontal) {
|
if (cursor_mode_ != CursorMode::Horizontal) {
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ class RowSet {
|
|||||||
/// `add_trace()`, which is why this is stored rather than resolved per frame --
|
/// `add_trace()`, which is why this is stored rather than resolved per frame --
|
||||||
/// changing it needs a rebuild, and `assign()` already forces one.
|
/// changing it needs a rebuild, and `assign()` already forces one.
|
||||||
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
|
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
|
||||||
|
/// The *persistence layer* this slot draws: 0 for a signal's own trace (whatever the frame's
|
||||||
|
/// display mode binds it to), `a` > 0 for the acquisition `a` latches back, drawn at
|
||||||
|
/// `slot_alpha`. A layer slot holds a suffixed name (`sine (A)`) and no channel at all -- it is
|
||||||
|
/// not a signal, and `channel[]` stays -1 for it (see `assign_layer()`).
|
||||||
|
std::array<int, kSlotsPerRow> layer = {0, 0, 0, 0};
|
||||||
|
/// How bright the slot's trace is built: 1 for a signal's own trace, `persist_alpha(layer)` for
|
||||||
|
/// a persistence layer, multiplied into `trace_alpha_`. It lives on the slot because a `Plot`
|
||||||
|
/// fixes a trace's colour -- alpha included -- at `add_trace()`, so a per-layer brightness is a
|
||||||
|
/// property of the trace's *creation*, which is what a rebuild is for.
|
||||||
|
std::array<float, kSlotsPerRow> slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
|
||||||
plot::u32 axis_x = 0;
|
plot::u32 axis_x = 0;
|
||||||
plot::u32 axis_y = 0;
|
plot::u32 axis_y = 0;
|
||||||
plot::u32 cursor_a = 0;
|
plot::u32 cursor_a = 0;
|
||||||
@@ -145,8 +155,43 @@ class RowSet {
|
|||||||
double ruler_b_y = 0.0;
|
double ruler_b_y = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The number of **plots** (cells over all figure rows), which is what `row(i)` indexes. With no
|
||||||
|
/// row split it is also the number of figure rows; `figure_row_count()` is the latter.
|
||||||
int row_count() const noexcept { return rows_; }
|
int row_count() const noexcept { return rows_; }
|
||||||
|
|
||||||
|
/// A figure row holds one to `kMaxCellsPerRow` plots side by side, sharing the row's height and
|
||||||
|
/// the figure's one x window. The flattened plot index (`row(i)`, `Row::plot`, the figure's
|
||||||
|
/// `plot(i)`) runs row-major: row 0's cells left to right, then row 1's.
|
||||||
|
static constexpr int kMaxCellsPerRow = 4;
|
||||||
|
int figure_row_count() const noexcept { return static_cast<int>(cells_per_row_.size()); }
|
||||||
|
/// How many cells figure row `fr` holds; 0 for a row that does not exist.
|
||||||
|
int cells_in_row(int fr) const;
|
||||||
|
/// The flattened index of figure row `fr`'s first cell; `row_count()` for `fr ==
|
||||||
|
/// figure_row_count()`.
|
||||||
|
int first_plot_of_row(int fr) const;
|
||||||
|
/// The figure row plot `plot` is in, or -1 for an index that is not a plot.
|
||||||
|
int figure_row_of(int plot) const;
|
||||||
|
/// Plot `plot`'s 0-based position inside its row, or -1.
|
||||||
|
int cell_of(int plot) const;
|
||||||
|
/// Figure row `fr`'s cell width shares (empty means equal; always empty for a single-cell row).
|
||||||
|
const std::vector<float>& cell_weights(int fr) const;
|
||||||
|
/// What the UI calls a plot: the figure row's number (`"2"`) for a single-cell row, the number
|
||||||
|
/// and a letter (`"2a"`..`"2d"`) for a split one.
|
||||||
|
std::string plot_label(int plot) const;
|
||||||
|
|
||||||
|
/// Adds an empty cell to figure row `fr` at position `at` (clamped to the row), pushing the
|
||||||
|
/// cells at and after it right. False at `kMaxCellsPerRow` cells or `kMaxRows` plots.
|
||||||
|
bool insert_cell(int fr, int at);
|
||||||
|
/// Removes plot `plot`. A single-cell row removes the row itself (`remove_row()`); false when
|
||||||
|
/// that would leave no plot.
|
||||||
|
bool remove_cell(int plot);
|
||||||
|
/// Figure row `fr`'s cell width shares, normalised by the plotter; empty means equal.
|
||||||
|
void set_cell_weights(int fr, std::vector<float> w);
|
||||||
|
/// Replaces the whole arrangement: `cells[r]` plots in figure row `r`, each clamped to
|
||||||
|
/// `1..kMaxCellsPerRow` and the total to `kMaxRows`. Weights are reset; plot contents stay in
|
||||||
|
/// their flattened slots.
|
||||||
|
void set_cells_per_row(std::vector<int> cells);
|
||||||
|
|
||||||
/// The alpha every trace is drawn with. The *group* views use it: accumulating events wants their
|
/// 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
|
/// 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
|
/// of a persistence display. It is mixed into the palette colour where the trace is created, so it
|
||||||
@@ -159,22 +204,24 @@ class RowSet {
|
|||||||
/// trace set or the row count changes, so weights that lived only in the figure would be lost on the
|
/// 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.
|
/// first drop or row change after a user had sized their layout.
|
||||||
void set_row_weights(std::vector<float> weights);
|
void set_row_weights(std::vector<float> weights);
|
||||||
/// Sizes the stored weights to the rows that exist and hands them to the figure.
|
/// Sizes the stored weights to the figure rows that exist and hands them to the figure. Weights
|
||||||
|
/// are per **figure row**, not per plot.
|
||||||
void apply_row_weights();
|
void apply_row_weights();
|
||||||
const std::vector<float>& row_weights() const noexcept { return 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` figure rows of one cell each (so `rows` plots, any split undone). A changed
|
||||||
/// count is fixed at construction.
|
/// arrangement rebuilds the figure, since a `Figure`'s shape is fixed at construction.
|
||||||
void set_row_count(int rows);
|
void set_row_count(int rows);
|
||||||
/// Inserts an empty row at `at` (0 = above the first row, `row_count()` = below the
|
/// Inserts an empty single-cell figure row at `at` (0 = above the first row,
|
||||||
/// last), pushing the rows at and below it down. Returns false at `kMaxRows`.
|
/// `figure_row_count()` = below the last), pushing the rows at and below it down with their
|
||||||
|
/// cells. Returns false at `kMaxRows` plots.
|
||||||
///
|
///
|
||||||
/// The rows below move as *rows*: their trace assignments, colours and cursors follow
|
/// The rows below move as *rows*: their trace assignments, colours and cursors follow
|
||||||
/// them, so inserting above row 1 does not renumber what row 1 was plotting. The
|
/// them, so inserting above row 1 does not renumber what row 1 was plotting. The
|
||||||
/// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is
|
/// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is
|
||||||
/// the same mechanism a row-count change already used.
|
/// the same mechanism a row-count change already used.
|
||||||
bool insert_row(int at);
|
bool insert_row(int at);
|
||||||
/// Removes row `at` and pulls the rows below it up. Returns false when only one row
|
/// Removes figure row `at` (with all its cells) and pulls the rows below it up. Returns false
|
||||||
/// is left: a figure with no rows has nothing to draw and no way back.
|
/// when only one row is left: a figure with no rows has nothing to draw and no way back.
|
||||||
bool remove_row(int at);
|
bool remove_row(int at);
|
||||||
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
|
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
|
||||||
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
|
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
|
||||||
@@ -200,6 +247,42 @@ class RowSet {
|
|||||||
/// The channel index a row/slot holds, or -1.
|
/// The channel index a row/slot holds, or -1.
|
||||||
int channel_of(int row, int slot) const;
|
int channel_of(int row, int slot) const;
|
||||||
|
|
||||||
|
// --- persistence layers ---------------------------------------------------
|
||||||
|
/// The brightness a persistence layer is drawn at, `a` acquisitions back: the acquisition on
|
||||||
|
/// screen is the full-bright one and each older one is **half** the previous, so the schedule is
|
||||||
|
/// exponential in age -- 1, 0.5, 0.25, 0.125, ... and by the seventh (0.008, `kMaxPersistDepth`)
|
||||||
|
/// the layer is gone. Halving is per acquisition and not per *depth*, so changing N re-cuts how
|
||||||
|
/// many are kept without rescaling the ones already on screen.
|
||||||
|
static float persist_alpha(int age) noexcept;
|
||||||
|
|
||||||
|
/// Binds a row's slot to a persistence *layer*: the acquisition `age` latches back, at `alpha`
|
||||||
|
/// (see `Row::layer`). The name carries the layer's suffix (`sine (A)`, `sine #4`), which is what
|
||||||
|
/// keeps a layer out of the signal list and out of the measurements -- it is not a signal -- while
|
||||||
|
/// `signal_color()` still resolves it to the base signal's own colour.
|
||||||
|
void assign_layer(int row, int slot, std::string signal_name, std::string display_name,
|
||||||
|
int color_index, int age, float alpha);
|
||||||
|
/// Frees every slot bound to a layer. What turning persistence off -- or the trigger -- is.
|
||||||
|
void clear_layers();
|
||||||
|
/// Lays the persistence layers out over the rows' free slots, one per (signal, age): the signal's
|
||||||
|
/// own trace stays where it is and its layers take the free slots of the *same* row, layer by
|
||||||
|
/// layer (age 1 for every signal first, then age 2, as the accumulate path does), so a row that
|
||||||
|
/// runs out of room draws as many layers as it has room for and no more. Every slot the plan does
|
||||||
|
/// not fill is freed, which is what makes the display follow a layout change, a depth change and a
|
||||||
|
/// signal's removal without any caller having to reset anything.
|
||||||
|
///
|
||||||
|
/// Returns the number of layers placed; `want` is how many the depth asked for, so a caller can
|
||||||
|
/// say when a layout could not show them all.
|
||||||
|
int sync_layers(int depth, int* want);
|
||||||
|
/// The layer a slot is drawing, 0 for a signal's own trace.
|
||||||
|
int layer_of(int row, int slot) const;
|
||||||
|
/// The slot's own brightness (1 unless it is a layer).
|
||||||
|
float slot_alpha_of(int row, int slot) const;
|
||||||
|
/// How many slots are layers, over every row: the reading that says the layers exist.
|
||||||
|
int layer_slots() const;
|
||||||
|
/// The layers of one row, oldest first, as `(age, alpha)` pairs -- the ordering a reader has to
|
||||||
|
/// see to know the decay is the right way round.
|
||||||
|
std::vector<std::pair<int, float>> row_layers(int row) const;
|
||||||
|
|
||||||
/// Rebinds every slot to what `mode` says it should show this frame. Called every
|
/// Rebinds every slot to what `mode` says it should show this frame. Called every
|
||||||
/// frame, because the channel list is renumbered by any structural change.
|
/// frame, because the channel list is renumbered by any structural change.
|
||||||
///
|
///
|
||||||
@@ -517,9 +600,15 @@ class RowSet {
|
|||||||
std::unique_ptr<plot::Figure> figure_;
|
std::unique_ptr<plot::Figure> figure_;
|
||||||
/// 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;
|
||||||
|
/// The number of plots; `sum(cells_per_row_) == rows_` always.
|
||||||
int rows_ = 1;
|
int rows_ = 1;
|
||||||
|
/// Cells per figure row, and each row's cell width shares (empty = equal; kept empty for a
|
||||||
|
/// single-cell row). Both are state of the `RowSet`, restated to every new figure.
|
||||||
|
std::vector<int> cells_per_row_{1};
|
||||||
|
std::vector<std::vector<float>> cell_weights_ = std::vector<std::vector<float>>(1);
|
||||||
|
void apply_cell_weights();
|
||||||
float trace_alpha_ = 1.0f;
|
float trace_alpha_ = 1.0f;
|
||||||
std::vector<float> row_weights_;
|
std::vector<float> row_weights_; ///< per figure row
|
||||||
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;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "settings.hpp"
|
#include "settings.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
@@ -64,6 +65,9 @@ Settings load_settings() {
|
|||||||
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
|
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
|
||||||
} else if (key == "relative_time") {
|
} else if (key == "relative_time") {
|
||||||
s.relative_time = parse_bool(value, s.relative_time);
|
s.relative_time = parse_bool(value, s.relative_time);
|
||||||
|
} else if (key == "memory_limit_mb") {
|
||||||
|
const long mb = std::strtol(value.c_str(), nullptr, 10);
|
||||||
|
if (mb >= 16) s.memory_limit_mb = static_cast<int>(std::min<long>(mb, 1000000000L));
|
||||||
} else if (key == "theme") {
|
} else if (key == "theme") {
|
||||||
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
||||||
} else if (key == "contextual_help") {
|
} else if (key == "contextual_help") {
|
||||||
@@ -83,6 +87,7 @@ bool save_settings(const Settings& settings) {
|
|||||||
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
|
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
|
||||||
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
||||||
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
||||||
|
out << "memory_limit_mb = " << settings.memory_limit_mb << "\n";
|
||||||
out << "theme = \"" << settings.theme << "\"\n";
|
out << "theme = \"" << settings.theme << "\"\n";
|
||||||
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
|
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
|
||||||
// the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the
|
// the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ struct Settings {
|
|||||||
/// buffers are planned before any stream is known, so it takes effect on the next start (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).
|
/// preferences dialog says so, like the configurations dialog does).
|
||||||
bool relative_time = false;
|
bool relative_time = false;
|
||||||
|
/// The hard ceiling on the sample rings' memory, in MiB, for the whole application rather than per
|
||||||
|
/// source (`--memory-limit-mb` overrides it for one run). The planner halves a source's sample
|
||||||
|
/// budget until the rings fit under it. Unlike the cache directory and relative time it applies as
|
||||||
|
/// it is changed: a running scope re-plans its buffer (and so drops its history) when the new limit
|
||||||
|
/// changes the rings.
|
||||||
|
int memory_limit_mb = 8192;
|
||||||
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
|
/// "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.
|
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
|
||||||
std::string theme = "auto";
|
std::string theme = "auto";
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ constexpr Accel kAccels[] = {
|
|||||||
{GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"},
|
{GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"},
|
||||||
{GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"},
|
{GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"},
|
||||||
{GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"},
|
{GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"},
|
||||||
{GDK_KEY_m, 0, AppAction::SideTabMeasure, "m", "Show the Measure tab", "Panes and editors"},
|
{GDK_KEY_m, 0, AppAction::ShowMeasure, "m", "Open or close the Measurements page",
|
||||||
{GDK_KEY_g, 0, AppAction::SideTabSignals, "g", "Show the Signals tab", "Panes and editors"},
|
"Panes and editors"},
|
||||||
|
{GDK_KEY_g, 0, AppAction::ShowSignals, "g", "Open or close the Signals page",
|
||||||
|
"Panes and editors"},
|
||||||
{GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor",
|
{GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor",
|
||||||
"Panes and editors"},
|
"Panes and editors"},
|
||||||
{GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the side panel",
|
{GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the dock",
|
||||||
"Panes and editors"},
|
"Panes and editors"},
|
||||||
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
|
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
|
||||||
"Export the visible range", "Files and windows"},
|
"Export the visible range", "Files and windows"},
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ enum class AppAction {
|
|||||||
TriggerArm,
|
TriggerArm,
|
||||||
TriggerPanel,
|
TriggerPanel,
|
||||||
CursorsPane,
|
CursorsPane,
|
||||||
SideTabMeasure,
|
ShowMeasure,
|
||||||
SideTabSignals,
|
ShowSignals,
|
||||||
LayoutEditor,
|
LayoutEditor,
|
||||||
SidePanel,
|
SidePanel,
|
||||||
Export,
|
Export,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/data_source.hpp"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
void DataSource::post(Command cmd) {
|
||||||
|
std::lock_guard<std::mutex> lk(cmd_mu_);
|
||||||
|
cmds_.push_back(std::move(cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
void DataSource::drain_commands() {
|
||||||
|
std::deque<Command> local;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(cmd_mu_);
|
||||||
|
local.swap(cmds_);
|
||||||
|
}
|
||||||
|
for (auto& c : local) c();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/frame.hpp"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a
|
||||||
|
/// source's state is only ever touched by the thread that owns it.
|
||||||
|
using Command = std::function<void()>;
|
||||||
|
|
||||||
|
/// The counters `StreamStats` shows, in source-neutral form. Producer thread only.
|
||||||
|
struct SourceStats {
|
||||||
|
std::uint64_t packets = 0;
|
||||||
|
std::uint64_t frames = 0;
|
||||||
|
std::uint64_t gaps = 0;
|
||||||
|
std::uint64_t late = 0;
|
||||||
|
std::uint64_t resequenced = 0;
|
||||||
|
std::uint64_t stale = 0;
|
||||||
|
std::uint64_t overflows = 0;
|
||||||
|
std::uint64_t reconnects = 0;
|
||||||
|
bool connected = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class SourceEvent { Connected, Disconnected, Error };
|
||||||
|
|
||||||
|
/// Where a source delivers, from inside `poll()`.
|
||||||
|
class SourceSink {
|
||||||
|
public:
|
||||||
|
virtual ~SourceSink() = default;
|
||||||
|
/// The signal set (re)defined. `publish_mode` is the stream's own publish mode.
|
||||||
|
virtual void on_config(const std::vector<SignalInfo>& signals, std::uint8_t publish_mode) = 0;
|
||||||
|
virtual void on_data(const Frame& frame) = 0;
|
||||||
|
virtual void on_event(SourceEvent event, const char* message) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Receives each raw datagram of a UDP-based source: bytes, length, receive time.
|
||||||
|
using DatagramTap = std::function<void(const std::uint8_t*, std::size_t, double)>;
|
||||||
|
|
||||||
|
/// The per-connection object. `Acquisition` owns one and drives it from the producer thread.
|
||||||
|
class DataSource {
|
||||||
|
public:
|
||||||
|
virtual ~DataSource() = default;
|
||||||
|
|
||||||
|
/// Allocates and connects. Throws `std::runtime_error` with a message fit for the user.
|
||||||
|
/// Does not deliver: CONFIG and `Connected` arrive from `poll()`.
|
||||||
|
virtual void open(SourceSink& sink) = 0;
|
||||||
|
/// Runs queued commands, then waits up to `timeout_ms` for work and delivers it.
|
||||||
|
virtual void poll(int timeout_ms) = 0;
|
||||||
|
virtual void close() = 0;
|
||||||
|
/// Producer thread only.
|
||||||
|
virtual SourceStats stats() const = 0;
|
||||||
|
virtual bool connected() const = 0;
|
||||||
|
/// Optional: UDP-based sources feed the raw-packet capture through this.
|
||||||
|
virtual void set_datagram_tap(DatagramTap) {}
|
||||||
|
|
||||||
|
/// Thread-safe. The command runs inside a later `poll()`.
|
||||||
|
void post(Command cmd);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
/// For `poll()` implementations: runs and clears the queue.
|
||||||
|
void drain_commands();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::mutex cmd_mu_;
|
||||||
|
std::deque<Command> cmds_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/e1608/e1608_device.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace udpscope::e1608 {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr double kClockHz = 80e6;
|
||||||
|
|
||||||
|
std::string lower(std::string s) {
|
||||||
|
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
double clamp_code(double v) { return std::min(65535.0, std::max(0.0, v)); }
|
||||||
|
|
||||||
|
float le_f32(const std::uint8_t* p) {
|
||||||
|
const std::uint32_t u = static_cast<std::uint32_t>(p[0]) | (static_cast<std::uint32_t>(p[1]) << 8) |
|
||||||
|
(static_cast<std::uint32_t>(p[2]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(p[3]) << 24);
|
||||||
|
float f;
|
||||||
|
std::memcpy(&f, &u, sizeof f);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void put_le32(std::vector<std::uint8_t>& v, std::uint32_t x) {
|
||||||
|
for (int i = 0; i < 4; ++i) v.push_back(static_cast<std::uint8_t>((x >> (8 * i)) & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `count` (slope, intercept) pairs at `addr`.
|
||||||
|
void read_table(Link& link, std::uint16_t addr, int count, Cal* out) {
|
||||||
|
const std::uint16_t bytes = static_cast<std::uint16_t>(count * 8);
|
||||||
|
const std::vector<std::uint8_t> req = {static_cast<std::uint8_t>(addr & 0xFF),
|
||||||
|
static_cast<std::uint8_t>(addr >> 8),
|
||||||
|
static_cast<std::uint8_t>(bytes & 0xFF),
|
||||||
|
static_cast<std::uint8_t>(bytes >> 8)};
|
||||||
|
const std::vector<std::uint8_t> r = link.command(0x40, req, bytes);
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
out[i].slope = le_f32(&r[static_cast<std::size_t>(i) * 8]);
|
||||||
|
out[i].intercept = le_f32(&r[static_cast<std::size_t>(i) * 8 + 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
double full_scale(Range r) {
|
||||||
|
static const double fs[] = {10.0, 5.0, 2.0, 1.0};
|
||||||
|
return fs[static_cast<int>(r)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* range_name(Range r) {
|
||||||
|
static const char* const n[] = {"BIP10V", "BIP5V", "BIP2V", "BIP1V"};
|
||||||
|
return n[static_cast<int>(r)];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parse_range(const std::string& text, Range& out) {
|
||||||
|
const std::string t = lower(text);
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
if (t == lower(range_name(static_cast<Range>(i)))) {
|
||||||
|
out = static_cast<Range>(i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* trigger_name(Trigger t) {
|
||||||
|
switch (t) {
|
||||||
|
case Trigger::None: return "none";
|
||||||
|
case Trigger::Rising: return "rising";
|
||||||
|
case Trigger::Falling: return "falling";
|
||||||
|
case Trigger::High: return "high";
|
||||||
|
case Trigger::Low: return "low";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parse_trigger(const std::string& text, Trigger& out) {
|
||||||
|
const std::string t = lower(text);
|
||||||
|
for (Trigger c : {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High, Trigger::Low}) {
|
||||||
|
if (t == trigger_name(c)) {
|
||||||
|
out = c;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double Calibration::volts(std::uint16_t raw, Range range, bool diff) const {
|
||||||
|
const Cal& c = (diff ? df : se)[static_cast<int>(range)];
|
||||||
|
const double code = clamp_code(std::round(static_cast<double>(raw) * c.slope + c.intercept));
|
||||||
|
return (code - 32768.0) * full_scale(range) / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint16_t Calibration::dac_code(int ch, double v) const {
|
||||||
|
v = std::min(10.0, std::max(-10.0, v));
|
||||||
|
const double nominal = clamp_code(std::round(v * 32768.0 / 10.0 + 32768.0));
|
||||||
|
const Cal& c = ao[ch & 1];
|
||||||
|
return static_cast<std::uint16_t>(clamp_code(std::round(nominal * c.slope + c.intercept)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
|
||||||
|
const Calibration& cal, std::vector<std::vector<double>>& out) {
|
||||||
|
const std::size_t nq = queue.size();
|
||||||
|
out.resize(nq);
|
||||||
|
for (std::size_t q = 0; q < nq; ++q) {
|
||||||
|
out[q].resize(scans);
|
||||||
|
for (std::size_t s = 0; s < scans; ++s)
|
||||||
|
out[q][s] = cal.volts(raw[s * nq + q], queue[q].range, queue[q].diff());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Device::read_calibration(Calibration& cal) {
|
||||||
|
Calibration c;
|
||||||
|
try {
|
||||||
|
read_table(link_, 0x00, 4, c.df);
|
||||||
|
read_table(link_, 0x20, 4, c.se);
|
||||||
|
read_table(link_, 0x40, 2, c.ao);
|
||||||
|
} catch (const LinkError& e) {
|
||||||
|
if (e.kind() != LinkError::Kind::Command) throw;
|
||||||
|
cal = Calibration{};
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
c.loaded = true;
|
||||||
|
cal = c;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint8_t Device::din() { return link_.command(0x00, {}, 1)[0]; }
|
||||||
|
|
||||||
|
void Device::dout(std::uint8_t value) { link_.command(0x03, {value}); }
|
||||||
|
|
||||||
|
void Device::dconf(std::uint8_t inputs_mask) { link_.command(0x05, {inputs_mask}); }
|
||||||
|
|
||||||
|
double Device::aout(int ch, double volts, const Calibration& cal) {
|
||||||
|
const double v = std::min(10.0, std::max(-10.0, volts));
|
||||||
|
const std::uint16_t code = cal.dac_code(ch, v);
|
||||||
|
link_.command(0x21, {static_cast<std::uint8_t>(ch & 1), static_cast<std::uint8_t>(code & 0xFF),
|
||||||
|
static_cast<std::uint8_t>(code >> 8)});
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t Device::counter() {
|
||||||
|
const std::vector<std::uint8_t> r = link_.command(0x30, {}, 4);
|
||||||
|
return static_cast<std::uint32_t>(r[0]) | (static_cast<std::uint32_t>(r[1]) << 8) |
|
||||||
|
(static_cast<std::uint32_t>(r[2]) << 16) | (static_cast<std::uint32_t>(r[3]) << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Device::counter_reset() { link_.command(0x31); }
|
||||||
|
|
||||||
|
std::uint16_t Device::status() {
|
||||||
|
const std::vector<std::uint8_t> r = link_.command(0x52, {}, 2);
|
||||||
|
return static_cast<std::uint16_t>(r[0] | (r[1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Device::scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
|
||||||
|
Trigger trigger) {
|
||||||
|
link_.open_scan();
|
||||||
|
try {
|
||||||
|
std::vector<std::uint8_t> q;
|
||||||
|
q.push_back(static_cast<std::uint8_t>(queue.size()));
|
||||||
|
for (const QueueEntry& e : queue) {
|
||||||
|
q.push_back(e.wire_channel);
|
||||||
|
q.push_back(static_cast<std::uint8_t>(e.range));
|
||||||
|
}
|
||||||
|
link_.command(0x15, q);
|
||||||
|
|
||||||
|
std::uint32_t pacer = 0;
|
||||||
|
if (!ext_clock) {
|
||||||
|
const double p = std::round(kClockHz / aggregate_hz - 1.0);
|
||||||
|
pacer = static_cast<std::uint32_t>(std::min(4294967295.0, std::max(1.0, p)));
|
||||||
|
}
|
||||||
|
std::vector<std::uint8_t> s;
|
||||||
|
put_le32(s, 0); // count 0: scan until stopped
|
||||||
|
put_le32(s, pacer);
|
||||||
|
s.push_back(static_cast<std::uint8_t>(trigger));
|
||||||
|
link_.command(0x11, s);
|
||||||
|
link_.send_filler();
|
||||||
|
} catch (...) {
|
||||||
|
link_.close_scan();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Device::scan_stop() {
|
||||||
|
link_.close_scan();
|
||||||
|
try {
|
||||||
|
link_.command(0x13, {1});
|
||||||
|
} catch (const LinkError&) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope::e1608
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/e1608/e1608_link.hpp"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope::e1608 {
|
||||||
|
|
||||||
|
enum class Range : std::uint8_t { Bip10V = 0, Bip5V = 1, Bip2V = 2, Bip1V = 3 };
|
||||||
|
|
||||||
|
double full_scale(Range r); ///< 10, 5, 2, 1
|
||||||
|
const char* range_name(Range r); ///< "BIP10V" ...
|
||||||
|
/// Case-insensitive: "BIP10V", "bip5v", ...
|
||||||
|
bool parse_range(const std::string& text, Range& out);
|
||||||
|
|
||||||
|
enum class Trigger : std::uint8_t { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 };
|
||||||
|
|
||||||
|
const char* trigger_name(Trigger t); ///< "none", "rising", "falling", "high", "low"
|
||||||
|
bool parse_trigger(const std::string& text, Trigger& out); ///< case-insensitive
|
||||||
|
|
||||||
|
struct Cal {
|
||||||
|
double slope = 1.0;
|
||||||
|
double intercept = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The factory calibration: one (slope, intercept) per range for single-ended and differential
|
||||||
|
/// inputs, one per DAC. Defaults are the nominal (uncalibrated) values.
|
||||||
|
struct Calibration {
|
||||||
|
Cal se[4];
|
||||||
|
Cal df[4];
|
||||||
|
Cal ao[2];
|
||||||
|
bool loaded = false;
|
||||||
|
|
||||||
|
/// A raw ADC count as calibrated volts.
|
||||||
|
double volts(std::uint16_t raw, Range range, bool diff) const;
|
||||||
|
/// The DAC code that makes `ch` output `volts` (clamped to +-10 V).
|
||||||
|
std::uint16_t dac_code(int ch, double volts) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One entry of the scan queue. DIFF channels are `channel + 8` on the wire.
|
||||||
|
struct QueueEntry {
|
||||||
|
std::uint8_t wire_channel;
|
||||||
|
Range range;
|
||||||
|
bool diff() const noexcept { return wire_channel >= 8; }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Turns `scans` interleaved scans of `queue.size()` raw counts into calibrated volts:
|
||||||
|
/// `out[queue index][scan]`.
|
||||||
|
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
|
||||||
|
const Calibration& cal, std::vector<std::vector<double>>& out);
|
||||||
|
|
||||||
|
/// Typed commands over a `Link`. Holds a reference: the link must outlive it.
|
||||||
|
class Device {
|
||||||
|
public:
|
||||||
|
explicit Device(Link& link) : link_(link) {}
|
||||||
|
|
||||||
|
/// Fills `cal` from the board's calibration memory and sets `cal.loaded`. A refusal from the
|
||||||
|
/// board (`LinkError::Kind::Command`) leaves the nominal values and returns false; any other
|
||||||
|
/// error propagates.
|
||||||
|
bool read_calibration(Calibration& cal);
|
||||||
|
|
||||||
|
std::uint8_t din();
|
||||||
|
void dout(std::uint8_t value);
|
||||||
|
/// `inputs_mask`: a set bit makes that line an input.
|
||||||
|
void dconf(std::uint8_t inputs_mask);
|
||||||
|
/// Writes `volts` (clamped to +-10 V, calibrated) to DAC `ch`; returns the clamped volts.
|
||||||
|
double aout(int ch, double volts, const Calibration& cal);
|
||||||
|
std::uint32_t counter();
|
||||||
|
void counter_reset();
|
||||||
|
/// Bit 0 scan socket open, bit 1 scan running, bit 2 overrun.
|
||||||
|
std::uint16_t status();
|
||||||
|
|
||||||
|
/// Opens the scan socket, writes the queue, starts the scan and sends the filler byte.
|
||||||
|
/// `aggregate_hz` is the scan rate times the number of channels. On failure the scan socket
|
||||||
|
/// is closed again.
|
||||||
|
void scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
|
||||||
|
Trigger trigger);
|
||||||
|
/// Closes the scan socket and tells the board to stop; board errors are ignored.
|
||||||
|
void scan_stop();
|
||||||
|
|
||||||
|
private:
|
||||||
|
Link& link_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope::e1608
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/e1608/e1608_source.hpp"
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
#include "source/e1608/e1608_panels.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace e1608;
|
||||||
|
using ChangeCb = std::shared_ptr<std::function<void()>>;
|
||||||
|
|
||||||
|
constexpr const char* kTriggerNames[] = {"none", "rising", "falling", "high", "low"};
|
||||||
|
constexpr Trigger kTriggers[] = {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High,
|
||||||
|
Trigger::Low};
|
||||||
|
|
||||||
|
std::string num(double v) {
|
||||||
|
char b[48];
|
||||||
|
std::snprintf(b, sizeof b, "%.10g", v);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* entry_row(const char* title) {
|
||||||
|
GtkWidget* r = adw_entry_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
|
||||||
|
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* switch_row(const char* title, bool def) {
|
||||||
|
GtkWidget* r = adw_switch_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(r), def);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* combo_row(const char* title, const char* const* items) {
|
||||||
|
GtkWidget* r = adw_combo_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
|
||||||
|
GtkWidget* e = adw_expander_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
|
||||||
|
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
|
||||||
|
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
|
||||||
|
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
|
||||||
|
bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w)); }
|
||||||
|
|
||||||
|
/// The form's widgets. Held by a `shared_ptr` that every signal closure and every `ConfigForm`
|
||||||
|
/// closure shares; the raw widget pointers are valid while the chooser keeps the rows.
|
||||||
|
struct Form {
|
||||||
|
GtkWidget *address, *mac, *subnet, *port, *code, *timeout;
|
||||||
|
GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate, *gen_rate;
|
||||||
|
GtkWidget *mode, *ai[8], *ai_group;
|
||||||
|
GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2];
|
||||||
|
ChangeCb changed = std::make_shared<std::function<void()>>();
|
||||||
|
bool updating = false;
|
||||||
|
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
|
||||||
|
GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows
|
||||||
|
std::vector<GtkWidget*> results; ///< the result rows of the current scan
|
||||||
|
std::unique_ptr<Scan> scan; ///< non-null while a scan is running
|
||||||
|
guint timer = 0; ///< the GLib source polling `scan`, 0 when none
|
||||||
|
int scan_ms = 0; ///< time spent in the running scan
|
||||||
|
|
||||||
|
bool diff() const { return sel_of(mode) == 1; }
|
||||||
|
int enabled_ai() const {
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < (diff() ? 4 : 8); ++i) n += sel_of(ai[i]) > 0 ? 1 : 0;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In DIFF mode AI rows 4 to 7 are insensitive, not hidden; the group's subtitle shows the load.
|
||||||
|
void refresh() {
|
||||||
|
const bool d = diff();
|
||||||
|
for (int i = 0; i < 8; ++i) gtk_widget_set_sensitive(ai[i], !(d && i >= 4));
|
||||||
|
const int n = enabled_ai();
|
||||||
|
char b[96];
|
||||||
|
std::snprintf(b, sizeof b, "%d channel%s, %.3g of 250 kS/s", n, n == 1 ? "" : "s",
|
||||||
|
n * value_of(rate) / 1000.0);
|
||||||
|
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(ai_group), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceConfig read() const {
|
||||||
|
SourceConfig c;
|
||||||
|
if (!text_of(address).empty()) c.set("address", text_of(address));
|
||||||
|
if (!text_of(mac).empty()) c.set("mac", text_of(mac));
|
||||||
|
if (!text_of(subnet).empty()) c.set("subnet", text_of(subnet));
|
||||||
|
c.set("port", num(value_of(port)));
|
||||||
|
c.set("code", num(value_of(code)));
|
||||||
|
c.set("timeout_ms", num(value_of(timeout)));
|
||||||
|
c.set("scan_rate", num(value_of(rate)));
|
||||||
|
c.set("ext_clock", on_of(ext_clock) ? "1" : "0");
|
||||||
|
c.set("trigger", kTriggerNames[std::min<guint>(sel_of(trigger), 4)]);
|
||||||
|
c.set("auto_restart", on_of(auto_restart) ? "1" : "0");
|
||||||
|
c.set("calibrate", on_of(calibrate) ? "1" : "0");
|
||||||
|
c.set("poll_rate", num(value_of(poll_rate)));
|
||||||
|
c.set("gen_rate", num(value_of(gen_rate)));
|
||||||
|
c.set("mode", diff() ? "diff" : "se");
|
||||||
|
static const char* const kRange[] = {"", "BIP10V", "BIP5V", "BIP2V", "BIP1V"};
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
const guint s = sel_of(ai[i]);
|
||||||
|
if (s > 0 && s <= 4 && !(diff() && i >= 4)) c.set("ai" + std::to_string(i), kRange[s]);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
const guint s = sel_of(dir[i]); // 0 input, 1 input plotted, 2 output
|
||||||
|
c.set("dir" + std::to_string(i), s == 2 ? "o" : "i");
|
||||||
|
c.set("plot" + std::to_string(i), s == 1 ? "1" : "0");
|
||||||
|
}
|
||||||
|
c.set("plot_counter", on_of(plot_counter) ? "1" : "0");
|
||||||
|
c.set("do_init", num(value_of(do_init)));
|
||||||
|
c.set("ao0_init", num(value_of(ao_init[0])));
|
||||||
|
c.set("ao1_init", num(value_of(ao_init[1])));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
void write(const SourceConfig& cfg) {
|
||||||
|
const E1608Settings s = E1608Settings::from(cfg);
|
||||||
|
updating = true;
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(address), s.address.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(mac), s.mac_text.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(subnet), s.subnet.c_str());
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(code), static_cast<double>(s.code));
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(timeout), s.timeout_ms);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.scan_rate);
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(ext_clock), s.ext_clock);
|
||||||
|
for (guint i = 0; i < 5; ++i) {
|
||||||
|
if (kTriggers[i] == s.trigger) adw_combo_row_set_selected(ADW_COMBO_ROW(trigger), i);
|
||||||
|
}
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(auto_restart), s.auto_restart);
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(calibrate), s.calibrate);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(poll_rate), s.poll_rate);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(gen_rate), s.gen_rate);
|
||||||
|
adw_combo_row_set_selected(ADW_COMBO_ROW(mode), s.diff ? 1 : 0);
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
adw_combo_row_set_selected(
|
||||||
|
ADW_COMBO_ROW(ai[i]), s.ai_on[i] ? static_cast<guint>(s.ai_range[i]) + 1 : 0);
|
||||||
|
adw_combo_row_set_selected(ADW_COMBO_ROW(dir[i]), s.dir_out[i] ? 2 : (s.plot[i] ? 1 : 0));
|
||||||
|
}
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(plot_counter), s.plot_counter);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(do_init), s.do_init);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[0]), s.ao_init[0]);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[1]), s.ao_init[1]);
|
||||||
|
updating = false;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A result click: puts one way of naming the board in and the other two fields out, under the
|
||||||
|
/// `updating` guard so the dialog is told once.
|
||||||
|
void fill(const std::string& addr, const std::string& mac_text) {
|
||||||
|
if (!alive) return;
|
||||||
|
updating = true;
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(subnet), "");
|
||||||
|
updating = false;
|
||||||
|
if (changed && *changed) (*changed)();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop_scan() {
|
||||||
|
if (timer != 0) g_source_remove(timer);
|
||||||
|
timer = 0;
|
||||||
|
scan.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
using FormPtr = std::shared_ptr<Form>;
|
||||||
|
|
||||||
|
void on_notify(GObject*, GParamSpec*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
if (!f.alive) return;
|
||||||
|
f.refresh();
|
||||||
|
if (!f.updating && f.changed && *f.changed) (*f.changed)();
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_edited(GtkEditable*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
if (!f.alive) return;
|
||||||
|
if (!f.updating && f.changed && *f.changed) (*f.changed)();
|
||||||
|
}
|
||||||
|
|
||||||
|
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
|
||||||
|
|
||||||
|
/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone.
|
||||||
|
void on_form_destroy(GtkWidget*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
f.alive = false;
|
||||||
|
f.stop_scan(); // the poll must not outlive the rows it writes to
|
||||||
|
}
|
||||||
|
|
||||||
|
void hook_destroy(GtkWidget* w, const FormPtr& f) {
|
||||||
|
g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) {
|
||||||
|
g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void hook_entry(GtkWidget* w, const FormPtr& f) {
|
||||||
|
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int kScanMs = 1500; ///< how long a scan listens
|
||||||
|
constexpr int kTickMs = 50; ///< how often it drains the sockets
|
||||||
|
|
||||||
|
/// A result row's two ways to name the board: what each fills into the form (the other is cleared).
|
||||||
|
struct Pick {
|
||||||
|
FormPtr form;
|
||||||
|
std::string value;
|
||||||
|
};
|
||||||
|
|
||||||
|
void free_pick(gpointer d, GClosure*) { delete static_cast<Pick*>(d); }
|
||||||
|
|
||||||
|
void on_pick_address(AdwActionRow*, gpointer user) {
|
||||||
|
Pick& p = *static_cast<Pick*>(user);
|
||||||
|
p.form->fill(p.value, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_pick_mac(GtkButton*, gpointer user) {
|
||||||
|
Pick& p = *static_cast<Pick*>(user);
|
||||||
|
p.form->fill("", p.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void add_result(const FormPtr& f, const DeviceInfo& d) {
|
||||||
|
const std::string mac = format_mac(d.mac);
|
||||||
|
GtkWidget* row = adw_action_row_new();
|
||||||
|
gtk_widget_set_name(row, "e1608-result");
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str());
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str());
|
||||||
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE);
|
||||||
|
GtkWidget* use_mac = gtk_button_new_with_label("Use MAC");
|
||||||
|
gtk_widget_set_name(use_mac, "e1608-use-mac");
|
||||||
|
gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER);
|
||||||
|
adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac);
|
||||||
|
g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address),
|
||||||
|
new Pick{f, d.ip}, free_pick, static_cast<GConnectFlags>(0));
|
||||||
|
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, mac},
|
||||||
|
free_pick, static_cast<GConnectFlags>(0));
|
||||||
|
adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row);
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE);
|
||||||
|
f->results.push_back(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_scan_status(Form& f, const std::string& text) {
|
||||||
|
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is
|
||||||
|
/// settled and the scan (its sockets) is released.
|
||||||
|
gboolean on_scan_tick(gpointer user) {
|
||||||
|
const FormPtr& fp = *static_cast<FormPtr*>(user);
|
||||||
|
Form& f = *fp;
|
||||||
|
if (!f.alive || !f.scan) {
|
||||||
|
f.timer = 0;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
const auto& found = f.scan->poll(); // valid until f.scan.reset() below, after its last use
|
||||||
|
while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]);
|
||||||
|
f.scan_ms += kTickMs;
|
||||||
|
if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE;
|
||||||
|
if (found.empty())
|
||||||
|
set_scan_status(f, "No board found");
|
||||||
|
else if (found.size() == 1)
|
||||||
|
set_scan_status(f, "1 board found");
|
||||||
|
else
|
||||||
|
set_scan_status(f, std::to_string(found.size()) + " boards found");
|
||||||
|
f.scan.reset();
|
||||||
|
f.timer = 0;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void free_form_ptr(gpointer d) { delete static_cast<FormPtr*>(d); }
|
||||||
|
|
||||||
|
/// A new scan on the Port field's value: the previous results go, a running scan is replaced.
|
||||||
|
void start_scan(const FormPtr& fp) {
|
||||||
|
Form& f = *fp;
|
||||||
|
if (!f.alive) return;
|
||||||
|
f.stop_scan();
|
||||||
|
for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r);
|
||||||
|
f.results.clear();
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE);
|
||||||
|
f.scan = std::make_unique<Scan>(static_cast<int>(value_of(f.port)));
|
||||||
|
f.scan_ms = 0;
|
||||||
|
set_scan_status(f, "Scanning...");
|
||||||
|
f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp),
|
||||||
|
free_form_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_scan_clicked(GtkButton*, gpointer user) {
|
||||||
|
start_scan(*static_cast<FormPtr*>(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
class E1608Kind final : public SourceKind {
|
||||||
|
public:
|
||||||
|
std::string id() const override { return "e1608"; }
|
||||||
|
std::string display_name() const override { return "MCC E-1608"; }
|
||||||
|
bool has_datagrams() const override { return false; }
|
||||||
|
|
||||||
|
std::string identity(const SourceConfig& cfg) const override {
|
||||||
|
return E1608Settings::from(cfg).identity();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
||||||
|
return std::make_unique<E1608Source>(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigForm make_config_form() const override {
|
||||||
|
auto f = std::make_shared<Form>();
|
||||||
|
static const char* const kTrig[] = {"None", "Rising edge", "Falling edge", "High level",
|
||||||
|
"Low level", nullptr};
|
||||||
|
static const char* const kMode[] = {"Single-ended (8 channels)", "Differential (4 channels)",
|
||||||
|
nullptr};
|
||||||
|
static const char* const kAi[] = {"Off", "\u00b110 V", "\u00b15 V", "\u00b12 V", "\u00b11 V",
|
||||||
|
nullptr};
|
||||||
|
static const char* const kDir[] = {"Input", "Input, plotted", "Output", nullptr};
|
||||||
|
|
||||||
|
f->address = entry_row("_Address");
|
||||||
|
f->mac = entry_row("_MAC address");
|
||||||
|
f->subnet = entry_row("_Discovery subnet");
|
||||||
|
f->port = spin_row("_Port", 1, 65534, 1, kDefaultPort, 0);
|
||||||
|
f->code = spin_row("Connection _code", 0, 4294967295.0, 1, 0, 0);
|
||||||
|
f->timeout = spin_row("_Timeout (ms)", 100, 60000, 100, 2000, 0);
|
||||||
|
f->rate = spin_row("Scan _rate (S/s per channel)", 0.02, 250000, 1, 1000, 2);
|
||||||
|
f->ext_clock = switch_row("_External clock", false);
|
||||||
|
f->trigger = combo_row("T_rigger", kTrig);
|
||||||
|
f->auto_restart = switch_row("_Restart after an overrun", true);
|
||||||
|
f->calibrate = switch_row("Apply _factory calibration", true);
|
||||||
|
f->poll_rate = spin_row("_Input poll rate (Hz)", 0.1, 100, 1, 10, 1);
|
||||||
|
f->gen_rate = spin_row("_Generator update rate (Hz)", 1, 200, 1, 50, 0);
|
||||||
|
f->mode = combo_row("Input _mode", kMode);
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
const std::string title = "Channel " + std::to_string(i);
|
||||||
|
f->ai[i] = combo_row(title.c_str(), kAi);
|
||||||
|
adw_combo_row_set_selected(ADW_COMBO_ROW(f->ai[i]), i == 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
const std::string title = "Bit " + std::to_string(i);
|
||||||
|
f->dir[i] = combo_row(title.c_str(), kDir);
|
||||||
|
}
|
||||||
|
f->plot_counter = switch_row("Plot the _counter", false);
|
||||||
|
f->do_init = spin_row("Initial output value", 0, 255, 1, 0, 0);
|
||||||
|
f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3);
|
||||||
|
f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3);
|
||||||
|
|
||||||
|
f->scan_row = adw_expander_row_new();
|
||||||
|
gtk_widget_set_name(f->scan_row, "e1608-scan-row");
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network");
|
||||||
|
GtkWidget* scan_button = gtk_button_new_with_label("Scan");
|
||||||
|
gtk_widget_set_name(scan_button, "e1608-scan");
|
||||||
|
gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER);
|
||||||
|
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button);
|
||||||
|
g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f),
|
||||||
|
free_form, static_cast<GConnectFlags>(0));
|
||||||
|
|
||||||
|
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
|
||||||
|
f->code, f->timeout});
|
||||||
|
GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart,
|
||||||
|
f->calibrate, f->poll_rate, f->gen_rate});
|
||||||
|
GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3],
|
||||||
|
f->ai[4], f->ai[5], f->ai[6], f->ai[7]});
|
||||||
|
GtkWidget* dio = expander("Digital I/O", {f->dir[0], f->dir[1], f->dir[2], f->dir[3], f->dir[4],
|
||||||
|
f->dir[5], f->dir[6], f->dir[7], f->plot_counter,
|
||||||
|
f->do_init, f->ao_init[0], f->ao_init[1]});
|
||||||
|
f->ai_group = ai;
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(conn), TRUE);
|
||||||
|
|
||||||
|
for (GtkWidget* w : {conn, acq, ai, dio}) hook_destroy(w, f);
|
||||||
|
for (GtkWidget* w : {f->address, f->mac, f->subnet}) hook_entry(w, f);
|
||||||
|
for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->gen_rate, f->do_init,
|
||||||
|
f->ao_init[0], f->ao_init[1]})
|
||||||
|
hook_notify(w, "notify::value", f);
|
||||||
|
for (GtkWidget* w : {f->ext_clock, f->auto_restart, f->calibrate, f->plot_counter})
|
||||||
|
hook_notify(w, "notify::active", f);
|
||||||
|
hook_notify(f->trigger, "notify::selected", f);
|
||||||
|
hook_notify(f->mode, "notify::selected", f);
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
hook_notify(f->ai[i], "notify::selected", f);
|
||||||
|
hook_notify(f->dir[i], "notify::selected", f);
|
||||||
|
}
|
||||||
|
f->refresh();
|
||||||
|
|
||||||
|
ConfigForm out;
|
||||||
|
out.rows = {conn, acq, ai, dio};
|
||||||
|
out.description =
|
||||||
|
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), "
|
||||||
|
"or scan the network for boards";
|
||||||
|
out.focus = f->address;
|
||||||
|
out.write = [f](const SourceConfig& c) { f->write(c); };
|
||||||
|
out.read = [f]() { return f->read(); };
|
||||||
|
out.valid = [f]() { return f->valid(); };
|
||||||
|
ChangeCb cb = f->changed;
|
||||||
|
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<SourcePanel> make_panels(DataSource& source) const override {
|
||||||
|
return make_e1608_panels(source);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<SourceKind> make_e1608_kind() { return std::make_unique<E1608Kind>(); }
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/e1608/e1608_link.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <ifaddrs.h>
|
||||||
|
#include <net/if.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <netinet/tcp.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <thread>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace udpscope::e1608 {
|
||||||
|
|
||||||
|
void Fd::reset(int fd) noexcept {
|
||||||
|
if (fd_ >= 0) ::close(fd_);
|
||||||
|
fd_ = fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using Kind = LinkError::Kind;
|
||||||
|
|
||||||
|
constexpr std::size_t kMaxFrame = 1024;
|
||||||
|
|
||||||
|
[[noreturn]] void sys_fail(const std::string& what) {
|
||||||
|
throw LinkError(Kind::Io, what + ": " + std::strerror(errno));
|
||||||
|
}
|
||||||
|
|
||||||
|
int ms_left(Clock::time_point deadline) {
|
||||||
|
const auto d = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now());
|
||||||
|
return d.count() < 0 ? 0 : static_cast<int>(d.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `fd` is ready for `events` (or has an error/hang-up to report); false on timeout.
|
||||||
|
bool wait_fd(int fd, short events, int timeout_ms) {
|
||||||
|
pollfd p{fd, events, 0};
|
||||||
|
for (;;) {
|
||||||
|
const int r = ::poll(&p, 1, timeout_ms);
|
||||||
|
if (r > 0) return true;
|
||||||
|
if (r == 0) return false;
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
sys_fail("poll");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sockaddr_in make_addr(const std::string& host, int port) {
|
||||||
|
sockaddr_in a{};
|
||||||
|
a.sin_family = AF_INET;
|
||||||
|
a.sin_port = htons(static_cast<std::uint16_t>(port));
|
||||||
|
if (::inet_pton(AF_INET, host.c_str(), &a.sin_addr) == 1) return a;
|
||||||
|
addrinfo hints{};
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_STREAM;
|
||||||
|
addrinfo* res = nullptr;
|
||||||
|
if (::getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr)
|
||||||
|
throw LinkError(Kind::Io, "cannot resolve '" + host + "'");
|
||||||
|
a.sin_addr = reinterpret_cast<const sockaddr_in*>(res->ai_addr)->sin_addr;
|
||||||
|
::freeaddrinfo(res);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
void send_all(int fd, const std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
|
||||||
|
while (n > 0) {
|
||||||
|
if (!wait_fd(fd, POLLOUT, ms_left(deadline)))
|
||||||
|
throw LinkError(Kind::Timeout, "timed out sending to the board");
|
||||||
|
const ssize_t w = ::send(fd, p, n, MSG_NOSIGNAL | MSG_DONTWAIT);
|
||||||
|
if (w < 0) {
|
||||||
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
||||||
|
sys_fail("send");
|
||||||
|
}
|
||||||
|
p += w;
|
||||||
|
n -= static_cast<std::size_t>(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void read_exact(int fd, std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
|
||||||
|
while (n > 0) {
|
||||||
|
if (!wait_fd(fd, POLLIN, ms_left(deadline)))
|
||||||
|
throw LinkError(Kind::Timeout, "timed out waiting for the board");
|
||||||
|
const ssize_t r = ::recv(fd, p, n, MSG_DONTWAIT);
|
||||||
|
if (r == 0) throw LinkError(Kind::Io, "the board closed the connection");
|
||||||
|
if (r < 0) {
|
||||||
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
||||||
|
sys_fail("recv");
|
||||||
|
}
|
||||||
|
p += r;
|
||||||
|
n -= static_cast<std::size_t>(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connected, non-blocking TCP socket with TCP_NODELAY. `rcvbuf > 0` sets SO_RCVBUF first.
|
||||||
|
Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) {
|
||||||
|
const sockaddr_in a = make_addr(host, port);
|
||||||
|
Fd fd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0));
|
||||||
|
if (!fd.valid()) sys_fail("socket");
|
||||||
|
const int one = 1;
|
||||||
|
::setsockopt(fd.get(), IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
||||||
|
if (rcvbuf > 0) ::setsockopt(fd.get(), SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
|
||||||
|
const std::string where = host + ":" + std::to_string(port);
|
||||||
|
if (::connect(fd.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0 &&
|
||||||
|
errno != EINPROGRESS)
|
||||||
|
sys_fail("cannot connect to " + where);
|
||||||
|
if (!wait_fd(fd.get(), POLLOUT, timeout_ms))
|
||||||
|
throw LinkError(Kind::Timeout, "timed out connecting to " + where);
|
||||||
|
int err = 0;
|
||||||
|
socklen_t len = sizeof err;
|
||||||
|
::getsockopt(fd.get(), SOL_SOCKET, SO_ERROR, &err, &len);
|
||||||
|
if (err != 0) {
|
||||||
|
errno = err;
|
||||||
|
sys_fail("cannot connect to " + where);
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board
|
||||||
|
/// that `found` did not already hold (one entry per MAC).
|
||||||
|
bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from,
|
||||||
|
std::vector<DeviceInfo>& found) {
|
||||||
|
if (n < 29 || buf[0] != 'D') return false;
|
||||||
|
DeviceInfo info;
|
||||||
|
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
|
||||||
|
if (info.product_id != kProductId) return false;
|
||||||
|
std::copy(buf + 1, buf + 7, info.mac.begin());
|
||||||
|
for (const DeviceInfo& d : found) {
|
||||||
|
if (d.mac == info.mac) return false;
|
||||||
|
}
|
||||||
|
char ip[INET_ADDRSTRLEN] = {};
|
||||||
|
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
|
||||||
|
info.ip = ip;
|
||||||
|
found.push_back(info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// --- addresses ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
bool parse_mac(const std::string& text, Mac& out) {
|
||||||
|
Mac m{};
|
||||||
|
std::size_t pos = 0;
|
||||||
|
for (std::size_t i = 0; i < 6; ++i) {
|
||||||
|
if (pos + 2 > text.size()) return false;
|
||||||
|
unsigned v = 0;
|
||||||
|
for (std::size_t k = 0; k < 2; ++k) {
|
||||||
|
const char c = text[pos + k];
|
||||||
|
v <<= 4;
|
||||||
|
if (c >= '0' && c <= '9') v |= static_cast<unsigned>(c - '0');
|
||||||
|
else if (c >= 'a' && c <= 'f') v |= static_cast<unsigned>(c - 'a' + 10);
|
||||||
|
else if (c >= 'A' && c <= 'F') v |= static_cast<unsigned>(c - 'A' + 10);
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
m[i] = static_cast<std::uint8_t>(v);
|
||||||
|
pos += 2;
|
||||||
|
if (i < 5) {
|
||||||
|
if (pos >= text.size() || (text[pos] != ':' && text[pos] != '-')) return false;
|
||||||
|
++pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos != text.size()) return false;
|
||||||
|
out = m;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_mac(const Mac& mac) {
|
||||||
|
char buf[24];
|
||||||
|
std::snprintf(buf, sizeof buf, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3],
|
||||||
|
mac[4], mac[5]);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> sweep_targets(const std::string& subnet) {
|
||||||
|
std::string s = subnet;
|
||||||
|
while (!s.empty() && s.front() == ' ') s.erase(s.begin());
|
||||||
|
while (!s.empty() && (s.back() == ' ' || s.back() == '.')) s.pop_back();
|
||||||
|
if (s.empty()) return {"255.255.255.255"};
|
||||||
|
int parts = 0;
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i <= s.size()) {
|
||||||
|
const std::size_t dot = s.find('.', i);
|
||||||
|
const std::string part = s.substr(i, dot == std::string::npos ? std::string::npos : dot - i);
|
||||||
|
if (part.empty() || part.size() > 3) return {};
|
||||||
|
int v = 0;
|
||||||
|
for (char c : part) {
|
||||||
|
if (c < '0' || c > '9') return {};
|
||||||
|
v = v * 10 + (c - '0');
|
||||||
|
}
|
||||||
|
if (v > 255) return {};
|
||||||
|
++parts;
|
||||||
|
if (dot == std::string::npos) break;
|
||||||
|
i = dot + 1;
|
||||||
|
}
|
||||||
|
if (parts != 3) return {};
|
||||||
|
std::vector<std::string> out;
|
||||||
|
out.reserve(254);
|
||||||
|
for (int h = 1; h <= 254; ++h) out.push_back(s + "." + std::to_string(h));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms) {
|
||||||
|
std::vector<DeviceInfo> found;
|
||||||
|
Fd u(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
|
||||||
|
if (!u.valid()) sys_fail("socket");
|
||||||
|
const int one = 1;
|
||||||
|
::setsockopt(u.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
|
||||||
|
const std::uint8_t probe = 'D';
|
||||||
|
for (const std::string& t : targets) {
|
||||||
|
sockaddr_in a{};
|
||||||
|
try {
|
||||||
|
a = make_addr(t, port);
|
||||||
|
} catch (const LinkError&) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A sweep may hit unreachable hosts; those sends fail and are simply skipped. A full send
|
||||||
|
// buffer is not that: wait for room once and retry, so a large sweep does not lose probes.
|
||||||
|
const auto send_probe = [&] {
|
||||||
|
return ::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&a),
|
||||||
|
sizeof a);
|
||||||
|
};
|
||||||
|
if (send_probe() < 0 && (errno == EAGAIN || errno == ENOBUFS)) {
|
||||||
|
wait_fd(u.get(), POLLOUT, 20);
|
||||||
|
(void)send_probe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
|
||||||
|
for (;;) {
|
||||||
|
if (!wait_fd(u.get(), POLLIN, ms_left(deadline))) break;
|
||||||
|
std::uint8_t buf[128];
|
||||||
|
sockaddr_in from{};
|
||||||
|
socklen_t flen = sizeof from;
|
||||||
|
const ssize_t n = ::recvfrom(u.get(), buf, sizeof buf, MSG_DONTWAIT,
|
||||||
|
reinterpret_cast<sockaddr*>(&from), &flen);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
take_reply(buf, n, from, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
Scan::Scan(int port) {
|
||||||
|
ifaddrs* list = nullptr;
|
||||||
|
if (::getifaddrs(&list) != 0) return;
|
||||||
|
sockaddr_in to{};
|
||||||
|
to.sin_family = AF_INET;
|
||||||
|
to.sin_port = htons(static_cast<std::uint16_t>(port));
|
||||||
|
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
|
||||||
|
const std::uint8_t probe = 'D';
|
||||||
|
std::vector<std::string> done; // an interface with several addresses needs one probe, not several
|
||||||
|
for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) {
|
||||||
|
if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue;
|
||||||
|
const unsigned fl = a->ifa_flags;
|
||||||
|
if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue;
|
||||||
|
const std::string name = a->ifa_name;
|
||||||
|
if (std::find(done.begin(), done.end(), name) != done.end()) continue;
|
||||||
|
Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
|
||||||
|
if (!s.valid()) continue;
|
||||||
|
const int one = 1;
|
||||||
|
::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
|
||||||
|
if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(),
|
||||||
|
static_cast<socklen_t>(name.size())) != 0) {
|
||||||
|
sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
|
||||||
|
me.sin_port = 0;
|
||||||
|
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
|
||||||
|
}
|
||||||
|
if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
|
||||||
|
sizeof to) != 1)
|
||||||
|
continue;
|
||||||
|
done.push_back(name);
|
||||||
|
socks_.push_back(std::move(s));
|
||||||
|
}
|
||||||
|
::freeifaddrs(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<DeviceInfo>& Scan::poll() {
|
||||||
|
for (const Fd& s : socks_) {
|
||||||
|
for (;;) {
|
||||||
|
std::uint8_t buf[128];
|
||||||
|
sockaddr_in from{};
|
||||||
|
socklen_t flen = sizeof from;
|
||||||
|
const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT,
|
||||||
|
reinterpret_cast<sockaddr*>(&from), &flen);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
break; // nothing more on this socket for now
|
||||||
|
}
|
||||||
|
take_reply(buf, n, from, found_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms) {
|
||||||
|
Scan scan(port);
|
||||||
|
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
|
||||||
|
while (ms_left(deadline) > 0) {
|
||||||
|
scan.poll();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
}
|
||||||
|
return scan.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Link -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) {
|
||||||
|
close();
|
||||||
|
host_ = host;
|
||||||
|
port_ = port;
|
||||||
|
timeout_ms_ = timeout_ms;
|
||||||
|
next_id_ = 0;
|
||||||
|
|
||||||
|
// The connection-code handshake: a connected UDP socket, so an ICMP refusal surfaces.
|
||||||
|
{
|
||||||
|
const sockaddr_in a = make_addr(host, port);
|
||||||
|
Fd u(::socket(AF_INET, SOCK_DGRAM, 0));
|
||||||
|
if (!u.valid()) sys_fail("socket");
|
||||||
|
if (::connect(u.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0)
|
||||||
|
sys_fail("handshake with " + host);
|
||||||
|
const std::uint8_t msg[5] = {'C', static_cast<std::uint8_t>(code & 0xFF),
|
||||||
|
static_cast<std::uint8_t>((code >> 8) & 0xFF),
|
||||||
|
static_cast<std::uint8_t>((code >> 16) & 0xFF),
|
||||||
|
static_cast<std::uint8_t>((code >> 24) & 0xFF)};
|
||||||
|
if (::send(u.get(), msg, sizeof msg, MSG_NOSIGNAL) < 0) sys_fail("handshake with " + host);
|
||||||
|
if (!wait_fd(u.get(), POLLIN, timeout_ms))
|
||||||
|
throw LinkError(Kind::Timeout, "no answer from the board at " + host);
|
||||||
|
std::uint8_t rep[16];
|
||||||
|
const ssize_t n = ::recv(u.get(), rep, sizeof rep, 0);
|
||||||
|
if (n < 0) sys_fail("handshake with " + host);
|
||||||
|
if (n < 2 || rep[0] != 'C') throw LinkError(Kind::Protocol, "unexpected handshake reply");
|
||||||
|
switch (rep[1]) {
|
||||||
|
case 0:
|
||||||
|
case 2:
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
throw LinkError(Kind::Rejected, "the board rejected the connection code");
|
||||||
|
case 3:
|
||||||
|
throw LinkError(Kind::Busy, "the board is busy (another client holds it)");
|
||||||
|
default:
|
||||||
|
throw LinkError(Kind::Protocol, "unexpected handshake status " + std::to_string(rep[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd_ = tcp_connect(host, port, timeout_ms, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Link::close() {
|
||||||
|
scan_.reset();
|
||||||
|
cmd_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> Link::command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload,
|
||||||
|
int expect) {
|
||||||
|
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
|
||||||
|
if (payload.size() + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "command too long");
|
||||||
|
// Bytes left by an earlier exchange that timed out must not be read as this reply.
|
||||||
|
{
|
||||||
|
std::uint8_t junk[256];
|
||||||
|
while (::recv(cmd_.get(), junk, sizeof junk, MSG_DONTWAIT) > 0) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (++next_id_ == 0) next_id_ = 1; // id 0 is never used, in case a board treats it specially
|
||||||
|
const std::uint8_t id = next_id_;
|
||||||
|
std::vector<std::uint8_t> f;
|
||||||
|
f.reserve(7 + payload.size());
|
||||||
|
f.push_back(0xDB);
|
||||||
|
f.push_back(cmd);
|
||||||
|
f.push_back(id);
|
||||||
|
f.push_back(0);
|
||||||
|
f.push_back(static_cast<std::uint8_t>(payload.size() & 0xFF));
|
||||||
|
f.push_back(static_cast<std::uint8_t>((payload.size() >> 8) & 0xFF));
|
||||||
|
f.insert(f.end(), payload.begin(), payload.end());
|
||||||
|
unsigned sum = 0;
|
||||||
|
for (std::uint8_t b : f) sum += b;
|
||||||
|
f.push_back(static_cast<std::uint8_t>(0xFF - (sum & 0xFF)));
|
||||||
|
|
||||||
|
const auto deadline = Clock::now() + std::chrono::milliseconds(timeout_ms_);
|
||||||
|
send_all(cmd_.get(), f.data(), f.size(), deadline);
|
||||||
|
|
||||||
|
std::uint8_t h[6];
|
||||||
|
read_exact(cmd_.get(), h, sizeof h, deadline);
|
||||||
|
if (h[0] != 0xDB) throw LinkError(Kind::Protocol, "bad sync byte in a reply");
|
||||||
|
const std::size_t n = static_cast<std::size_t>(h[4] | (h[5] << 8));
|
||||||
|
if (n + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "oversized reply");
|
||||||
|
std::vector<std::uint8_t> body(n + 1);
|
||||||
|
read_exact(cmd_.get(), body.data(), body.size(), deadline);
|
||||||
|
unsigned rsum = 0;
|
||||||
|
for (std::uint8_t b : h) rsum += b;
|
||||||
|
for (std::uint8_t b : body) rsum += b;
|
||||||
|
if ((rsum & 0xFF) != 0xFF) throw LinkError(Kind::Protocol, "bad checksum in a reply");
|
||||||
|
if (h[1] != (cmd | 0x80) || h[2] != id)
|
||||||
|
throw LinkError(Kind::Protocol, "reply does not match the command");
|
||||||
|
if (h[3] != 0) {
|
||||||
|
static const char* const kText[] = {"ok", "protocol error", "parameter error",
|
||||||
|
"busy", "not ready", "timeout",
|
||||||
|
"other error"};
|
||||||
|
const char* t = h[3] < 7 ? kText[h[3]] : "unknown error";
|
||||||
|
char hex[8];
|
||||||
|
std::snprintf(hex, sizeof hex, "%02x", cmd);
|
||||||
|
throw LinkError(Kind::Command, std::string("the board refused command 0x") + hex + ": " + t);
|
||||||
|
}
|
||||||
|
body.pop_back();
|
||||||
|
if (expect >= 0 && body.size() != static_cast<std::size_t>(expect))
|
||||||
|
throw LinkError(Kind::Command, "reply of " + std::to_string(body.size()) +
|
||||||
|
" bytes, expected " + std::to_string(expect));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Link::open_scan() {
|
||||||
|
close_scan();
|
||||||
|
scan_ = tcp_connect(host_, port_ + 1, timeout_ms_, 1 << 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Link::close_scan() { scan_.reset(); }
|
||||||
|
|
||||||
|
void Link::send_filler() {
|
||||||
|
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
|
||||||
|
const std::uint8_t z = 0;
|
||||||
|
send_all(cmd_.get(), &z, 1, Clock::now() + std::chrono::milliseconds(timeout_ms_));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t Link::read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms) {
|
||||||
|
if (!scan_.valid()) throw LinkError(Kind::Io, "the scan socket is not open");
|
||||||
|
if (cap == 0) return 0; // recv would return 0 and read as a close
|
||||||
|
if (!wait_fd(scan_.get(), POLLIN, timeout_ms)) return 0;
|
||||||
|
const ssize_t n = ::recv(scan_.get(), buf, cap, MSG_DONTWAIT);
|
||||||
|
if (n > 0) return static_cast<std::size_t>(n);
|
||||||
|
if (n == 0) throw LinkError(Kind::Io, "scan socket closed by the board");
|
||||||
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0;
|
||||||
|
sys_fail("scan recv");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope::e1608
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope::e1608 {
|
||||||
|
|
||||||
|
constexpr int kDefaultPort = 54211;
|
||||||
|
constexpr std::uint16_t kProductId = 0x012F;
|
||||||
|
|
||||||
|
/// Every failure of the link. `Rejected` (a bad connection code) is the only one a retry cannot
|
||||||
|
/// fix. `Command` is a reply the board answered with a nonzero status, or a reply of the wrong
|
||||||
|
/// length: the link itself is fine. `Protocol` is a framing error (sync, checksum, id).
|
||||||
|
class LinkError : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
enum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command };
|
||||||
|
LinkError(Kind kind, const std::string& what) : std::runtime_error(what), kind_(kind) {}
|
||||||
|
Kind kind() const noexcept { return kind_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
Kind kind_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An owned file descriptor.
|
||||||
|
class Fd {
|
||||||
|
public:
|
||||||
|
Fd() = default;
|
||||||
|
explicit Fd(int fd) : fd_(fd) {}
|
||||||
|
Fd(const Fd&) = delete;
|
||||||
|
Fd& operator=(const Fd&) = delete;
|
||||||
|
Fd(Fd&& o) noexcept : fd_(o.release()) {}
|
||||||
|
Fd& operator=(Fd&& o) noexcept {
|
||||||
|
if (this != &o) reset(o.release());
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
~Fd() { reset(); }
|
||||||
|
int get() const noexcept { return fd_; }
|
||||||
|
bool valid() const noexcept { return fd_ >= 0; }
|
||||||
|
int release() noexcept {
|
||||||
|
const int f = fd_;
|
||||||
|
fd_ = -1;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
/// Closes the current descriptor (if any) and takes `fd`.
|
||||||
|
void reset(int fd = -1) noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
int fd_ = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
using Mac = std::array<std::uint8_t, 6>;
|
||||||
|
|
||||||
|
/// Six hex bytes separated by `:` or `-`. False (and `out` untouched) on anything else.
|
||||||
|
bool parse_mac(const std::string& text, Mac& out);
|
||||||
|
/// Lower-case `aa:bb:cc:dd:ee:ff`.
|
||||||
|
std::string format_mac(const Mac& mac);
|
||||||
|
|
||||||
|
struct DeviceInfo {
|
||||||
|
std::string ip;
|
||||||
|
Mac mac{};
|
||||||
|
std::uint16_t product_id = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Where to send a discovery `D`: an empty subnet is the limited broadcast; `a.b.c` (an optional
|
||||||
|
/// trailing dot) is a unicast to hosts .1 to .254; anything else is empty.
|
||||||
|
std::vector<std::string> sweep_targets(const std::string& subnet);
|
||||||
|
|
||||||
|
/// Sends `D` to every target, then collects replies for `wait_ms`. Keeps E-1608s only (product id
|
||||||
|
/// `kProductId`) and one entry per MAC.
|
||||||
|
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms);
|
||||||
|
|
||||||
|
/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default
|
||||||
|
/// route only, so on a host with several interfaces it can miss a board on another one; this opens
|
||||||
|
/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or
|
||||||
|
/// to the interface address when binding to the device is refused), sends `D` to
|
||||||
|
/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the
|
||||||
|
/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan.
|
||||||
|
class Scan {
|
||||||
|
public:
|
||||||
|
explicit Scan(int port);
|
||||||
|
Scan(const Scan&) = delete;
|
||||||
|
Scan& operator=(const Scan&) = delete;
|
||||||
|
/// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC.
|
||||||
|
const std::vector<DeviceInfo>& poll();
|
||||||
|
/// How many interfaces a probe was sent on.
|
||||||
|
std::size_t interfaces() const noexcept { return socks_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<Fd> socks_;
|
||||||
|
std::vector<DeviceInfo> found_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
|
||||||
|
|
||||||
|
/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`).
|
||||||
|
/// Not thread safe; the source's producer thread owns it.
|
||||||
|
class Link {
|
||||||
|
public:
|
||||||
|
/// Handshake, then the command connection. Throws `LinkError` (`Rejected` for a bad code).
|
||||||
|
void connect(const std::string& host, int port, std::uint32_t code, int timeout_ms);
|
||||||
|
/// Closes the command and scan sockets. Safe to call at any time.
|
||||||
|
void close();
|
||||||
|
bool is_open() const noexcept { return cmd_.valid(); }
|
||||||
|
|
||||||
|
/// One framed exchange. `expect >= 0` demands a reply payload of exactly that length. Throws
|
||||||
|
/// `Command` for a nonzero status or a length mismatch, `Protocol` for a bad frame.
|
||||||
|
std::vector<std::uint8_t> command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload = {},
|
||||||
|
int expect = -1);
|
||||||
|
|
||||||
|
/// Opens the scan stream socket (a 1 MiB receive buffer, set before connecting).
|
||||||
|
void open_scan();
|
||||||
|
void close_scan();
|
||||||
|
bool scan_open() const noexcept { return scan_.valid(); }
|
||||||
|
/// The single `0x00` byte on the command socket that starts the stream after scan start.
|
||||||
|
void send_filler();
|
||||||
|
/// Waits up to `timeout_ms` for scan bytes and reads what is there (at most `cap`). Returns 0 on
|
||||||
|
/// a timeout. Throws `Io` when the board closed the socket.
|
||||||
|
std::size_t read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Fd cmd_;
|
||||||
|
Fd scan_;
|
||||||
|
std::string host_;
|
||||||
|
int port_ = kDefaultPort;
|
||||||
|
int timeout_ms_ = 2000;
|
||||||
|
std::uint8_t next_id_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope::e1608
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/e1608/e1608_panels.hpp"
|
||||||
|
|
||||||
|
#include "source/e1608/e1608_source.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace e1608;
|
||||||
|
|
||||||
|
constexpr gint64 kRefreshUs = 100000; // the panels redraw their readouts at 10 Hz
|
||||||
|
|
||||||
|
/// A signal closure's argument: the panel's context and the index (DAC or bit) it concerns.
|
||||||
|
struct Hook {
|
||||||
|
void* ctx = nullptr;
|
||||||
|
int idx = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
GtkWidget* spin(const std::string& name, const char* title, double lo, double hi, double step,
|
||||||
|
double def, guint digits) {
|
||||||
|
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
|
||||||
|
gtk_widget_set_name(r, name.c_str());
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_spin_row_set_digits(ADW_SPIN_ROW(r), digits);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* switch_row(const std::string& name, const char* title, const char* subtitle) {
|
||||||
|
GtkWidget* r = adw_switch_row_new();
|
||||||
|
gtk_widget_set_name(r, name.c_str());
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
if (subtitle != nullptr) adw_action_row_set_subtitle(ADW_ACTION_ROW(r), subtitle);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool due(gint64& last_us) {
|
||||||
|
const gint64 now = g_get_monotonic_time();
|
||||||
|
if (now - last_us < kRefreshUs) return false;
|
||||||
|
last_us = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_text(GtkWidget* label, const char* text) {
|
||||||
|
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr, GtkWidget* c = nullptr) {
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18);
|
||||||
|
gtk_widget_set_margin_top(box, 12);
|
||||||
|
gtk_widget_set_margin_bottom(box, 12);
|
||||||
|
gtk_widget_set_margin_start(box, 12);
|
||||||
|
gtk_widget_set_margin_end(box, 12);
|
||||||
|
gtk_box_append(GTK_BOX(box), a);
|
||||||
|
if (b != nullptr) gtk_box_append(GTK_BOX(box), b);
|
||||||
|
if (c != nullptr) gtk_box_append(GTK_BOX(box), c);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Analog out -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct AoWidgets {
|
||||||
|
GtkWidget *mode = nullptr, *level = nullptr, *amp = nullptr, *offset = nullptr, *freq = nullptr,
|
||||||
|
*run = nullptr, *last = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AoCtx {
|
||||||
|
E1608Source* src = nullptr;
|
||||||
|
std::shared_ptr<E1608Readouts> ro;
|
||||||
|
AoWidgets w[2];
|
||||||
|
GtkWidget* gen_rate = nullptr; ///< the "Update rate" row: the achieved rate against the requested one
|
||||||
|
Hook hooks[2];
|
||||||
|
bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing
|
||||||
|
unsigned epoch_seen = 0;
|
||||||
|
gint64 last_us = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void ao_sensitivity(const AoWidgets& w) {
|
||||||
|
const bool level = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode)) == 0;
|
||||||
|
gtk_widget_set_sensitive(w.level, level);
|
||||||
|
for (GtkWidget* x : {w.amp, w.offset, w.freq, w.run}) gtk_widget_set_sensitive(x, !level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back to what the source holds after a (re)connect.
|
||||||
|
void ao_reset(AoCtx* c) {
|
||||||
|
c->updating = true;
|
||||||
|
for (int ch = 0; ch < 2; ++ch) {
|
||||||
|
const AoWidgets& w = c->w[ch];
|
||||||
|
adw_combo_row_set_selected(ADW_COMBO_ROW(w.mode), 0);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.level), c->src->settings().ao_init[ch]);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.amp), 1.0);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.offset), 0.0);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.freq), 1.0);
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
|
||||||
|
ao_sensitivity(w);
|
||||||
|
}
|
||||||
|
c->updating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ao_push(AoCtx* c, int ch) {
|
||||||
|
if (c->updating) return;
|
||||||
|
const AoWidgets& w = c->w[ch];
|
||||||
|
const guint mode = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode));
|
||||||
|
if (mode == 0 && adw_switch_row_get_active(ADW_SWITCH_ROW(w.run))) { // Level has no generator
|
||||||
|
c->updating = true;
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
|
||||||
|
c->updating = false;
|
||||||
|
}
|
||||||
|
ao_sensitivity(w);
|
||||||
|
AoSettings s;
|
||||||
|
s.mode = static_cast<AoMode>(mode);
|
||||||
|
s.level = adw_spin_row_get_value(ADW_SPIN_ROW(w.level));
|
||||||
|
s.amplitude = adw_spin_row_get_value(ADW_SPIN_ROW(w.amp));
|
||||||
|
s.offset = adw_spin_row_get_value(ADW_SPIN_ROW(w.offset));
|
||||||
|
s.freq_hz = adw_spin_row_get_value(ADW_SPIN_ROW(w.freq));
|
||||||
|
s.run = adw_switch_row_get_active(ADW_SWITCH_ROW(w.run));
|
||||||
|
E1608Source* src = c->src;
|
||||||
|
src->post([src, ch, s] { src->set_analog_out(ch, s); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_ao_changed(GObject*, GParamSpec*, gpointer user) {
|
||||||
|
const Hook* h = static_cast<const Hook*>(user);
|
||||||
|
ao_push(static_cast<AoCtx*>(h->ctx), h->idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ao_refresh(AoCtx* c) {
|
||||||
|
const unsigned ep = c->ro->epoch.load();
|
||||||
|
if (ep != c->epoch_seen) {
|
||||||
|
c->epoch_seen = ep;
|
||||||
|
ao_reset(c);
|
||||||
|
}
|
||||||
|
for (int ch = 0; ch < 2; ++ch) {
|
||||||
|
const double v = c->ro->ao[ch].load();
|
||||||
|
char b[48];
|
||||||
|
if (std::isnan(v))
|
||||||
|
std::snprintf(b, sizeof b, "not written");
|
||||||
|
else
|
||||||
|
std::snprintf(b, sizeof b, "%+.4f V", v);
|
||||||
|
const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->w[ch].last));
|
||||||
|
if (cur == nullptr || std::strcmp(cur, b) != 0)
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->w[ch].last), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The achieved generator rate: ticks completed per second, measured by the producer thread. Below
|
||||||
|
// the requested rate, the board's command latency is what limits it.
|
||||||
|
char r[64];
|
||||||
|
const double req = c->src->settings().gen_rate;
|
||||||
|
const bool running = c->ro->gen_on[0].load() || c->ro->gen_on[1].load();
|
||||||
|
const double hz = c->ro->gen_hz.load();
|
||||||
|
if (!running)
|
||||||
|
std::snprintf(r, sizeof r, "off (requested %.0f Hz)", req);
|
||||||
|
else if (hz <= 0.0)
|
||||||
|
std::snprintf(r, sizeof r, "measuring... (requested %.0f Hz)", req);
|
||||||
|
else
|
||||||
|
std::snprintf(r, sizeof r, "%.0f Hz (requested %.0f Hz)", hz, req);
|
||||||
|
const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->gen_rate));
|
||||||
|
if (cur == nullptr || std::strcmp(cur, r) != 0)
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), r);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean ao_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
|
||||||
|
AoCtx* c = static_cast<AoCtx*>(user);
|
||||||
|
if (due(c->last_us)) ao_refresh(c);
|
||||||
|
return G_SOURCE_CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* build_ao(E1608Source* src) {
|
||||||
|
auto* c = new AoCtx;
|
||||||
|
c->src = src;
|
||||||
|
c->ro = src->readouts();
|
||||||
|
|
||||||
|
GtkWidget* groups[2];
|
||||||
|
static const char* const kModes[] = {"Level", "Sine", "Square", "Triangle", "Ramp", nullptr};
|
||||||
|
for (int ch = 0; ch < 2; ++ch) {
|
||||||
|
const std::string p = "e1608-ao" + std::to_string(ch);
|
||||||
|
AoWidgets& w = c->w[ch];
|
||||||
|
w.mode = adw_combo_row_new();
|
||||||
|
gtk_widget_set_name(w.mode, (p + "-mode").c_str());
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.mode), "Mode");
|
||||||
|
adw_combo_row_set_model(ADW_COMBO_ROW(w.mode), G_LIST_MODEL(gtk_string_list_new(kModes)));
|
||||||
|
w.level = spin(p + "-level", "Level (V)", -10, 10, 0.1, src->settings().ao_init[ch], 3);
|
||||||
|
w.amp = spin(p + "-amp", "Amplitude (V)", 0, 10, 0.1, 1.0, 3);
|
||||||
|
w.offset = spin(p + "-offset", "Offset (V)", -10, 10, 0.1, 0.0, 3);
|
||||||
|
w.freq = spin(p + "-freq", "Frequency (Hz)", 0.1, 10, 0.1, 1.0, 2);
|
||||||
|
w.run = switch_row(p + "-run", "Run", nullptr);
|
||||||
|
w.last = adw_action_row_new();
|
||||||
|
gtk_widget_set_name(w.last, (p + "-last").c_str());
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.last), "Last written");
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(w.last), "not written");
|
||||||
|
|
||||||
|
GtkWidget* g = adw_preferences_group_new();
|
||||||
|
const std::string title = "DAC " + std::to_string(ch);
|
||||||
|
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(g), title.c_str());
|
||||||
|
for (GtkWidget* r : {w.mode, w.level, w.amp, w.offset, w.freq, w.run, w.last})
|
||||||
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(g), r);
|
||||||
|
groups[ch] = g;
|
||||||
|
|
||||||
|
c->hooks[ch] = {c, ch};
|
||||||
|
g_signal_connect_data(w.mode, "notify::selected", G_CALLBACK(on_ao_changed), &c->hooks[ch],
|
||||||
|
nullptr, static_cast<GConnectFlags>(0));
|
||||||
|
for (GtkWidget* x : {w.level, w.amp, w.offset, w.freq})
|
||||||
|
g_signal_connect_data(x, "notify::value", G_CALLBACK(on_ao_changed), &c->hooks[ch], nullptr,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
g_signal_connect_data(w.run, "notify::active", G_CALLBACK(on_ao_changed), &c->hooks[ch],
|
||||||
|
nullptr, static_cast<GConnectFlags>(0));
|
||||||
|
ao_sensitivity(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
c->gen_rate = adw_action_row_new();
|
||||||
|
gtk_widget_set_name(c->gen_rate, "e1608-gen-rate");
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(c->gen_rate), "Update rate");
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), "off");
|
||||||
|
GtkWidget* gen_group = adw_preferences_group_new();
|
||||||
|
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(gen_group), "Generator");
|
||||||
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(gen_group), c->gen_rate);
|
||||||
|
|
||||||
|
GtkWidget* root = panel_root(groups[0], groups[1], gen_group);
|
||||||
|
g_object_set_data_full(G_OBJECT(root), "e1608-ao-ctx", c,
|
||||||
|
+[](gpointer p) { delete static_cast<AoCtx*>(p); });
|
||||||
|
gtk_widget_add_tick_callback(root, ao_tick, c, nullptr);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Digital I/O ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct DioCtx {
|
||||||
|
E1608Source* src = nullptr;
|
||||||
|
std::shared_ptr<E1608Readouts> ro;
|
||||||
|
GtkWidget* led[8] = {}; ///< input bits
|
||||||
|
GtkWidget* out[8] = {}; ///< output bits (switch rows)
|
||||||
|
GtkWidget* counter = nullptr;
|
||||||
|
Hook hooks[8];
|
||||||
|
bool updating = false;
|
||||||
|
unsigned epoch_seen = 0;
|
||||||
|
gint64 last_us = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void set_led(GtkWidget* l, bool on) {
|
||||||
|
if (gtk_widget_has_css_class(l, "success") == on) return;
|
||||||
|
if (on) {
|
||||||
|
gtk_widget_remove_css_class(l, "dim-label");
|
||||||
|
gtk_widget_add_css_class(l, "success");
|
||||||
|
} else {
|
||||||
|
gtk_widget_remove_css_class(l, "success");
|
||||||
|
gtk_widget_add_css_class(l, "dim-label");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dio_refresh(DioCtx* c) {
|
||||||
|
const unsigned ep = c->ro->epoch.load();
|
||||||
|
if (ep != c->epoch_seen) { // a (re)connect re-latched the outputs from the form
|
||||||
|
c->epoch_seen = ep;
|
||||||
|
const unsigned latch = c->ro->do_latch.load();
|
||||||
|
c->updating = true;
|
||||||
|
for (int b = 0; b < 8; ++b) {
|
||||||
|
if (c->out[b] != nullptr)
|
||||||
|
adw_switch_row_set_active(ADW_SWITCH_ROW(c->out[b]), ((latch >> b) & 1u) != 0);
|
||||||
|
}
|
||||||
|
c->updating = false;
|
||||||
|
}
|
||||||
|
const unsigned di = c->ro->di.load();
|
||||||
|
for (int b = 0; b < 8; ++b) {
|
||||||
|
if (c->led[b] != nullptr) set_led(c->led[b], ((di >> b) & 1u) != 0);
|
||||||
|
}
|
||||||
|
char buf[24];
|
||||||
|
std::snprintf(buf, sizeof buf, "%u", static_cast<unsigned>(c->ro->counter.load()));
|
||||||
|
set_text(c->counter, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean dio_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
|
||||||
|
DioCtx* c = static_cast<DioCtx*>(user);
|
||||||
|
if (due(c->last_us)) dio_refresh(c);
|
||||||
|
return G_SOURCE_CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_do_changed(GObject* obj, GParamSpec*, gpointer user) {
|
||||||
|
const Hook* h = static_cast<const Hook*>(user);
|
||||||
|
DioCtx* c = static_cast<DioCtx*>(h->ctx);
|
||||||
|
if (c->updating) return;
|
||||||
|
const bool on = adw_switch_row_get_active(ADW_SWITCH_ROW(obj));
|
||||||
|
E1608Source* src = c->src;
|
||||||
|
const int bit = h->idx;
|
||||||
|
src->post([src, bit, on] { src->set_output_bit(bit, on); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_counter_reset(GtkButton*, gpointer user) {
|
||||||
|
DioCtx* c = static_cast<DioCtx*>(user);
|
||||||
|
E1608Source* src = c->src;
|
||||||
|
src->post([src] { src->reset_counter(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* build_dio(E1608Source* src) {
|
||||||
|
auto* c = new DioCtx;
|
||||||
|
c->src = src;
|
||||||
|
c->ro = src->readouts();
|
||||||
|
|
||||||
|
GtkWidget* lines = adw_preferences_group_new();
|
||||||
|
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(lines), "Digital lines");
|
||||||
|
for (int b = 0; b < 8; ++b) {
|
||||||
|
const std::string title = "Bit " + std::to_string(b);
|
||||||
|
if (src->settings().dir_out[b]) {
|
||||||
|
GtkWidget* row = switch_row("e1608-do" + std::to_string(b), title.c_str(), "Output");
|
||||||
|
c->out[b] = row;
|
||||||
|
c->hooks[b] = {c, b};
|
||||||
|
g_signal_connect_data(row, "notify::active", G_CALLBACK(on_do_changed), &c->hooks[b], nullptr,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
|
||||||
|
} else {
|
||||||
|
GtkWidget* row = adw_action_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), title.c_str());
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), "Input");
|
||||||
|
GtkWidget* led = gtk_label_new("\u25cf");
|
||||||
|
gtk_widget_set_name(led, ("e1608-di" + std::to_string(b)).c_str());
|
||||||
|
gtk_widget_add_css_class(led, "dim-label");
|
||||||
|
adw_action_row_add_suffix(ADW_ACTION_ROW(row), led);
|
||||||
|
c->led[b] = led;
|
||||||
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* counter_group = adw_preferences_group_new();
|
||||||
|
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(counter_group), "Counter");
|
||||||
|
GtkWidget* crow = adw_action_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(crow), "Counts");
|
||||||
|
c->counter = gtk_label_new("0");
|
||||||
|
gtk_widget_set_name(c->counter, "e1608-counter");
|
||||||
|
gtk_widget_add_css_class(c->counter, "numeric");
|
||||||
|
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), c->counter);
|
||||||
|
GtkWidget* reset = gtk_button_new_with_label("Reset");
|
||||||
|
gtk_widget_set_name(reset, "e1608-counter-reset");
|
||||||
|
gtk_widget_set_valign(reset, GTK_ALIGN_CENTER);
|
||||||
|
g_signal_connect_data(reset, "clicked", G_CALLBACK(on_counter_reset), c, nullptr,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), reset);
|
||||||
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(counter_group), crow);
|
||||||
|
|
||||||
|
GtkWidget* root = panel_root(lines, counter_group);
|
||||||
|
g_object_set_data_full(G_OBJECT(root), "e1608-dio-ctx", c,
|
||||||
|
+[](gpointer p) { delete static_cast<DioCtx*>(p); });
|
||||||
|
gtk_widget_add_tick_callback(root, dio_tick, c, nullptr);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<SourcePanel> make_e1608_panels(DataSource& source) {
|
||||||
|
E1608Source* src = static_cast<E1608Source*>(&source);
|
||||||
|
SourcePanel ao;
|
||||||
|
ao.id = "ao";
|
||||||
|
ao.title = "Analog out";
|
||||||
|
ao.widget = build_ao(src);
|
||||||
|
SourcePanel dio;
|
||||||
|
dio.id = "dio";
|
||||||
|
dio.title = "Digital I/O";
|
||||||
|
dio.widget = build_dio(src);
|
||||||
|
return {ao, dio};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/source_kind.hpp"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// The E-1608's two capability panels, "Analog out" and "Digital I/O". `source` must be an
|
||||||
|
/// `e1608::E1608Source`. The widgets reach it only through `source.post()`.
|
||||||
|
std::vector<SourcePanel> make_e1608_panels(DataSource& source);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/e1608/e1608_source.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace udpscope::e1608 {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string lower(std::string s) {
|
||||||
|
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim(const std::string& s) {
|
||||||
|
std::size_t a = 0, b = s.size();
|
||||||
|
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||||
|
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||||
|
return s.substr(a, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
double clamp(double v, double lo, double hi) { return std::min(hi, std::max(lo, v)); }
|
||||||
|
|
||||||
|
double wave(AoMode m, double f) {
|
||||||
|
switch (m) {
|
||||||
|
case AoMode::Sine: return std::sin(2.0 * M_PI * f);
|
||||||
|
case AoMode::Square: return f < 0.5 ? 1.0 : -1.0;
|
||||||
|
case AoMode::Triangle: return f < 0.5 ? 4.0 * f - 1.0 : 3.0 - 4.0 * f;
|
||||||
|
case AoMode::Ramp: return 2.0 * f - 1.0;
|
||||||
|
case AoMode::Level: break;
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double epoch_seconds() {
|
||||||
|
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// --- E1608Settings --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
E1608Settings E1608Settings::from(const SourceConfig& c) {
|
||||||
|
E1608Settings s;
|
||||||
|
s.address = trim(c.get("address"));
|
||||||
|
s.mac_text = trim(c.get("mac"));
|
||||||
|
s.subnet = trim(c.get("subnet"));
|
||||||
|
s.port = static_cast<int>(c.get_int("port", kDefaultPort));
|
||||||
|
const long code = c.get_int("code", 0);
|
||||||
|
s.code = static_cast<std::uint32_t>(std::min(4294967295L, std::max(0L, code)));
|
||||||
|
s.timeout_ms = static_cast<int>(c.get_int("timeout_ms", 2000));
|
||||||
|
s.scan_rate = c.get_double("scan_rate", 1000.0);
|
||||||
|
s.ext_clock = c.get_bool("ext_clock", false);
|
||||||
|
parse_trigger(c.get("trigger", "none"), s.trigger);
|
||||||
|
s.auto_restart = c.get_bool("auto_restart", true);
|
||||||
|
s.calibrate = c.get_bool("calibrate", true);
|
||||||
|
s.poll_rate = c.get_double("poll_rate", 10.0);
|
||||||
|
s.gen_rate = c.get_double("gen_rate", 50.0);
|
||||||
|
s.diff = lower(c.get("mode", "se")) == "diff";
|
||||||
|
|
||||||
|
bool any_key = false;
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
const std::string key = "ai" + std::to_string(i);
|
||||||
|
if (!c.has(key)) continue;
|
||||||
|
any_key = true;
|
||||||
|
Range r;
|
||||||
|
if (parse_range(c.get(key), r)) {
|
||||||
|
s.ai_on[i] = true;
|
||||||
|
s.ai_range[i] = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any_key) { // a bare `--source-opt address=...` is connectable
|
||||||
|
s.ai_on[0] = true;
|
||||||
|
s.ai_range[0] = Range::Bip10V;
|
||||||
|
}
|
||||||
|
if (s.diff) {
|
||||||
|
for (int i = 4; i < 8; ++i) s.ai_on[i] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
const std::string d = lower(c.get("dir" + std::to_string(i), "i"));
|
||||||
|
s.dir_out[i] = d == "o" || d == "out" || d == "output";
|
||||||
|
s.plot[i] = c.get_bool("plot" + std::to_string(i), false) && !s.dir_out[i];
|
||||||
|
}
|
||||||
|
s.plot_counter = c.get_bool("plot_counter", false);
|
||||||
|
s.do_init = static_cast<int>(c.get_int("do_init", 0) & 0xFF);
|
||||||
|
for (int i = 0; i < 2; ++i)
|
||||||
|
s.ao_init[i] = clamp(c.get_double("ao" + std::to_string(i) + "_init", 0.0), -10.0, 10.0);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
int E1608Settings::active_channels() const {
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < 8; ++i) n += ai_on[i] ? 1 : 0;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<QueueEntry> E1608Settings::queue() const {
|
||||||
|
std::vector<QueueEntry> q;
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
if (ai_on[i]) q.push_back({static_cast<std::uint8_t>(diff ? i + 8 : i), ai_range[i]});
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string E1608Settings::identity() const {
|
||||||
|
if (!address.empty()) return address;
|
||||||
|
Mac m;
|
||||||
|
return parse_mac(mac_text, m) ? format_mac(m) : mac_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string E1608Settings::problem() const {
|
||||||
|
Mac m;
|
||||||
|
if (address.empty() && mac_text.empty()) return "Enter an address, or a MAC address to look up";
|
||||||
|
if (!mac_text.empty() && !parse_mac(mac_text, m))
|
||||||
|
return "The MAC address must be six hex bytes, such as 00:80:2f:00:16:08";
|
||||||
|
if (address.empty() && !subnet.empty() && sweep_targets(subnet).empty())
|
||||||
|
return "The subnet must look like 192.168.1";
|
||||||
|
if (port < 1 || port > 65534) return "The port must be between 1 and 65534";
|
||||||
|
if (timeout_ms < 100 || timeout_ms > 60000) return "The timeout must be 100 to 60000 ms";
|
||||||
|
if (!(scan_rate > 0.0)) return "The scan rate must be positive";
|
||||||
|
if (active_channels() < 1) return "Enable at least one analog input";
|
||||||
|
if (aggregate() > kMaxAggregateHz) {
|
||||||
|
char b[96];
|
||||||
|
std::snprintf(b, sizeof b, "%d channels at %.6g S/s is %.6g kS/s; the board takes 250 kS/s",
|
||||||
|
active_channels(), scan_rate, aggregate() / 1000.0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
if (aggregate() < kMinAggregateHz) return "The aggregate rate must be at least 0.02 S/s";
|
||||||
|
if (!(poll_rate >= 0.1 && poll_rate <= 100.0)) return "The input poll rate must be 0.1 to 100 Hz";
|
||||||
|
if (!(gen_rate >= 1.0 && gen_rate <= 200.0)) return "The generator update rate must be 1 to 200 Hz";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- E1608Source ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
E1608Source::E1608Source(const SourceConfig& cfg) : cfg_(E1608Settings::from(cfg)) {
|
||||||
|
queue_ = cfg_.queue();
|
||||||
|
block_ = static_cast<std::size_t>(clamp(std::round(cfg_.scan_rate / 100.0), 1.0, 4096.0));
|
||||||
|
rx_.resize(65536);
|
||||||
|
ready_.resize(queue_.size());
|
||||||
|
|
||||||
|
auto base = [&](const std::string& name, const char* unit, double lo, double hi) {
|
||||||
|
SignalInfo s;
|
||||||
|
s.name = name;
|
||||||
|
s.unit = unit;
|
||||||
|
s.type_code = 9; // float64
|
||||||
|
s.rows = 1;
|
||||||
|
s.cols = 1;
|
||||||
|
s.range_min = lo;
|
||||||
|
s.range_max = hi;
|
||||||
|
s.time_mode = kTimePacket;
|
||||||
|
s.sampling_rate = cfg_.scan_rate;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
if (!cfg_.ai_on[i]) continue;
|
||||||
|
const double fs = full_scale(cfg_.ai_range[i]);
|
||||||
|
signals_.push_back(base("AI" + std::to_string(i), "V", -fs, fs));
|
||||||
|
}
|
||||||
|
for (int b = 0; b < 8; ++b) {
|
||||||
|
if (!cfg_.plot[b]) continue;
|
||||||
|
plotted_bits_.push_back(b);
|
||||||
|
signals_.push_back(base("DI" + std::to_string(b), "", 0.0, 1.0));
|
||||||
|
}
|
||||||
|
if (cfg_.plot_counter) signals_.push_back(base("Counter", "counts", 0.0, 1000.0));
|
||||||
|
held_di_.assign(plotted_bits_.size(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string E1608Source::last_message() const {
|
||||||
|
std::lock_guard<std::mutex> lk(msg_mu_);
|
||||||
|
return last_message_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::open(SourceSink& sink) {
|
||||||
|
const std::string p = cfg_.problem();
|
||||||
|
if (!p.empty()) throw std::runtime_error(p);
|
||||||
|
sink_ = &sink;
|
||||||
|
state_ = State::Down;
|
||||||
|
retry_at_ = Clock::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::close() {
|
||||||
|
if (state_ == State::Scanning) dev_.scan_stop();
|
||||||
|
scan_started_ = false;
|
||||||
|
link_.close();
|
||||||
|
connected_.store(false, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceStats E1608Source::stats() const {
|
||||||
|
SourceStats s;
|
||||||
|
s.packets = packets_;
|
||||||
|
s.frames = frames_;
|
||||||
|
s.reconnects = reconnects_;
|
||||||
|
s.connected = connected_.load(std::memory_order_relaxed);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::note(const std::string& msg) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(msg_mu_);
|
||||||
|
last_message_ = msg;
|
||||||
|
}
|
||||||
|
if (msg != last_logged_) { // a retry every 2 s must not flood stderr
|
||||||
|
last_logged_ = msg;
|
||||||
|
std::fprintf(stderr, "[e1608] %s\n", msg.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::fail(const std::string& msg, bool fatal) {
|
||||||
|
if (scan_started_) {
|
||||||
|
// The scan may have started before a later connect step failed: tell the board to stop, so it
|
||||||
|
// is not left scanning with nobody reading. Best effort; it must never throw out of here.
|
||||||
|
try {
|
||||||
|
dev_.scan_stop();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
scan_started_ = false;
|
||||||
|
}
|
||||||
|
link_.close();
|
||||||
|
const bool was_up = connected_.exchange(false, std::memory_order_relaxed);
|
||||||
|
note(msg);
|
||||||
|
if (was_up) sink_->on_event(SourceEvent::Disconnected, msg.c_str());
|
||||||
|
sink_->on_event(SourceEvent::Error, msg.c_str());
|
||||||
|
any_gen_ = false;
|
||||||
|
readouts_->gen_on[0].store(false);
|
||||||
|
readouts_->gen_on[1].store(false);
|
||||||
|
gen_rate_reset();
|
||||||
|
if (fatal) {
|
||||||
|
state_ = State::Halted;
|
||||||
|
} else {
|
||||||
|
state_ = State::Down;
|
||||||
|
retry_at_ = Clock::now() + std::chrono::seconds(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::poll(int timeout_ms) {
|
||||||
|
if (sink_ == nullptr) return;
|
||||||
|
try {
|
||||||
|
drain_commands();
|
||||||
|
switch (state_) {
|
||||||
|
case State::Down: {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
if (now < retry_at_) {
|
||||||
|
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(retry_at_ - now);
|
||||||
|
std::this_thread::sleep_for(std::min(left, std::chrono::milliseconds(timeout_ms)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect_and_start();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case State::Scanning:
|
||||||
|
scan_step(timeout_ms);
|
||||||
|
break;
|
||||||
|
case State::Halted:
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (const LinkError& e) {
|
||||||
|
fail(e.what(), e.kind() == LinkError::Kind::Rejected);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
fail(e.what(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::connect_and_start() {
|
||||||
|
std::string host = cfg_.address;
|
||||||
|
Mac want{};
|
||||||
|
const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want);
|
||||||
|
if (host.empty() || have_mac) {
|
||||||
|
const int wait_ms = std::min(cfg_.timeout_ms, 1500);
|
||||||
|
// No address and no subnet: the limited broadcast, sent on every interface rather than only
|
||||||
|
// the default route's.
|
||||||
|
const bool broadcast = host.empty() && cfg_.subnet.empty();
|
||||||
|
const std::vector<DeviceInfo> found =
|
||||||
|
broadcast ? discover_broadcast(cfg_.port, wait_ms)
|
||||||
|
: discover(host.empty() ? sweep_targets(cfg_.subnet)
|
||||||
|
: std::vector<std::string>{host},
|
||||||
|
cfg_.port, wait_ms);
|
||||||
|
if (host.empty()) {
|
||||||
|
for (const DeviceInfo& d : found) {
|
||||||
|
if (d.mac == want) host = d.ip;
|
||||||
|
}
|
||||||
|
if (host.empty())
|
||||||
|
throw LinkError(LinkError::Kind::Io,
|
||||||
|
"no E-1608 with MAC " + format_mac(want) + " answered");
|
||||||
|
} else {
|
||||||
|
if (found.empty()) throw LinkError(LinkError::Kind::Io, "no E-1608 answered at " + host);
|
||||||
|
if (found[0].mac != want)
|
||||||
|
throw LinkError(LinkError::Kind::Protocol, "the board at " + host + " has MAC " +
|
||||||
|
format_mac(found[0].mac) + ", not " +
|
||||||
|
format_mac(want));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
link_.connect(host, cfg_.port, cfg_.code, cfg_.timeout_ms);
|
||||||
|
scan_started_ = false;
|
||||||
|
cal_ = Calibration{};
|
||||||
|
if (cfg_.calibrate && !dev_.read_calibration(cal_))
|
||||||
|
note("calibration memory refused; using nominal values");
|
||||||
|
|
||||||
|
std::uint8_t inputs = 0;
|
||||||
|
for (int b = 0; b < 8; ++b) {
|
||||||
|
if (!cfg_.dir_out[b]) inputs = static_cast<std::uint8_t>(inputs | (1u << b));
|
||||||
|
}
|
||||||
|
dev_.dconf(inputs);
|
||||||
|
do_latch_ = static_cast<std::uint8_t>(cfg_.do_init);
|
||||||
|
dev_.dout(do_latch_);
|
||||||
|
readouts_->do_latch.store(do_latch_);
|
||||||
|
for (int ch = 0; ch < 2; ++ch) {
|
||||||
|
ao_[ch] = AoSettings{};
|
||||||
|
ao_[ch].level = cfg_.ao_init[ch];
|
||||||
|
write_ao(ch, cfg_.ao_init[ch]);
|
||||||
|
}
|
||||||
|
any_gen_ = false;
|
||||||
|
update_gen_flags();
|
||||||
|
|
||||||
|
start_scan();
|
||||||
|
|
||||||
|
pending_.clear();
|
||||||
|
for (auto& r : ready_) r.clear();
|
||||||
|
pending_lost_ = 0;
|
||||||
|
const auto now = Clock::now();
|
||||||
|
next_slow_ = now;
|
||||||
|
next_status_ = now + std::chrono::milliseconds(500);
|
||||||
|
next_gen_ = now + gen_period();
|
||||||
|
slow_read();
|
||||||
|
|
||||||
|
state_ = State::Scanning;
|
||||||
|
connected_.store(true, std::memory_order_relaxed);
|
||||||
|
if (connects_.fetch_add(1) > 0) ++reconnects_;
|
||||||
|
readouts_->epoch.fetch_add(1);
|
||||||
|
last_logged_.clear();
|
||||||
|
sink_->on_config(signals_, 1);
|
||||||
|
sink_->on_event(SourceEvent::Connected, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
int E1608Source::wait_ms(int timeout_ms, Clock::time_point now) const {
|
||||||
|
Clock::time_point next = std::min(next_slow_, next_status_);
|
||||||
|
if (any_gen_) next = std::min(next, next_gen_);
|
||||||
|
const long long d = std::chrono::duration_cast<std::chrono::milliseconds>(next - now).count();
|
||||||
|
return static_cast<int>(std::max(0LL, std::min<long long>(d, timeout_ms)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::scan_step(int timeout_ms) {
|
||||||
|
const std::size_t n = link_.read_scan(rx_.data(), rx_.size(), wait_ms(timeout_ms, Clock::now()));
|
||||||
|
if (n > 0) {
|
||||||
|
++packets_;
|
||||||
|
pending_.insert(pending_.end(), rx_.begin(), rx_.begin() + static_cast<std::ptrdiff_t>(n));
|
||||||
|
const std::size_t nq = queue_.size();
|
||||||
|
const std::size_t scans = pending_.size() / (2 * nq);
|
||||||
|
if (scans > 0) {
|
||||||
|
std::vector<std::uint16_t> raw(scans * nq);
|
||||||
|
for (std::size_t i = 0; i < raw.size(); ++i)
|
||||||
|
raw[i] = static_cast<std::uint16_t>(pending_[2 * i] | (pending_[2 * i + 1] << 8));
|
||||||
|
pending_.erase(pending_.begin(),
|
||||||
|
pending_.begin() + static_cast<std::ptrdiff_t>(scans * nq * 2));
|
||||||
|
decode_scans(raw.data(), scans, queue_, cal_, dec_);
|
||||||
|
for (std::size_t q = 0; q < nq; ++q)
|
||||||
|
ready_[q].insert(ready_[q].end(), dec_[q].begin(), dec_[q].end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (!ready_.empty() && ready_[0].size() >= block_) emit_frame(block_);
|
||||||
|
run_timers();
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::emit_frame(std::size_t count) {
|
||||||
|
const std::size_t nq = queue_.size();
|
||||||
|
const std::size_t total = signals_.size();
|
||||||
|
vals_.resize(total);
|
||||||
|
views_.resize(total);
|
||||||
|
for (std::size_t q = 0; q < nq; ++q) {
|
||||||
|
vals_[q].assign(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
|
||||||
|
ready_[q].erase(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
|
||||||
|
}
|
||||||
|
for (std::size_t k = 0; k < plotted_bits_.size(); ++k) vals_[nq + k].assign(count, held_di_[k]);
|
||||||
|
if (cfg_.plot_counter) vals_[total - 1].assign(count, held_counter_);
|
||||||
|
for (std::size_t i = 0; i < total; ++i)
|
||||||
|
views_[i] = SignalValues{vals_[i].data(), static_cast<std::uint32_t>(count)};
|
||||||
|
|
||||||
|
Frame f;
|
||||||
|
f.counter = frame_counter_++;
|
||||||
|
f.lost = pending_lost_;
|
||||||
|
pending_lost_ = 0;
|
||||||
|
f.late = false;
|
||||||
|
f.recv_time = epoch_seconds();
|
||||||
|
f.publish_mode = 1;
|
||||||
|
f.num_samples = static_cast<std::uint32_t>(count);
|
||||||
|
f.num_signals = static_cast<std::uint32_t>(total);
|
||||||
|
f.signals = signals_.data();
|
||||||
|
f.values = views_.data();
|
||||||
|
++frames_;
|
||||||
|
sink_->on_data(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::run_timers() {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
if (now >= next_slow_) {
|
||||||
|
next_slow_ = now + std::chrono::duration_cast<Clock::duration>(
|
||||||
|
std::chrono::duration<double>(1.0 / cfg_.poll_rate));
|
||||||
|
slow_read();
|
||||||
|
}
|
||||||
|
if (now >= next_status_) {
|
||||||
|
next_status_ = now + std::chrono::milliseconds(500);
|
||||||
|
check_status();
|
||||||
|
}
|
||||||
|
if (any_gen_ && now >= next_gen_) {
|
||||||
|
next_gen_ = now + gen_period(); // from now, not from the old deadline: a slow board gets no catch-up burst
|
||||||
|
generator_tick(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::slow_read() {
|
||||||
|
const std::uint8_t d = dev_.din();
|
||||||
|
const std::uint32_t c = dev_.counter();
|
||||||
|
readouts_->di.store(d);
|
||||||
|
readouts_->counter.store(c);
|
||||||
|
for (std::size_t k = 0; k < plotted_bits_.size(); ++k)
|
||||||
|
held_di_[k] = (d >> plotted_bits_[k]) & 1 ? 1.0 : 0.0;
|
||||||
|
held_counter_ = static_cast<double>(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::check_status() {
|
||||||
|
const std::uint16_t st = dev_.status();
|
||||||
|
if (st & 4) {
|
||||||
|
if (!cfg_.auto_restart) {
|
||||||
|
fail("scan overrun; the scan is stopped (auto-restart is off)", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
note("scan overrun; restarting");
|
||||||
|
sink_->on_event(SourceEvent::Error, "scan overrun; restarting");
|
||||||
|
restart_scan();
|
||||||
|
pending_lost_ = 1;
|
||||||
|
last_logged_.clear(); // a later overrun on this connection is news again, not a retry echo
|
||||||
|
} else if (!(st & 2) && cfg_.auto_restart && cfg_.trigger == Trigger::None) {
|
||||||
|
// With a hardware trigger the board may report "not running" while it waits for the edge;
|
||||||
|
// restarting then would reset the wait forever, so only the overrun bit restarts the scan.
|
||||||
|
restart_scan();
|
||||||
|
pending_lost_ = 1; // the samples between the stop and the restart are gone either way
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::restart_scan() {
|
||||||
|
dev_.scan_stop();
|
||||||
|
scan_started_ = false;
|
||||||
|
pending_.clear();
|
||||||
|
for (auto& r : ready_) r.clear();
|
||||||
|
start_scan();
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::start_scan() {
|
||||||
|
// The device layer does not validate the rate; the board's cap is enforced here.
|
||||||
|
if (cfg_.aggregate() > kMaxAggregateHz)
|
||||||
|
throw LinkError(LinkError::Kind::Command, "the aggregate rate exceeds 250 kS/s");
|
||||||
|
try {
|
||||||
|
dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger);
|
||||||
|
scan_started_ = true;
|
||||||
|
} catch (...) {
|
||||||
|
// The board may have accepted the start (0x11) before a later step failed (the filler byte):
|
||||||
|
// tell it to stop, so it is not left scanning with nobody reading.
|
||||||
|
try {
|
||||||
|
dev_.scan_stop();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
scan_started_ = false;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::write_ao(int ch, double volts) {
|
||||||
|
readouts_->ao[ch].store(dev_.aout(ch, volts, cal_));
|
||||||
|
}
|
||||||
|
|
||||||
|
E1608Source::Clock::duration E1608Source::gen_period() const {
|
||||||
|
return std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(1.0 / cfg_.gen_rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::gen_rate_reset() {
|
||||||
|
gen_win_ticks_ = 0;
|
||||||
|
gen_win_t0_ = Clock::now();
|
||||||
|
readouts_->gen_hz.store(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::update_gen_flags() {
|
||||||
|
const bool was_gen = any_gen_;
|
||||||
|
any_gen_ = false;
|
||||||
|
for (int ch = 0; ch < 2; ++ch) {
|
||||||
|
const bool on = ao_[ch].mode != AoMode::Level && ao_[ch].run;
|
||||||
|
readouts_->gen_on[ch].store(on);
|
||||||
|
any_gen_ = any_gen_ || on;
|
||||||
|
}
|
||||||
|
if (any_gen_ != was_gen) gen_rate_reset(); // a generator started or the last one stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::generator_tick(Clock::time_point now) {
|
||||||
|
bool wrote = false;
|
||||||
|
for (int ch = 0; ch < 2; ++ch) {
|
||||||
|
const AoSettings& s = ao_[ch];
|
||||||
|
if (s.mode == AoMode::Level || !s.run) continue;
|
||||||
|
const double t = std::chrono::duration<double>(now - gen_t0_[ch]).count();
|
||||||
|
const double phase = std::fmod(t * s.freq_hz, 1.0);
|
||||||
|
write_ao(ch, clamp(s.offset + s.amplitude * wave(s.mode, phase), -10.0, 10.0));
|
||||||
|
wrote = true;
|
||||||
|
}
|
||||||
|
if (!wrote) return;
|
||||||
|
// The achieved rate: ticks that wrote, per second of wall time, published once a window closes.
|
||||||
|
// The DAC writes are synchronous, so a slow board shows up here as a rate below the requested one.
|
||||||
|
++gen_win_ticks_;
|
||||||
|
const double elapsed = std::chrono::duration<double>(Clock::now() - gen_win_t0_).count();
|
||||||
|
if (elapsed >= 1.0) {
|
||||||
|
readouts_->gen_hz.store(gen_win_ticks_ / elapsed);
|
||||||
|
gen_win_ticks_ = 0;
|
||||||
|
gen_win_t0_ = Clock::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::set_output_bit(int bit, bool on) {
|
||||||
|
if (state_ != State::Scanning || bit < 0 || bit > 7 || !cfg_.dir_out[bit]) return;
|
||||||
|
do_latch_ = static_cast<std::uint8_t>(on ? (do_latch_ | (1u << bit)) : (do_latch_ & ~(1u << bit)));
|
||||||
|
dev_.dout(do_latch_);
|
||||||
|
readouts_->do_latch.store(do_latch_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::reset_counter() {
|
||||||
|
if (state_ != State::Scanning) return;
|
||||||
|
dev_.counter_reset();
|
||||||
|
held_counter_ = 0.0;
|
||||||
|
readouts_->counter.store(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void E1608Source::set_analog_out(int ch, const AoSettings& s) {
|
||||||
|
if (state_ != State::Scanning || ch < 0 || ch > 1) return;
|
||||||
|
ao_[ch] = s;
|
||||||
|
ao_[ch].freq_hz = clamp(s.freq_hz, 0.1, 10.0);
|
||||||
|
ao_[ch].amplitude = clamp(s.amplitude, 0.0, 10.0);
|
||||||
|
ao_[ch].offset = clamp(s.offset, -10.0, 10.0);
|
||||||
|
if (s.mode == AoMode::Level) {
|
||||||
|
ao_[ch].run = false;
|
||||||
|
write_ao(ch, clamp(s.level, -10.0, 10.0));
|
||||||
|
} else if (s.run) {
|
||||||
|
gen_t0_[ch] = Clock::now();
|
||||||
|
}
|
||||||
|
update_gen_flags();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope::e1608
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/data_source.hpp"
|
||||||
|
#include "source/e1608/e1608_device.hpp"
|
||||||
|
#include "source/e1608/e1608_link.hpp"
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope::e1608 {
|
||||||
|
|
||||||
|
constexpr double kMaxAggregateHz = 250000.0;
|
||||||
|
constexpr double kMinAggregateHz = 0.02;
|
||||||
|
|
||||||
|
/// Everything a configured E-1608 source needs, parsed once from the flat `SourceConfig`.
|
||||||
|
struct E1608Settings {
|
||||||
|
std::string address; ///< host or IP; empty means "find it by MAC"
|
||||||
|
std::string mac_text; ///< as typed; empty means "any board at `address`"
|
||||||
|
std::string subnet; ///< `a.b.c` to sweep when looking up a MAC; empty means broadcast
|
||||||
|
int port = kDefaultPort;
|
||||||
|
std::uint32_t code = 0;
|
||||||
|
int timeout_ms = 2000;
|
||||||
|
double scan_rate = 1000.0; ///< per channel; with an external clock, that clock's rate
|
||||||
|
bool ext_clock = false;
|
||||||
|
Trigger trigger = Trigger::None;
|
||||||
|
bool auto_restart = true;
|
||||||
|
bool calibrate = true;
|
||||||
|
double poll_rate = 10.0; ///< Hz, for the digital inputs and the counter
|
||||||
|
double gen_rate = 50.0; ///< Hz, the waveform generator's DAC update rate
|
||||||
|
bool diff = false;
|
||||||
|
bool ai_on[8] = {};
|
||||||
|
Range ai_range[8] = {};
|
||||||
|
bool dir_out[8] = {};
|
||||||
|
bool plot[8] = {};
|
||||||
|
bool plot_counter = false;
|
||||||
|
int do_init = 0;
|
||||||
|
double ao_init[2] = {0.0, 0.0};
|
||||||
|
|
||||||
|
static E1608Settings from(const SourceConfig& cfg);
|
||||||
|
/// The first reason this cannot connect, in words for the user; empty when it can.
|
||||||
|
std::string problem() const;
|
||||||
|
int active_channels() const;
|
||||||
|
std::vector<QueueEntry> queue() const;
|
||||||
|
double aggregate() const { return scan_rate * active_channels(); }
|
||||||
|
/// The address, else the MAC; names the session file.
|
||||||
|
std::string identity() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What the panels read. Written by the producer thread, read by the GTK thread's timer.
|
||||||
|
struct E1608Readouts {
|
||||||
|
std::atomic<unsigned> di{0}; ///< the DIN byte as last read
|
||||||
|
std::atomic<unsigned> do_latch{0}; ///< the DOUT byte as last written
|
||||||
|
std::atomic<std::uint32_t> counter{0};
|
||||||
|
std::atomic<double> ao[2]; ///< the last DAC voltage written; NaN before any
|
||||||
|
std::atomic<bool> gen_on[2];
|
||||||
|
std::atomic<double> gen_hz{0.0}; ///< generator ticks completed per second, over ~1 s; 0 when off
|
||||||
|
std::atomic<unsigned> epoch{0}; ///< bumped at each (re)connect: panels re-sync from it
|
||||||
|
E1608Readouts() {
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
ao[i].store(std::nan(""));
|
||||||
|
gen_on[i].store(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class AoMode { Level, Sine, Square, Triangle, Ramp };
|
||||||
|
|
||||||
|
struct AoSettings {
|
||||||
|
AoMode mode = AoMode::Level;
|
||||||
|
double level = 0.0; ///< Level mode, volts
|
||||||
|
double amplitude = 1.0; ///< waveform modes, volts
|
||||||
|
double offset = 0.0;
|
||||||
|
double freq_hz = 1.0; ///< clamped to 0.1 .. 10
|
||||||
|
bool run = false; ///< waveform modes only
|
||||||
|
};
|
||||||
|
|
||||||
|
class E1608Source final : public DataSource {
|
||||||
|
public:
|
||||||
|
explicit E1608Source(const SourceConfig& cfg);
|
||||||
|
|
||||||
|
void open(SourceSink& sink) override;
|
||||||
|
void poll(int timeout_ms) override;
|
||||||
|
void close() override;
|
||||||
|
SourceStats stats() const override;
|
||||||
|
bool connected() const override { return connected_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
// Producer thread only: reach these through `post()`. Each is ignored unless scanning.
|
||||||
|
void set_output_bit(int bit, bool on);
|
||||||
|
void reset_counter();
|
||||||
|
void set_analog_out(int ch, const AoSettings& s);
|
||||||
|
|
||||||
|
std::shared_ptr<E1608Readouts> readouts() const { return readouts_; }
|
||||||
|
const E1608Settings& settings() const { return cfg_; }
|
||||||
|
unsigned connects() const { return connects_.load(std::memory_order_relaxed); }
|
||||||
|
std::string last_message() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
enum class State { Down, Scanning, Halted };
|
||||||
|
|
||||||
|
void connect_and_start();
|
||||||
|
void scan_step(int timeout_ms);
|
||||||
|
void emit_frame(std::size_t count);
|
||||||
|
void run_timers();
|
||||||
|
void slow_read();
|
||||||
|
void check_status();
|
||||||
|
void restart_scan();
|
||||||
|
void start_scan();
|
||||||
|
void generator_tick(Clock::time_point now);
|
||||||
|
void write_ao(int ch, double volts);
|
||||||
|
void update_gen_flags();
|
||||||
|
void gen_rate_reset();
|
||||||
|
Clock::duration gen_period() const;
|
||||||
|
void fail(const std::string& msg, bool fatal);
|
||||||
|
void note(const std::string& msg);
|
||||||
|
int wait_ms(int timeout_ms, Clock::time_point now) const;
|
||||||
|
|
||||||
|
E1608Settings cfg_;
|
||||||
|
SourceSink* sink_ = nullptr;
|
||||||
|
State state_ = State::Down;
|
||||||
|
bool scan_started_ = false; ///< a scan start succeeded and no stop has followed
|
||||||
|
Link link_;
|
||||||
|
Device dev_{link_};
|
||||||
|
Calibration cal_;
|
||||||
|
std::vector<QueueEntry> queue_;
|
||||||
|
std::vector<SignalInfo> signals_;
|
||||||
|
std::vector<int> plotted_bits_; ///< the DI bits that are plot channels, ascending
|
||||||
|
std::size_t block_ = 10;
|
||||||
|
|
||||||
|
std::shared_ptr<E1608Readouts> readouts_ = std::make_shared<E1608Readouts>();
|
||||||
|
std::atomic<bool> connected_{false};
|
||||||
|
std::atomic<unsigned> connects_{0};
|
||||||
|
mutable std::mutex msg_mu_;
|
||||||
|
std::string last_message_;
|
||||||
|
std::string last_logged_;
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> rx_; ///< the read buffer
|
||||||
|
std::vector<std::uint8_t> pending_; ///< bytes of an incomplete scan
|
||||||
|
std::vector<std::vector<double>> ready_; ///< decoded, not yet framed: [queue index][scan]
|
||||||
|
std::vector<std::vector<double>> dec_;
|
||||||
|
std::vector<std::vector<double>> vals_; ///< the frame being built: [signal][sample]
|
||||||
|
std::vector<SignalValues> views_;
|
||||||
|
std::vector<double> held_di_;
|
||||||
|
double held_counter_ = 0.0;
|
||||||
|
std::uint8_t do_latch_ = 0;
|
||||||
|
|
||||||
|
AoSettings ao_[2];
|
||||||
|
Clock::time_point gen_t0_[2];
|
||||||
|
bool any_gen_ = false;
|
||||||
|
Clock::time_point gen_win_t0_; ///< start of the current rate-measurement window
|
||||||
|
unsigned gen_win_ticks_ = 0; ///< ticks that wrote since `gen_win_t0_`
|
||||||
|
|
||||||
|
Clock::time_point retry_at_;
|
||||||
|
Clock::time_point next_slow_, next_status_, next_gen_;
|
||||||
|
std::uint32_t frame_counter_ = 0;
|
||||||
|
std::uint32_t pending_lost_ = 0;
|
||||||
|
std::uint64_t packets_ = 0, frames_ = 0, reconnects_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope::e1608
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/flatudp/flatudp_source.hpp"
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace flatudp;
|
||||||
|
using ChangeCb = std::shared_ptr<std::function<void()>>;
|
||||||
|
|
||||||
|
std::string num(double v) {
|
||||||
|
char b[48];
|
||||||
|
std::snprintf(b, sizeof b, "%.10g", v);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim(const std::string& s) {
|
||||||
|
std::size_t a = 0, b = s.size();
|
||||||
|
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||||
|
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||||
|
return s.substr(a, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* entry_row(const char* title) {
|
||||||
|
GtkWidget* r = adw_entry_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
|
||||||
|
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* combo_row(const char* title, const char* const* items) {
|
||||||
|
GtkWidget* r = adw_combo_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
|
||||||
|
GtkWidget* e = adw_expander_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
|
||||||
|
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
|
||||||
|
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
|
||||||
|
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
|
||||||
|
|
||||||
|
void mark(GtkWidget* w, bool bad) {
|
||||||
|
if (bad) {
|
||||||
|
gtk_widget_add_css_class(w, "error");
|
||||||
|
} else {
|
||||||
|
gtk_widget_remove_css_class(w, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One signal's editing widgets; `row` is the `AdwEntryRow` that holds the others. `vec` ("Vector as":
|
||||||
|
/// Channels or Waveform) is shown only while `count` is above 1.
|
||||||
|
struct SigRow {
|
||||||
|
GtkWidget *row, *type, *count, *vec;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. The raw
|
||||||
|
/// widget pointers are valid while the chooser keeps the rows.
|
||||||
|
struct Form : std::enable_shared_from_this<Form> {
|
||||||
|
GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *group;
|
||||||
|
std::vector<SigRow> sigs;
|
||||||
|
ChangeCb changed = std::make_shared<std::function<void()>>();
|
||||||
|
bool updating = false;
|
||||||
|
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
|
||||||
|
|
||||||
|
std::vector<Row> rows_now() const {
|
||||||
|
std::vector<Row> rows;
|
||||||
|
for (const SigRow& s : sigs) {
|
||||||
|
Row r;
|
||||||
|
r.name = trim(text_of(s.row));
|
||||||
|
r.type = static_cast<Type>(std::min<guint>(gtk_drop_down_get_selected(GTK_DROP_DOWN(s.type)), 9));
|
||||||
|
r.count = static_cast<std::uint32_t>(gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count)));
|
||||||
|
r.wave = r.count > 1 && gtk_drop_down_get_selected(GTK_DROP_DOWN(s.vec)) == 1;
|
||||||
|
rows.push_back(r);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first `S<n>` that no row uses.
|
||||||
|
std::string next_name() const {
|
||||||
|
const std::vector<Row> rows = rows_now();
|
||||||
|
for (int n = 1;; ++n) {
|
||||||
|
const std::string name = "S" + std::to_string(n);
|
||||||
|
bool used = false;
|
||||||
|
for (const Row& r : rows) used = used || r.name == name;
|
||||||
|
if (!used) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bad addresses are marked with the `error` style; the group's subtitle carries the signal list's
|
||||||
|
/// own state, the first rule it breaks or the size it implies.
|
||||||
|
void refresh() {
|
||||||
|
mark(bind, !text_of(bind).empty() && !valid_ipv4(text_of(bind)));
|
||||||
|
mark(multicast, !text_of(multicast).empty() && !valid_multicast(text_of(multicast)));
|
||||||
|
mark(iface, !text_of(iface).empty() && !valid_ipv4(text_of(iface)));
|
||||||
|
// "Vector as" belongs to a vector row only; a row back at one element returns to Channels.
|
||||||
|
for (const SigRow& s : sigs) {
|
||||||
|
const bool vec = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count)) > 1;
|
||||||
|
gtk_widget_set_visible(s.vec, vec);
|
||||||
|
if (!vec && gtk_drop_down_get_selected(GTK_DROP_DOWN(s.vec)) != 0)
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(s.vec), 0);
|
||||||
|
}
|
||||||
|
const std::vector<Row> rows = rows_now();
|
||||||
|
const std::string p = flatudp::problem(rows);
|
||||||
|
std::string sub = p;
|
||||||
|
if (p.empty()) {
|
||||||
|
sub = std::to_string(channel_count(rows)) + (channel_count(rows) == 1 ? " channel, " : " channels, ") +
|
||||||
|
std::to_string(datagram_size(rows)) + " bytes per datagram";
|
||||||
|
}
|
||||||
|
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(group), sub.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void fire() {
|
||||||
|
if (!updating && changed && *changed) (*changed)();
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceConfig read() const {
|
||||||
|
SourceConfig c;
|
||||||
|
c.set("port", num(value_of(port)));
|
||||||
|
if (!text_of(bind).empty()) c.set("bind", text_of(bind));
|
||||||
|
if (!text_of(multicast).empty()) c.set("multicast", text_of(multicast));
|
||||||
|
if (!text_of(iface).empty()) c.set("iface", text_of(iface));
|
||||||
|
c.set("rate", num(value_of(rate)));
|
||||||
|
c.set("endian", sel_of(endian) == 1 ? "big" : "little");
|
||||||
|
c.set("signals", format(rows_now()));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
void add_row(const Row& r);
|
||||||
|
void remove_row(GtkWidget* row);
|
||||||
|
|
||||||
|
void write(const SourceConfig& cfg) {
|
||||||
|
const FlatUdpSettings s = FlatUdpSettings::from(cfg);
|
||||||
|
updating = true;
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(bind), s.bind.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(multicast), s.multicast.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(iface), s.iface.c_str());
|
||||||
|
if (s.rate > 0.0) adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.rate);
|
||||||
|
adw_combo_row_set_selected(ADW_COMBO_ROW(endian), s.big_endian ? 1 : 0);
|
||||||
|
while (!sigs.empty()) {
|
||||||
|
GtkWidget* row = sigs.back().row;
|
||||||
|
sigs.pop_back();
|
||||||
|
adw_expander_row_remove(ADW_EXPANDER_ROW(group), row);
|
||||||
|
}
|
||||||
|
for (const Row& r : s.rows) add_row(r);
|
||||||
|
updating = false;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid() const { return FlatUdpSettings::from(read()).problem().empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
using FormPtr = std::shared_ptr<Form>;
|
||||||
|
|
||||||
|
/// A remove button's context: which form, which row.
|
||||||
|
struct RowCtx {
|
||||||
|
FormPtr form;
|
||||||
|
GtkWidget* row;
|
||||||
|
};
|
||||||
|
|
||||||
|
void on_notify(GObject*, GParamSpec*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
if (!f.alive) return;
|
||||||
|
f.refresh();
|
||||||
|
f.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_edited(GtkEditable*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
if (!f.alive) return;
|
||||||
|
f.refresh();
|
||||||
|
f.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_spin(GtkSpinButton*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
if (!f.alive) return;
|
||||||
|
f.refresh();
|
||||||
|
f.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_add(GtkButton*, gpointer user) {
|
||||||
|
FormPtr f = *static_cast<FormPtr*>(user);
|
||||||
|
if (!f->alive) return;
|
||||||
|
f->add_row(Row{f->next_name(), Type::F32, 1});
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE);
|
||||||
|
f->refresh();
|
||||||
|
f->fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_remove(GtkButton*, gpointer user) {
|
||||||
|
// Copy first: removing the row finalizes this button, and with it this context.
|
||||||
|
RowCtx* c = static_cast<RowCtx*>(user);
|
||||||
|
FormPtr f = c->form;
|
||||||
|
GtkWidget* row = c->row;
|
||||||
|
if (!f->alive) return;
|
||||||
|
f->remove_row(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
|
||||||
|
void free_ctx(gpointer d, GClosure*) { delete static_cast<RowCtx*>(d); }
|
||||||
|
|
||||||
|
/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone.
|
||||||
|
void on_form_destroy(GtkWidget*, gpointer user) { (**static_cast<FormPtr*>(user)).alive = false; }
|
||||||
|
|
||||||
|
void hook_destroy(GtkWidget* w, const FormPtr& f) {
|
||||||
|
g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) {
|
||||||
|
g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void hook_entry(GtkWidget* w, const FormPtr& f) {
|
||||||
|
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Form::add_row(const Row& r) {
|
||||||
|
FormPtr self = shared_from_this();
|
||||||
|
GtkWidget* row = adw_entry_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), "Name");
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(row), r.name.c_str());
|
||||||
|
|
||||||
|
const char* names[11];
|
||||||
|
for (std::size_t i = 0; i < 10; ++i) names[i] = type_name(static_cast<Type>(i));
|
||||||
|
names[10] = nullptr;
|
||||||
|
GtkWidget* type = gtk_drop_down_new_from_strings(names);
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(type), static_cast<guint>(r.type));
|
||||||
|
gtk_widget_set_valign(type, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_name(type, "flatudp-type");
|
||||||
|
|
||||||
|
// The spin clamps to 1..4096, so a hand-edited session with a count outside it is repaired here.
|
||||||
|
GtkWidget* count = gtk_spin_button_new_with_range(1, kMaxCount, 1);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(count), r.count);
|
||||||
|
gtk_widget_set_valign(count, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_tooltip_text(count, "Elements: 1 is a scalar signal, more make a vector signal");
|
||||||
|
gtk_widget_set_name(count, "flatudp-count");
|
||||||
|
|
||||||
|
static const char* const kVec[] = {"Channels", "Waveform", nullptr};
|
||||||
|
GtkWidget* vec = gtk_drop_down_new_from_strings(kVec);
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(vec), r.wave && r.count > 1 ? 1 : 0);
|
||||||
|
gtk_widget_set_valign(vec, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_visible(vec, r.count > 1);
|
||||||
|
gtk_widget_set_tooltip_text(vec,
|
||||||
|
"Vector as: Channels makes one channel per element, one sample per cycle; "
|
||||||
|
"Waveform makes one channel of all the elements per cycle");
|
||||||
|
gtk_widget_set_name(vec, "flatudp-vector");
|
||||||
|
|
||||||
|
GtkWidget* rm = gtk_button_new_with_label("Remove");
|
||||||
|
gtk_widget_set_valign(rm, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_name(rm, "flatudp-remove");
|
||||||
|
|
||||||
|
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), type);
|
||||||
|
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), count);
|
||||||
|
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), vec);
|
||||||
|
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), rm);
|
||||||
|
|
||||||
|
hook_entry(row, self);
|
||||||
|
hook_notify(type, "notify::selected", self);
|
||||||
|
hook_notify(vec, "notify::selected", self);
|
||||||
|
g_signal_connect_data(count, "value-changed", G_CALLBACK(on_spin), new FormPtr(self), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
g_signal_connect_data(rm, "clicked", G_CALLBACK(on_remove), new RowCtx{self, row}, free_ctx,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
|
||||||
|
adw_expander_row_add_row(ADW_EXPANDER_ROW(group), row);
|
||||||
|
sigs.push_back(SigRow{row, type, count, vec});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Form::remove_row(GtkWidget* row) {
|
||||||
|
for (auto it = sigs.begin(); it != sigs.end(); ++it) {
|
||||||
|
if (it->row == row) {
|
||||||
|
sigs.erase(it);
|
||||||
|
adw_expander_row_remove(ADW_EXPANDER_ROW(group), row);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlatUdpKind final : public SourceKind {
|
||||||
|
public:
|
||||||
|
std::string id() const override { return "flatudp"; }
|
||||||
|
std::string display_name() const override { return "Flat UDP (MARTe2 UDPSender)"; }
|
||||||
|
bool has_datagrams() const override { return true; }
|
||||||
|
|
||||||
|
std::string identity(const SourceConfig& cfg) const override {
|
||||||
|
return FlatUdpSettings::from(cfg).identity();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
||||||
|
return std::make_unique<FlatUdpSource>(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigForm make_config_form() const override {
|
||||||
|
auto f = std::make_shared<Form>();
|
||||||
|
static const char* const kEndian[] = {"Little endian", "Big endian", nullptr};
|
||||||
|
|
||||||
|
f->port = spin_row("_Port", 1, 65535, 1, kDefaultPort, 0);
|
||||||
|
f->bind = entry_row("_Bind address");
|
||||||
|
f->multicast = entry_row("_Multicast group");
|
||||||
|
f->iface = entry_row("Multicast _interface");
|
||||||
|
f->rate = spin_row("Cycle _rate (Hz)", 0.001, 1000000, 1, 1000, 3);
|
||||||
|
f->endian = combo_row("_Byte order", kEndian);
|
||||||
|
|
||||||
|
GtkWidget* net = expander("Network", {f->port, f->bind, f->multicast, f->iface});
|
||||||
|
GtkWidget* timing = expander("Timing and format", {f->rate, f->endian});
|
||||||
|
f->group = adw_expander_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->group), "Signals");
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE);
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE);
|
||||||
|
|
||||||
|
GtkWidget* add = gtk_button_new_with_label("Add signal");
|
||||||
|
gtk_widget_set_valign(add, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_name(add, "flatudp-add");
|
||||||
|
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->group), add);
|
||||||
|
g_signal_connect_data(add, "clicked", G_CALLBACK(on_add), new FormPtr(f), free_form,
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
|
||||||
|
for (GtkWidget* w : {net, timing, f->group}) hook_destroy(w, f);
|
||||||
|
for (GtkWidget* w : {f->bind, f->multicast, f->iface}) hook_entry(w, f);
|
||||||
|
for (GtkWidget* w : {f->port, f->rate}) hook_notify(w, "notify::value", f);
|
||||||
|
hook_notify(f->endian, "notify::selected", f);
|
||||||
|
f->refresh();
|
||||||
|
|
||||||
|
ConfigForm out;
|
||||||
|
out.rows = {net, timing, f->group};
|
||||||
|
out.description =
|
||||||
|
"The signal list must match the sender's exactly, in order: the datagrams carry no "
|
||||||
|
"description, and a datagram of any other size is dropped. A vector signal is either "
|
||||||
|
"one channel per element or, as a waveform, one channel of all its elements";
|
||||||
|
out.focus = f->rate;
|
||||||
|
out.write = [f](const SourceConfig& c) { f->write(c); };
|
||||||
|
out.read = [f]() { return f->read(); };
|
||||||
|
out.valid = [f]() { return f->valid(); };
|
||||||
|
ChangeCb cb = f->changed;
|
||||||
|
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<SourceKind> make_flatudp_kind() { return std::make_unique<FlatUdpKind>(); }
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/flatudp/flatudp_signals.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <bit>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstring>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
namespace udpscope::flatudp {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* kNames[] = {"uint8", "int8", "uint16", "int16", "uint32",
|
||||||
|
"int32", "uint64", "int64", "float32", "float64"};
|
||||||
|
constexpr std::size_t kSizes[] = {1, 1, 2, 2, 4, 4, 8, 8, 4, 8};
|
||||||
|
|
||||||
|
std::string trim(const std::string& s) {
|
||||||
|
std::size_t a = 0, b = s.size();
|
||||||
|
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||||
|
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||||
|
return s.substr(a, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name must survive `format` and `parse`: none of the separators, no edge whitespace.
|
||||||
|
bool valid_name(const std::string& n) {
|
||||||
|
if (n.empty()) return false;
|
||||||
|
if (std::isspace(static_cast<unsigned char>(n.front())) ||
|
||||||
|
std::isspace(static_cast<unsigned char>(n.back())))
|
||||||
|
return false;
|
||||||
|
for (char c : n) {
|
||||||
|
if (c == ',' || c == ':' || c == '[' || c == ']' || static_cast<unsigned char>(c) < 0x20)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <class T>
|
||||||
|
double load(const std::uint8_t* p, bool swap) {
|
||||||
|
unsigned char b[sizeof(T)];
|
||||||
|
std::memcpy(b, p, sizeof(T));
|
||||||
|
if (swap) std::reverse(b, b + sizeof(T));
|
||||||
|
T v;
|
||||||
|
std::memcpy(&v, b, sizeof(T));
|
||||||
|
return static_cast<double>(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
double load_typed(Type t, const std::uint8_t* p, bool swap) {
|
||||||
|
switch (t) {
|
||||||
|
case Type::U8: return load<std::uint8_t>(p, swap);
|
||||||
|
case Type::I8: return load<std::int8_t>(p, swap);
|
||||||
|
case Type::U16: return load<std::uint16_t>(p, swap);
|
||||||
|
case Type::I16: return load<std::int16_t>(p, swap);
|
||||||
|
case Type::U32: return load<std::uint32_t>(p, swap);
|
||||||
|
case Type::I32: return load<std::int32_t>(p, swap);
|
||||||
|
case Type::U64: return load<std::uint64_t>(p, swap);
|
||||||
|
case Type::I64: return load<std::int64_t>(p, swap);
|
||||||
|
case Type::F32: return load<float>(p, swap);
|
||||||
|
case Type::F64: return load<double>(p, swap);
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::size_t type_size(Type t) { return kSizes[static_cast<std::size_t>(t)]; }
|
||||||
|
const char* type_name(Type t) { return kNames[static_cast<std::size_t>(t)]; }
|
||||||
|
|
||||||
|
bool parse_type(const std::string& s, Type& out) {
|
||||||
|
for (std::size_t i = 0; i < 10; ++i) {
|
||||||
|
if (s == kNames[i]) {
|
||||||
|
out = static_cast<Type>(i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t datagram_size(const std::vector<Row>& rows) {
|
||||||
|
std::size_t n = 0;
|
||||||
|
for (const Row& r : rows) n += type_size(r.type) * static_cast<std::size_t>(r.count);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool all_scalar(const std::vector<Row>& rows) {
|
||||||
|
return std::all_of(rows.begin(), rows.end(), [](const Row& r) { return r.count == 1; });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool any_wave(const std::vector<Row>& rows) {
|
||||||
|
return std::any_of(rows.begin(), rows.end(), [](const Row& r) { return r.wave; });
|
||||||
|
}
|
||||||
|
|
||||||
|
int channel_count(const std::vector<Row>& rows) {
|
||||||
|
long n = 0;
|
||||||
|
for (const Row& r : rows) n += r.wave ? 1 : static_cast<long>(r.count);
|
||||||
|
return static_cast<int>(std::min<long>(n, 1L << 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format(const std::vector<Row>& rows) {
|
||||||
|
std::string out;
|
||||||
|
for (const Row& r : rows) {
|
||||||
|
if (!out.empty()) out += ',';
|
||||||
|
out += r.name + ':' + type_name(r.type);
|
||||||
|
if (r.count != 1 || r.wave) out += '[' + std::to_string(r.count) + ']';
|
||||||
|
if (r.wave) out += 'w';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string parse(const std::string& text, std::vector<Row>& rows) {
|
||||||
|
rows.clear();
|
||||||
|
if (trim(text).empty()) return {};
|
||||||
|
std::size_t pos = 0;
|
||||||
|
int index = 0;
|
||||||
|
for (;;) {
|
||||||
|
std::size_t end = text.find(',', pos);
|
||||||
|
if (end == std::string::npos) end = text.size();
|
||||||
|
++index;
|
||||||
|
const std::string where = "signal " + std::to_string(index) + ": ";
|
||||||
|
const std::string tok = trim(text.substr(pos, end - pos));
|
||||||
|
const std::size_t colon = tok.find(':');
|
||||||
|
if (colon == std::string::npos) {
|
||||||
|
rows.clear();
|
||||||
|
return where + "expected name:type";
|
||||||
|
}
|
||||||
|
Row r;
|
||||||
|
r.name = trim(tok.substr(0, colon));
|
||||||
|
std::string ty = trim(tok.substr(colon + 1));
|
||||||
|
const std::size_t br = ty.find('[');
|
||||||
|
if (br != std::string::npos) {
|
||||||
|
const std::size_t cl = ty.rfind(']');
|
||||||
|
if (cl == std::string::npos || cl < br) {
|
||||||
|
rows.clear();
|
||||||
|
return where + "missing ']' after the count";
|
||||||
|
}
|
||||||
|
const std::string tail = trim(ty.substr(cl + 1));
|
||||||
|
if (tail == "w") {
|
||||||
|
r.wave = true;
|
||||||
|
} else if (!tail.empty()) {
|
||||||
|
rows.clear();
|
||||||
|
return where + "unexpected '" + tail + "' after the count (only w, for a waveform)";
|
||||||
|
}
|
||||||
|
const std::string n = trim(ty.substr(br + 1, cl - br - 1));
|
||||||
|
const bool digits = !n.empty() && n.size() <= 6 &&
|
||||||
|
std::all_of(n.begin(), n.end(), [](char c) { return c >= '0' && c <= '9'; });
|
||||||
|
if (!digits) {
|
||||||
|
rows.clear();
|
||||||
|
return where + "the count must be a whole number";
|
||||||
|
}
|
||||||
|
r.count = static_cast<std::uint32_t>(std::stoul(n));
|
||||||
|
ty = trim(ty.substr(0, br));
|
||||||
|
}
|
||||||
|
if (!parse_type(ty, r.type)) {
|
||||||
|
rows.clear();
|
||||||
|
return where + "unknown type '" + ty + "'";
|
||||||
|
}
|
||||||
|
rows.push_back(r);
|
||||||
|
if (end >= text.size()) break;
|
||||||
|
pos = end + 1;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string problem(const std::vector<Row>& rows) {
|
||||||
|
if (rows.empty()) return "add at least one signal";
|
||||||
|
std::set<std::string> seen;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
const Row& r = rows[i];
|
||||||
|
if (r.name.empty()) return "signal " + std::to_string(i + 1) + " has no name";
|
||||||
|
if (!valid_name(r.name))
|
||||||
|
return "'" + r.name + "': a name cannot hold , : [ ] or edge spaces";
|
||||||
|
if (!seen.insert(r.name).second) return "the name '" + r.name + "' is used twice";
|
||||||
|
if (r.count < 1 || r.count > kMaxCount)
|
||||||
|
return "'" + r.name + "': the count must be 1 to " + std::to_string(kMaxCount);
|
||||||
|
}
|
||||||
|
const std::size_t n = datagram_size(rows);
|
||||||
|
if (n > kMaxDatagram)
|
||||||
|
return std::to_string(n) + " bytes per datagram exceed the " + std::to_string(kMaxDatagram) +
|
||||||
|
" a UDP datagram can carry";
|
||||||
|
|
||||||
|
// A waveform is one channel of `count` samples per datagram; it cannot share a frame with a
|
||||||
|
// channel of another length, so a list of waveforms is all waveforms of one length.
|
||||||
|
const Row* first_wave = nullptr;
|
||||||
|
for (const Row& r : rows) {
|
||||||
|
if (!r.wave) continue;
|
||||||
|
if (r.count < 2) return "signal '" + r.name + "' is a waveform and needs a count above 1";
|
||||||
|
if (first_wave == nullptr) first_wave = &r;
|
||||||
|
}
|
||||||
|
if (first_wave != nullptr) {
|
||||||
|
for (const Row& r : rows) {
|
||||||
|
if (!r.wave)
|
||||||
|
return "signal '" + r.name + "' is not a waveform but '" + first_wave->name +
|
||||||
|
"' is; every row must be a waveform of the same length";
|
||||||
|
if (r.count != first_wave->count)
|
||||||
|
return "signal '" + r.name + "' is a waveform of " + std::to_string(r.count) +
|
||||||
|
" values but '" + first_wave->name + "' has " + std::to_string(first_wave->count) +
|
||||||
|
"; every row must be a waveform of the same length";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int channels = channel_count(rows);
|
||||||
|
if (channels > kMaxChannels)
|
||||||
|
return std::to_string(channels) + " channels exceed the " + std::to_string(kMaxChannels) +
|
||||||
|
" one list may make (a vector row makes one per element unless it is a waveform)";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void decode_datagram(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
|
||||||
|
std::vector<std::vector<double>>& cols) {
|
||||||
|
if (cols.size() < rows.size()) cols.resize(rows.size());
|
||||||
|
const bool swap = big_endian != (std::endian::native == std::endian::big);
|
||||||
|
std::size_t off = 0;
|
||||||
|
for (std::size_t s = 0; s < rows.size(); ++s) {
|
||||||
|
const std::size_t w = type_size(rows[s].type);
|
||||||
|
for (std::uint32_t k = 0; k < rows[s].count; ++k) {
|
||||||
|
cols[s].push_back(load_typed(rows[s].type, data + off, swap));
|
||||||
|
off += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void decode_channels(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
|
||||||
|
std::vector<std::vector<double>>& cols) {
|
||||||
|
const std::size_t channels = static_cast<std::size_t>(channel_count(rows));
|
||||||
|
if (cols.size() < channels) cols.resize(channels);
|
||||||
|
const bool swap = big_endian != (std::endian::native == std::endian::big);
|
||||||
|
std::size_t off = 0, ch = 0;
|
||||||
|
for (const Row& r : rows) {
|
||||||
|
const std::size_t w = type_size(r.type);
|
||||||
|
for (std::uint32_t k = 0; k < r.count; ++k) {
|
||||||
|
cols[r.wave ? ch : ch + k].push_back(load_typed(r.type, data + off, swap));
|
||||||
|
off += w;
|
||||||
|
}
|
||||||
|
ch += r.wave ? 1 : r.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope::flatudp
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope::flatudp {
|
||||||
|
|
||||||
|
/// The numeric value is the `SignalInfo::type_code` (the order of the client library's `UDPS_T_*`).
|
||||||
|
enum class Type : std::uint8_t { U8 = 0, I8, U16, I16, U32, I32, U64, I64, F32, F64 };
|
||||||
|
|
||||||
|
constexpr std::uint32_t kMaxCount = 4096;
|
||||||
|
constexpr std::size_t kMaxDatagram = 65507; ///< the largest UDP payload over IPv4
|
||||||
|
constexpr int kMaxChannels = 256; ///< the most channels one list may make
|
||||||
|
|
||||||
|
/// One signal of the sender's datagram: `count` consecutive values of `type`.
|
||||||
|
struct Row {
|
||||||
|
std::string name;
|
||||||
|
Type type = Type::F32;
|
||||||
|
std::uint32_t count = 1;
|
||||||
|
/// A vector row (count > 1) only: true is ONE channel of `count` consecutive samples per
|
||||||
|
/// datagram (a waveform); false is `count` channels `name[0]`.. of one sample each.
|
||||||
|
bool wave = false;
|
||||||
|
bool operator==(const Row&) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::size_t type_size(Type t);
|
||||||
|
const char* type_name(Type t); ///< "uint8" ... "float64"
|
||||||
|
bool parse_type(const std::string& s, Type& out);
|
||||||
|
|
||||||
|
/// The bytes one datagram must have: the sum of `type_size * count`.
|
||||||
|
std::size_t datagram_size(const std::vector<Row>& rows);
|
||||||
|
/// True when no row is a vector. (Batching depends on `any_wave`, not on this.)
|
||||||
|
bool all_scalar(const std::vector<Row>& rows);
|
||||||
|
|
||||||
|
/// True when some row is a waveform (`wave`).
|
||||||
|
bool any_wave(const std::vector<Row>& rows);
|
||||||
|
/// The channels the list makes: a scalar 1, a non-wave vector `count`, a waveform 1.
|
||||||
|
int channel_count(const std::vector<Row>& rows);
|
||||||
|
|
||||||
|
/// `name:type[,name:type[count][w]...]`; a count of 1 is written without brackets, and the `w`
|
||||||
|
/// of a waveform row follows its count.
|
||||||
|
std::string format(const std::vector<Row>& rows);
|
||||||
|
/// The inverse of `format`. Whitespace around names, types and counts is ignored. An empty text
|
||||||
|
/// is an empty list (and not an error: `problem` reports it). Returns an error in words, empty
|
||||||
|
/// on success; `rows` is empty after a failure.
|
||||||
|
std::string parse(const std::string& text, std::vector<Row>& rows);
|
||||||
|
|
||||||
|
/// The first reason this list cannot describe a datagram, in words for the user; empty when valid.
|
||||||
|
std::string problem(const std::vector<Row>& rows);
|
||||||
|
|
||||||
|
/// Decodes one datagram of `datagram_size(rows)` bytes to one column per *channel* (see
|
||||||
|
/// `channel_count`): a non-wave vector row feeds `count` columns of one value each, a waveform row
|
||||||
|
/// one column of `count` values. Appends; the caller clears the columns between uses.
|
||||||
|
void decode_channels(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
|
||||||
|
std::vector<std::vector<double>>& cols);
|
||||||
|
|
||||||
|
/// Decodes one datagram of `datagram_size(rows)` bytes to doubles, row by row, byte-swapping when
|
||||||
|
/// `big_endian` differs from the host. Appends to `cols[s]`; the caller clears them between uses.
|
||||||
|
void decode_datagram(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
|
||||||
|
std::vector<std::vector<double>>& cols);
|
||||||
|
|
||||||
|
} // namespace udpscope::flatudp
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/flatudp/flatudp_source.hpp"
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace udpscope::flatudp {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace std::chrono_literals;
|
||||||
|
|
||||||
|
std::string lower(std::string s) {
|
||||||
|
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim(const std::string& s) {
|
||||||
|
std::size_t a = 0, b = s.size();
|
||||||
|
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||||
|
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||||
|
return s.substr(a, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
double epoch_seconds() {
|
||||||
|
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr std::size_t kMaxReportedSizes = 8; ///< a sender of random sizes must not flood stderr
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool valid_ipv4(const std::string& s) {
|
||||||
|
in_addr a{};
|
||||||
|
return inet_pton(AF_INET, s.c_str(), &a) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid_multicast(const std::string& s) {
|
||||||
|
in_addr a{};
|
||||||
|
if (inet_pton(AF_INET, s.c_str(), &a) != 1) return false;
|
||||||
|
return (ntohl(a.s_addr) >> 28) == 0xE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FlatUdpSettings ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FlatUdpSettings FlatUdpSettings::from(const SourceConfig& c) {
|
||||||
|
FlatUdpSettings s;
|
||||||
|
s.port = static_cast<int>(c.get_int("port", kDefaultPort));
|
||||||
|
s.bind = trim(c.get("bind"));
|
||||||
|
s.multicast = trim(c.get("multicast"));
|
||||||
|
s.iface = trim(c.get("iface"));
|
||||||
|
s.rate = c.get_double("rate", 0.0);
|
||||||
|
const std::string e = lower(trim(c.get("endian", "little")));
|
||||||
|
s.endian_ok = e == "little" || e == "big";
|
||||||
|
s.big_endian = e == "big";
|
||||||
|
s.signals_error = parse(c.get("signals"), s.rows);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FlatUdpSettings::problem() const {
|
||||||
|
if (port < 1 || port > 65535) return "the port must be 1 to 65535";
|
||||||
|
if (!signals_error.empty()) return signals_error;
|
||||||
|
const std::string p = flatudp::problem(rows);
|
||||||
|
if (!p.empty()) return p;
|
||||||
|
if (!(rate > 0.0) || !std::isfinite(rate)) return "give the sender's cycle rate in Hz (rate)";
|
||||||
|
if (!endian_ok) return "endian must be little or big";
|
||||||
|
if (!bind.empty() && !valid_ipv4(bind)) return "the bind address '" + bind + "' is not an IPv4 address";
|
||||||
|
if (!multicast.empty() && !valid_multicast(multicast))
|
||||||
|
return "'" + multicast + "' is not a multicast group (224.0.0.0 to 239.255.255.255)";
|
||||||
|
if (!iface.empty() && !valid_ipv4(iface))
|
||||||
|
return "the interface address '" + iface + "' is not an IPv4 address";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FlatUdpSettings::identity() const {
|
||||||
|
return (multicast.empty() ? std::string() : multicast + ":") + std::to_string(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t FlatUdpSettings::block() const {
|
||||||
|
if (any_wave(rows) || !(rate > 0.0) || !std::isfinite(rate)) return 1;
|
||||||
|
const long b = std::lround(rate / 100.0);
|
||||||
|
return static_cast<std::size_t>(std::min(1024L, std::max(1L, b)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FlatUdpSource --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FlatUdpSource::FlatUdpSource(const SourceConfig& cfg) : cfg_(FlatUdpSettings::from(cfg)) {}
|
||||||
|
|
||||||
|
FlatUdpSource::~FlatUdpSource() { close_fd(); }
|
||||||
|
|
||||||
|
std::string FlatUdpSource::last_message() const {
|
||||||
|
std::lock_guard<std::mutex> lk(msg_mu_);
|
||||||
|
return last_message_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::note(const std::string& msg) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(msg_mu_);
|
||||||
|
last_message_ = msg;
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "[flatudp] %s\n", msg.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::close_fd() {
|
||||||
|
if (fd_ >= 0) {
|
||||||
|
::close(fd_);
|
||||||
|
fd_ = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::open(SourceSink& sink) {
|
||||||
|
const std::string p = cfg_.problem();
|
||||||
|
if (!p.empty()) throw std::runtime_error(p);
|
||||||
|
sink_ = &sink;
|
||||||
|
|
||||||
|
// A fresh session: Stop then Go live opens the same object again, and the model needs its CONFIG.
|
||||||
|
configured_ = false;
|
||||||
|
connected_.store(false, std::memory_order_relaxed);
|
||||||
|
ever_connected_ = false;
|
||||||
|
recv_error_noted_ = false;
|
||||||
|
reported_sizes_.clear();
|
||||||
|
frame_counter_ = 0;
|
||||||
|
frames_ = 0;
|
||||||
|
reconnects_ = 0;
|
||||||
|
valid_.store(0, std::memory_order_relaxed);
|
||||||
|
wrong_.store(0, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
// One SignalInfo per channel: a scalar, one element of a `channels` vector (`Name[k]`), or a whole
|
||||||
|
// waveform (rows = count consecutive samples at rate * count).
|
||||||
|
signals_.clear();
|
||||||
|
const auto add = [&](const std::string& name, const Row& r, std::uint32_t rows, double rate) {
|
||||||
|
SignalInfo s;
|
||||||
|
s.name = name;
|
||||||
|
s.type_code = static_cast<std::uint8_t>(r.type);
|
||||||
|
s.rows = rows;
|
||||||
|
s.cols = 1;
|
||||||
|
s.time_mode = kTimePacket;
|
||||||
|
s.sampling_rate = rate;
|
||||||
|
signals_.push_back(s);
|
||||||
|
};
|
||||||
|
for (const Row& r : cfg_.rows) {
|
||||||
|
if (r.wave) {
|
||||||
|
add(r.name, r, r.count, cfg_.rate * static_cast<double>(r.count));
|
||||||
|
} else if (r.count == 1) {
|
||||||
|
add(r.name, r, 1, cfg_.rate);
|
||||||
|
} else {
|
||||||
|
for (std::uint32_t k = 0; k < r.count; ++k)
|
||||||
|
add(r.name + "[" + std::to_string(k) + "]", r, 1, cfg_.rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expected_ = datagram_size(cfg_.rows);
|
||||||
|
block_ = cfg_.block();
|
||||||
|
pending_.assign(signals_.size(), {});
|
||||||
|
pending_n_ = 0;
|
||||||
|
rx_.assign(65536, 0);
|
||||||
|
|
||||||
|
const auto fail = [this](const std::string& what) {
|
||||||
|
const std::string msg = what + ": " + std::strerror(errno);
|
||||||
|
close_fd();
|
||||||
|
throw std::runtime_error(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
fd_ = ::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
|
||||||
|
if (fd_ < 0) fail("cannot create a UDP socket");
|
||||||
|
// Receivers of one multicast group share its port; a unicast port is the one app's.
|
||||||
|
if (!cfg_.multicast.empty()) {
|
||||||
|
const int one = 1;
|
||||||
|
::setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
||||||
|
}
|
||||||
|
const int rcvbuf = 4 << 20; // best effort: a 1 kHz sender at 64 KiB would not fit a default one
|
||||||
|
::setsockopt(fd_, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
|
||||||
|
|
||||||
|
sockaddr_in addr{};
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(static_cast<std::uint16_t>(cfg_.port));
|
||||||
|
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||||
|
if (!cfg_.bind.empty()) inet_pton(AF_INET, cfg_.bind.c_str(), &addr.sin_addr);
|
||||||
|
if (::bind(fd_, reinterpret_cast<const sockaddr*>(&addr), sizeof addr) < 0)
|
||||||
|
fail("cannot bind UDP port " + std::to_string(cfg_.port));
|
||||||
|
|
||||||
|
if (!cfg_.multicast.empty()) {
|
||||||
|
ip_mreq mreq{};
|
||||||
|
inet_pton(AF_INET, cfg_.multicast.c_str(), &mreq.imr_multiaddr);
|
||||||
|
if (cfg_.iface.empty()) {
|
||||||
|
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
|
||||||
|
} else {
|
||||||
|
inet_pton(AF_INET, cfg_.iface.c_str(), &mreq.imr_interface);
|
||||||
|
}
|
||||||
|
if (::setsockopt(fd_, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) < 0)
|
||||||
|
fail("cannot join multicast group " + cfg_.multicast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::close() {
|
||||||
|
close_fd();
|
||||||
|
connected_.store(false, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceStats FlatUdpSource::stats() const {
|
||||||
|
SourceStats s;
|
||||||
|
s.packets = valid_.load(std::memory_order_relaxed) + wrong_.load(std::memory_order_relaxed);
|
||||||
|
s.frames = frames_;
|
||||||
|
s.stale = wrong_.load(std::memory_order_relaxed);
|
||||||
|
s.reconnects = reconnects_;
|
||||||
|
s.connected = connected_.load(std::memory_order_relaxed);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::poll(int timeout_ms) {
|
||||||
|
drain_commands();
|
||||||
|
// Never wait long: the partial-block flush and the silence timeout are checked on this cadence.
|
||||||
|
const int wait = std::clamp(timeout_ms, 0, 20);
|
||||||
|
if (fd_ < 0 || sink_ == nullptr) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(wait));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!configured_) {
|
||||||
|
configured_ = true;
|
||||||
|
sink_->on_config(signals_, 1);
|
||||||
|
}
|
||||||
|
pollfd p{};
|
||||||
|
p.fd = fd_;
|
||||||
|
p.events = POLLIN;
|
||||||
|
if (::poll(&p, 1, wait) > 0 && (p.revents & POLLIN)) drain_socket();
|
||||||
|
|
||||||
|
const auto now = Clock::now();
|
||||||
|
if (pending_n_ > 0 && now - batch_start_ >= 100ms) flush(); // a stopped sender holds nothing back
|
||||||
|
if (connected_.load(std::memory_order_relaxed) && now - last_valid_ >= 2s) {
|
||||||
|
connected_.store(false, std::memory_order_relaxed);
|
||||||
|
sink_->on_event(SourceEvent::Disconnected, "no datagram for 2 s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::drain_socket() {
|
||||||
|
for (int i = 0; i < 8192; ++i) { // bounded, so a flood cannot starve the timers
|
||||||
|
// MSG_TRUNC: the return value is the datagram's real length even if it did not fit `rx_`.
|
||||||
|
const ssize_t n = ::recv(fd_, rx_.data(), rx_.size(), MSG_TRUNC);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
if (errno != EAGAIN && errno != EWOULDBLOCK && !recv_error_noted_) {
|
||||||
|
recv_error_noted_ = true;
|
||||||
|
note(std::string("receive failed: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
break; // EAGAIN: drained
|
||||||
|
}
|
||||||
|
const double t = epoch_seconds();
|
||||||
|
const std::size_t len = static_cast<std::size_t>(n);
|
||||||
|
if (tap_) tap_(rx_.data(), std::min(len, rx_.size()), t);
|
||||||
|
if (len == expected_) {
|
||||||
|
accept(rx_.data());
|
||||||
|
} else {
|
||||||
|
reject(len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::accept(const std::uint8_t* data) {
|
||||||
|
valid_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
last_valid_ = Clock::now();
|
||||||
|
if (!connected_.load(std::memory_order_relaxed)) {
|
||||||
|
if (ever_connected_) ++reconnects_;
|
||||||
|
ever_connected_ = true;
|
||||||
|
connected_.store(true, std::memory_order_relaxed);
|
||||||
|
sink_->on_event(SourceEvent::Connected, "");
|
||||||
|
}
|
||||||
|
decode_channels(cfg_.rows, data, cfg_.big_endian, pending_);
|
||||||
|
if (pending_n_ == 0) batch_start_ = last_valid_;
|
||||||
|
if (++pending_n_ >= block_) flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::reject(std::size_t len) {
|
||||||
|
wrong_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
if (reported_sizes_.size() >= kMaxReportedSizes || !reported_sizes_.insert(len).second) return;
|
||||||
|
const std::string msg = "datagram of " + std::to_string(len) + " bytes, expected " +
|
||||||
|
std::to_string(expected_) + ", check the signal list";
|
||||||
|
note(msg);
|
||||||
|
sink_->on_event(SourceEvent::Error, msg.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlatUdpSource::flush() {
|
||||||
|
const std::size_t n = signals_.size();
|
||||||
|
views_.resize(n);
|
||||||
|
for (std::size_t i = 0; i < n; ++i)
|
||||||
|
views_[i] = SignalValues{pending_[i].data(), static_cast<std::uint32_t>(pending_[i].size())};
|
||||||
|
Frame f;
|
||||||
|
f.counter = frame_counter_++;
|
||||||
|
f.lost = 0;
|
||||||
|
f.late = false;
|
||||||
|
f.recv_time = epoch_seconds();
|
||||||
|
f.publish_mode = 1;
|
||||||
|
f.num_samples = static_cast<std::uint32_t>(pending_n_);
|
||||||
|
f.num_signals = static_cast<std::uint32_t>(n);
|
||||||
|
f.signals = signals_.data();
|
||||||
|
f.values = views_.data();
|
||||||
|
++frames_;
|
||||||
|
sink_->on_data(f);
|
||||||
|
for (auto& c : pending_) c.clear();
|
||||||
|
pending_n_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope::flatudp
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/data_source.hpp"
|
||||||
|
#include "source/flatudp/flatudp_signals.hpp"
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope::flatudp {
|
||||||
|
|
||||||
|
constexpr int kDefaultPort = 44488; ///< `UDPSender`'s own default
|
||||||
|
|
||||||
|
bool valid_ipv4(const std::string& s);
|
||||||
|
/// An IPv4 address in 224.0.0.0/4.
|
||||||
|
bool valid_multicast(const std::string& s);
|
||||||
|
|
||||||
|
/// Everything a configured flat UDP source needs, parsed once from the flat `SourceConfig`.
|
||||||
|
struct FlatUdpSettings {
|
||||||
|
int port = kDefaultPort;
|
||||||
|
std::string bind; ///< local address; empty is any
|
||||||
|
std::string multicast; ///< group to join; empty is plain unicast
|
||||||
|
std::string iface; ///< local interface address for the join; empty is the system's choice
|
||||||
|
double rate = 0.0; ///< the sender's cycle rate in Hz; 0 means not given
|
||||||
|
bool big_endian = false;
|
||||||
|
bool endian_ok = true; ///< false when `endian` is neither "little" nor "big"
|
||||||
|
std::vector<Row> rows;
|
||||||
|
std::string signals_error; ///< why the `signals` text did not parse; `rows` is empty then
|
||||||
|
|
||||||
|
static FlatUdpSettings from(const SourceConfig& cfg);
|
||||||
|
/// The first reason this cannot connect, in words for the user; empty when it can.
|
||||||
|
std::string problem() const;
|
||||||
|
/// `[multicast:]port`; names the session file.
|
||||||
|
std::string identity() const;
|
||||||
|
/// Datagrams per frame: 1 for a list with a waveform row, else `clamp(round(rate / 100), 1, 1024)`.
|
||||||
|
std::size_t block() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FlatUdpSource final : public DataSource {
|
||||||
|
public:
|
||||||
|
explicit FlatUdpSource(const SourceConfig& cfg);
|
||||||
|
~FlatUdpSource() override;
|
||||||
|
|
||||||
|
void open(SourceSink& sink) override;
|
||||||
|
void poll(int timeout_ms) override;
|
||||||
|
void close() override;
|
||||||
|
SourceStats stats() const override;
|
||||||
|
bool connected() const override { return connected_.load(std::memory_order_relaxed); }
|
||||||
|
void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); }
|
||||||
|
|
||||||
|
const FlatUdpSettings& settings() const { return cfg_; }
|
||||||
|
/// Datagrams of the expected size / of any other size; for the probe, readable from any thread.
|
||||||
|
std::uint64_t valid_datagrams() const { return valid_.load(std::memory_order_relaxed); }
|
||||||
|
std::uint64_t wrong_size_datagrams() const { return wrong_.load(std::memory_order_relaxed); }
|
||||||
|
std::string last_message() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
|
||||||
|
void close_fd();
|
||||||
|
void drain_socket();
|
||||||
|
void accept(const std::uint8_t* data);
|
||||||
|
void reject(std::size_t len);
|
||||||
|
void flush();
|
||||||
|
void note(const std::string& msg);
|
||||||
|
|
||||||
|
FlatUdpSettings cfg_;
|
||||||
|
SourceSink* sink_ = nullptr;
|
||||||
|
DatagramTap tap_;
|
||||||
|
int fd_ = -1;
|
||||||
|
bool configured_ = false;
|
||||||
|
bool ever_connected_ = false;
|
||||||
|
bool recv_error_noted_ = false; ///< a failing `recv` is logged once per open
|
||||||
|
std::size_t expected_ = 0; ///< bytes of a valid datagram
|
||||||
|
std::size_t block_ = 1;
|
||||||
|
|
||||||
|
std::vector<SignalInfo> signals_;
|
||||||
|
std::vector<std::uint8_t> rx_;
|
||||||
|
std::vector<std::vector<double>> pending_; ///< decoded, not yet framed: [signal][value]
|
||||||
|
std::vector<SignalValues> views_;
|
||||||
|
std::size_t pending_n_ = 0; ///< datagrams in `pending_`
|
||||||
|
Clock::time_point batch_start_;
|
||||||
|
Clock::time_point last_valid_;
|
||||||
|
std::set<std::size_t> reported_sizes_;
|
||||||
|
|
||||||
|
std::atomic<bool> connected_{false};
|
||||||
|
std::atomic<std::uint64_t> valid_{0};
|
||||||
|
std::atomic<std::uint64_t> wrong_{0};
|
||||||
|
mutable std::mutex msg_mu_;
|
||||||
|
std::string last_message_;
|
||||||
|
|
||||||
|
std::uint32_t frame_counter_ = 0;
|
||||||
|
std::uint64_t frames_ = 0;
|
||||||
|
std::uint64_t reconnects_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope::flatudp
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "model.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// One signal's values in a frame. `values` points into the source's own buffer and is valid
|
||||||
|
/// only while the `on_data` call that received the frame is running.
|
||||||
|
struct SignalValues {
|
||||||
|
const double* values = nullptr;
|
||||||
|
std::uint32_t count = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet
|
||||||
|
/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only
|
||||||
|
/// needs `counter`, `lost` and `late`.
|
||||||
|
struct Frame {
|
||||||
|
std::uint32_t counter = 0;
|
||||||
|
std::uint32_t lost = 0;
|
||||||
|
bool late = false;
|
||||||
|
double recv_time = 0.0;
|
||||||
|
std::uint8_t publish_mode = 0;
|
||||||
|
std::uint32_t num_samples = 0;
|
||||||
|
std::uint32_t num_signals = 0;
|
||||||
|
const SignalInfo* signals = nullptr;
|
||||||
|
const SignalValues* values = nullptr;
|
||||||
|
|
||||||
|
/// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar
|
||||||
|
/// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`.
|
||||||
|
/// Out of range reads 0.0.
|
||||||
|
double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept {
|
||||||
|
if (sig >= num_signals) return 0.0;
|
||||||
|
const SignalValues& v = values[sig];
|
||||||
|
const std::uint32_t idx =
|
||||||
|
(v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem;
|
||||||
|
return idx < v.count ? v.values[idx] : 0.0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/probe_source.hpp"
|
||||||
|
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
ProbeSource::ProbeSource(const SourceConfig& cfg)
|
||||||
|
: rate_hz_(std::max(1.0, cfg.get_double("rate", 1000.0))), batch_(kBatch, 0.0) {
|
||||||
|
SignalInfo s;
|
||||||
|
s.name = "probe";
|
||||||
|
s.unit = "V";
|
||||||
|
s.type_code = 9; // float64
|
||||||
|
s.rows = 1;
|
||||||
|
s.cols = 1;
|
||||||
|
s.range_min = -1.0;
|
||||||
|
s.range_max = 1.0;
|
||||||
|
s.time_mode = kTimePacket;
|
||||||
|
s.sampling_rate = rate_hz_;
|
||||||
|
signals_.push_back(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProbeSource::open(SourceSink& sink) {
|
||||||
|
sink_ = &sink;
|
||||||
|
configured_ = false;
|
||||||
|
open_.store(true, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProbeSource::close() { open_.store(false, std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
SourceStats ProbeSource::stats() const {
|
||||||
|
SourceStats s;
|
||||||
|
s.packets = frames_;
|
||||||
|
s.frames = frames_;
|
||||||
|
s.connected = open_.load(std::memory_order_relaxed);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProbeSource::poll(int timeout_ms) {
|
||||||
|
drain_commands();
|
||||||
|
if (!open_.load(std::memory_order_relaxed) || sink_ == nullptr) return;
|
||||||
|
if (!configured_) {
|
||||||
|
configured_ = true;
|
||||||
|
sink_->on_config(signals_, /*publish_mode=*/1);
|
||||||
|
sink_->on_event(SourceEvent::Connected, "");
|
||||||
|
}
|
||||||
|
// One batch takes kBatch / rate seconds to "arrive".
|
||||||
|
const int period_ms = std::max(1, static_cast<int>(1000.0 * kBatch / rate_hz_));
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(period_ms, timeout_ms)));
|
||||||
|
const double step = 2.0 * M_PI * 5.0 / rate_hz_; // a 5 Hz sine
|
||||||
|
for (std::uint32_t k = 0; k < kBatch; ++k) {
|
||||||
|
batch_[k] = std::sin(phase_);
|
||||||
|
phase_ += step;
|
||||||
|
}
|
||||||
|
values_.values = batch_.data();
|
||||||
|
values_.count = kBatch;
|
||||||
|
Frame f;
|
||||||
|
f.counter = counter_++;
|
||||||
|
f.lost = 0;
|
||||||
|
f.late = false;
|
||||||
|
f.recv_time = std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
f.publish_mode = 1;
|
||||||
|
f.num_samples = kBatch;
|
||||||
|
f.num_signals = 1;
|
||||||
|
f.signals = signals_.data();
|
||||||
|
f.values = &values_;
|
||||||
|
++frames_;
|
||||||
|
sink_->on_data(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void on_rate_changed(GObject*, GParamSpec*, gpointer user) {
|
||||||
|
const auto& cb = *static_cast<std::shared_ptr<std::function<void()>>*>(user);
|
||||||
|
if (cb && *cb) (*cb)();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProbeKind final : public SourceKind {
|
||||||
|
public:
|
||||||
|
std::string id() const override { return "probe"; }
|
||||||
|
std::string display_name() const override { return "Probe (test)"; }
|
||||||
|
std::string identity(const SourceConfig& cfg) const override {
|
||||||
|
return "rate" + std::to_string(cfg.get_int("rate", 1000));
|
||||||
|
}
|
||||||
|
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
||||||
|
return std::make_unique<ProbeSource>(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigForm make_config_form() const override {
|
||||||
|
GtkWidget* rate = GTK_WIDGET(adw_spin_row_new_with_range(1, 100000, 1));
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(rate), "_Rate (Hz)");
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(rate), TRUE);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(rate), 1000);
|
||||||
|
|
||||||
|
auto cb = std::make_shared<std::function<void()>>();
|
||||||
|
g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed),
|
||||||
|
new std::shared_ptr<std::function<void()>>(cb),
|
||||||
|
+[](gpointer d, GClosure*) {
|
||||||
|
delete static_cast<std::shared_ptr<std::function<void()>>*>(d);
|
||||||
|
},
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
|
||||||
|
ConfigForm f;
|
||||||
|
f.rows = {rate};
|
||||||
|
f.description = "A synthetic 5 Hz sine, for the self-check";
|
||||||
|
f.focus = rate;
|
||||||
|
f.write = [=](const SourceConfig& c) {
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(rate), static_cast<double>(c.get_int("rate", 1000)));
|
||||||
|
};
|
||||||
|
f.read = [=]() {
|
||||||
|
SourceConfig c;
|
||||||
|
c.set("rate", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(rate)))));
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
f.valid = []() { return true; };
|
||||||
|
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<SourcePanel> make_panels(DataSource& source) const override {
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||||
|
gtk_widget_set_margin_top(box, 12);
|
||||||
|
gtk_widget_set_margin_bottom(box, 12);
|
||||||
|
gtk_widget_set_margin_start(box, 12);
|
||||||
|
gtk_widget_set_margin_end(box, 12);
|
||||||
|
GtkWidget* label = gtk_label_new("Test panel");
|
||||||
|
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(box), label);
|
||||||
|
// The probe finds the button as the box's last child. It reaches the source only through
|
||||||
|
// `post()`: the command runs inside the next `poll()`, on the producer thread.
|
||||||
|
GtkWidget* poke = gtk_button_new_with_label("Poke");
|
||||||
|
gtk_box_append(GTK_BOX(box), poke);
|
||||||
|
DataSource* src = &source;
|
||||||
|
g_signal_connect_data(
|
||||||
|
poke, "clicked",
|
||||||
|
G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
DataSource* s = static_cast<DataSource*>(user);
|
||||||
|
s->post([s] { static_cast<ProbeSource*>(s)->note_applied(); });
|
||||||
|
}),
|
||||||
|
src, nullptr, static_cast<GConnectFlags>(0));
|
||||||
|
SourcePanel p;
|
||||||
|
p.id = "poke";
|
||||||
|
p.title = "Probe";
|
||||||
|
p.widget = box;
|
||||||
|
return {p};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<SourceKind> make_probe_kind() { return std::make_unique<ProbeKind>(); }
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/data_source.hpp"
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// A synthetic source for the headless self-check: one 1 V sine signal, `kBatch` samples per
|
||||||
|
/// frame at `rate` Hz, no socket. It exists to prove the framework end to end (open, CONFIG,
|
||||||
|
/// data, commands, panels) and is registered only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set.
|
||||||
|
class ProbeSource final : public DataSource {
|
||||||
|
public:
|
||||||
|
static constexpr std::uint32_t kBatch = 10;
|
||||||
|
|
||||||
|
explicit ProbeSource(const SourceConfig& cfg);
|
||||||
|
|
||||||
|
void open(SourceSink& sink) override;
|
||||||
|
void poll(int timeout_ms) override;
|
||||||
|
void close() override;
|
||||||
|
SourceStats stats() const override;
|
||||||
|
bool connected() const override { return open_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// What a panel command does, so the probe can see that `post()` reached the producer thread.
|
||||||
|
void note_applied() noexcept { applied_.fetch_add(1, std::memory_order_relaxed); }
|
||||||
|
std::uint64_t commands_applied() const noexcept {
|
||||||
|
return applied_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
double rate_hz_;
|
||||||
|
SourceSink* sink_ = nullptr;
|
||||||
|
std::atomic<bool> open_{false};
|
||||||
|
bool configured_ = false;
|
||||||
|
std::uint32_t counter_ = 0;
|
||||||
|
std::uint64_t frames_ = 0;
|
||||||
|
double phase_ = 0.0;
|
||||||
|
std::vector<SignalInfo> signals_;
|
||||||
|
std::vector<double> batch_;
|
||||||
|
SignalValues values_;
|
||||||
|
std::atomic<std::uint64_t> applied_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::vector<std::unique_ptr<SourceKind>>& owned() {
|
||||||
|
static std::vector<std::unique_ptr<SourceKind>> v;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
std::vector<SourceKind*>& raw() {
|
||||||
|
static std::vector<SourceKind*> v;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string sanitize(const std::string& s) {
|
||||||
|
std::string out = s;
|
||||||
|
for (char& c : out) {
|
||||||
|
const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
||||||
|
c == '.' || c == '_' || c == '-';
|
||||||
|
if (!ok) c = '_';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void register_kind(std::unique_ptr<SourceKind> kind) {
|
||||||
|
if (!kind || find_kind(kind->id())) return;
|
||||||
|
raw().push_back(kind.get());
|
||||||
|
owned().push_back(std::move(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceKind* find_kind(const std::string& id) {
|
||||||
|
for (SourceKind* k : raw()) {
|
||||||
|
if (k->id() == id) return k;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<SourceKind*>& kinds() { return raw(); }
|
||||||
|
|
||||||
|
void register_builtin_sources() {
|
||||||
|
static bool done = false;
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
register_kind(make_udps_kind());
|
||||||
|
register_kind(make_e1608_kind());
|
||||||
|
register_kind(make_flatudp_kind());
|
||||||
|
if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string session_host(const std::string& kind_id, const SourceConfig& cfg) {
|
||||||
|
if (kind_id == "udps") return cfg.get("server", "127.0.0.1");
|
||||||
|
SourceKind* k = find_kind(kind_id);
|
||||||
|
return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown")));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) {
|
||||||
|
if (kind_id == "udps") {
|
||||||
|
const long p = cfg.get_int("port", 8000);
|
||||||
|
return static_cast<std::uint16_t>(p > 0 && p <= 65535 ? p : 8000);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/source_kind.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Takes ownership. A second kind with the same id is ignored.
|
||||||
|
void register_kind(std::unique_ptr<SourceKind> kind);
|
||||||
|
SourceKind* find_kind(const std::string& id);
|
||||||
|
/// In registration order (the chooser's dropdown order).
|
||||||
|
const std::vector<SourceKind*>& kinds();
|
||||||
|
/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker
|
||||||
|
/// may drop. A later `dlopen` loader would only call `register_kind()`.
|
||||||
|
void register_builtin_sources();
|
||||||
|
|
||||||
|
/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`, `flatudp/flatudp_kind.cpp`).
|
||||||
|
std::unique_ptr<SourceKind> make_udps_kind();
|
||||||
|
std::unique_ptr<SourceKind> make_probe_kind();
|
||||||
|
std::unique_ptr<SourceKind> make_e1608_kind();
|
||||||
|
std::unique_ptr<SourceKind> make_flatudp_kind();
|
||||||
|
|
||||||
|
/// The (host, port) pair that names a source's session file. UDPS keeps its legacy
|
||||||
|
/// `server`/`port`; any other kind is `"<kind>-<identity>"` (characters outside `[A-Za-z0-9._-]`
|
||||||
|
/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses.
|
||||||
|
std::string session_host(const std::string& kind_id, const SourceConfig& cfg);
|
||||||
|
std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
void SourceConfig::set(const std::string& key, const std::string& value) {
|
||||||
|
for (auto& kv : items_) {
|
||||||
|
if (kv.first == key) {
|
||||||
|
kv.second = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items_.emplace_back(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SourceConfig::has(const std::string& key) const {
|
||||||
|
return std::any_of(items_.begin(), items_.end(),
|
||||||
|
[&](const auto& kv) { return kv.first == key; });
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SourceConfig::get(const std::string& key, const std::string& def) const {
|
||||||
|
for (const auto& kv : items_) {
|
||||||
|
if (kv.first == key) return kv.second;
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
long SourceConfig::get_int(const std::string& key, long def) const {
|
||||||
|
if (!has(key)) return def;
|
||||||
|
const std::string v = get(key);
|
||||||
|
char* end = nullptr;
|
||||||
|
errno = 0;
|
||||||
|
const long n = std::strtol(v.c_str(), &end, 10);
|
||||||
|
return (end == v.c_str() || *end != '\0' || errno == ERANGE) ? def : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
double SourceConfig::get_double(const std::string& key, double def) const {
|
||||||
|
if (!has(key)) return def;
|
||||||
|
const std::string v = get(key);
|
||||||
|
char* end = nullptr;
|
||||||
|
errno = 0;
|
||||||
|
const double d = std::strtod(v.c_str(), &end);
|
||||||
|
return (end == v.c_str() || *end != '\0' || std::isinf(d)) ? def : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SourceConfig::get_bool(const std::string& key, bool def) const {
|
||||||
|
if (!has(key)) return def;
|
||||||
|
std::string v = get(key);
|
||||||
|
std::transform(v.begin(), v.end(), v.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
|
||||||
|
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SourceConfig::valid_key(const std::string& key) {
|
||||||
|
return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) {
|
||||||
|
return std::isalnum(c) || c == '_';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SourceConfig::parse_assignment(const std::string& kv) {
|
||||||
|
const auto eq = kv.find('=');
|
||||||
|
if (eq == std::string::npos) return false;
|
||||||
|
const std::string key = kv.substr(0, eq);
|
||||||
|
if (!valid_key(key)) return false;
|
||||||
|
set(key, kv.substr(eq + 1));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// A source's settings: an ordered string key/value map. It is the stored form (top-level
|
||||||
|
/// `source_opt_<key>` entries of a session), the `--source-opt k=v` form, and what a config form
|
||||||
|
/// reads and writes. Order is insertion order, so a stored session stays diff-friendly.
|
||||||
|
class SourceConfig {
|
||||||
|
public:
|
||||||
|
using Items = std::vector<std::pair<std::string, std::string>>;
|
||||||
|
|
||||||
|
SourceConfig() = default;
|
||||||
|
explicit SourceConfig(Items items) : items_(std::move(items)) {}
|
||||||
|
|
||||||
|
/// Replaces the value in place if the key exists, else appends.
|
||||||
|
void set(const std::string& key, const std::string& value);
|
||||||
|
bool has(const std::string& key) const;
|
||||||
|
std::string get(const std::string& key, const std::string& def = {}) const;
|
||||||
|
long get_int(const std::string& key, long def) const;
|
||||||
|
double get_double(const std::string& key, double def) const;
|
||||||
|
/// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`.
|
||||||
|
bool get_bool(const std::string& key, bool def) const;
|
||||||
|
|
||||||
|
/// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`.
|
||||||
|
bool parse_assignment(const std::string& kv);
|
||||||
|
|
||||||
|
const Items& items() const noexcept { return items_; }
|
||||||
|
bool empty() const noexcept { return items_.empty(); }
|
||||||
|
|
||||||
|
/// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix.
|
||||||
|
static bool valid_key(const std::string& key);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Items items_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "source/data_source.hpp"
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are
|
||||||
|
/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows
|
||||||
|
/// (the chooser drops the form when it removes the rows).
|
||||||
|
struct ConfigForm {
|
||||||
|
std::vector<GtkWidget*> rows;
|
||||||
|
/// Shown under the group's title; empty for none.
|
||||||
|
std::string description;
|
||||||
|
/// The widget that takes focus when the form is shown; may be null.
|
||||||
|
GtkWidget* focus = nullptr;
|
||||||
|
/// Puts `cfg` into the widgets (absent keys leave the kind's defaults).
|
||||||
|
std::function<void(const SourceConfig&)> write;
|
||||||
|
/// The widgets as a `SourceConfig`.
|
||||||
|
std::function<SourceConfig()> read;
|
||||||
|
/// Whether the form is complete enough to connect.
|
||||||
|
std::function<bool()> valid;
|
||||||
|
/// Calls `fn` whenever any widget changes.
|
||||||
|
std::function<void(std::function<void()>)> on_change;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A capability panel (signal generator, digital I/O, ...) for the side area.
|
||||||
|
struct SourcePanel {
|
||||||
|
std::string id; ///< stack page name
|
||||||
|
std::string title;
|
||||||
|
GtkWidget* widget = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One plugin type: its identity, factory and UI.
|
||||||
|
class SourceKind {
|
||||||
|
public:
|
||||||
|
virtual ~SourceKind() = default;
|
||||||
|
virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name
|
||||||
|
virtual std::string display_name() const = 0;
|
||||||
|
/// A stable, human-readable name for one configured source (`host:port`, a serial number).
|
||||||
|
virtual std::string identity(const SourceConfig& cfg) const = 0;
|
||||||
|
virtual std::unique_ptr<DataSource> make(const SourceConfig& cfg) const = 0;
|
||||||
|
virtual ConfigForm make_config_form() const = 0;
|
||||||
|
/// Panels for a live source. The widgets must reach the source only through `post()`.
|
||||||
|
virtual std::vector<SourcePanel> make_panels(DataSource& source) const {
|
||||||
|
(void)source;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
/// Whether the source reports raw datagrams (enables the packet-capture UI).
|
||||||
|
virtual bool has_datagrams() const { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
#include "source/udps_source.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using ChangeCb = std::shared_ptr<std::function<void()>>;
|
||||||
|
|
||||||
|
GtkWidget* entry_row(const char* title) {
|
||||||
|
GtkWidget* r = adw_entry_row_new();
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_edited(GtkEditable*, gpointer user) {
|
||||||
|
const auto& cb = *static_cast<ChangeCb*>(user);
|
||||||
|
if (cb && *cb) (*cb)();
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_value(GObject*, GParamSpec*, gpointer user) {
|
||||||
|
const auto& cb = *static_cast<ChangeCb*>(user);
|
||||||
|
if (cb && *cb) (*cb)();
|
||||||
|
}
|
||||||
|
|
||||||
|
void hook_value(GtkWidget* spin, const ChangeCb& cb) {
|
||||||
|
g_signal_connect_data(spin, "notify::value", G_CALLBACK(on_value), new ChangeCb(cb),
|
||||||
|
+[](gpointer d, GClosure*) { delete static_cast<ChangeCb*>(d); },
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void hook(GtkWidget* entry, const ChangeCb& cb) {
|
||||||
|
g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb),
|
||||||
|
+[](gpointer d, GClosure*) { delete static_cast<ChangeCb*>(d); },
|
||||||
|
static_cast<GConnectFlags>(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
|
||||||
|
|
||||||
|
class UdpsKind final : public SourceKind {
|
||||||
|
public:
|
||||||
|
std::string id() const override { return "udps"; }
|
||||||
|
std::string display_name() const override { return "UDPStreamer"; }
|
||||||
|
bool has_datagrams() const override { return true; }
|
||||||
|
|
||||||
|
std::string identity(const SourceConfig& cfg) const override {
|
||||||
|
return cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 8000));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
||||||
|
return std::make_unique<UdpsSource>(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigForm make_config_form() const override {
|
||||||
|
GtkWidget* server = entry_row("_Server");
|
||||||
|
GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1));
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port");
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(port), 8000);
|
||||||
|
GtkWidget* group = entry_row("Multicast _group");
|
||||||
|
GtkWidget* iface = entry_row("_Interface");
|
||||||
|
|
||||||
|
ChangeCb cb = std::make_shared<std::function<void()>>();
|
||||||
|
hook(server, cb);
|
||||||
|
hook_value(port, cb);
|
||||||
|
hook(group, cb);
|
||||||
|
hook(iface, cb);
|
||||||
|
|
||||||
|
ConfigForm f;
|
||||||
|
f.rows = {server, port, group, iface};
|
||||||
|
f.description = "Leave the multicast group and the interface empty for unicast on the default route";
|
||||||
|
f.focus = server;
|
||||||
|
f.write = [=](const SourceConfig& c) {
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str());
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast<double>(c.get_int("port", 8000)));
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(group), c.get("multicast").c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(iface), c.get("interface").c_str());
|
||||||
|
};
|
||||||
|
f.read = [=]() {
|
||||||
|
SourceConfig c;
|
||||||
|
c.set("server", text_of(server));
|
||||||
|
c.set("port", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(port)))));
|
||||||
|
if (!text_of(group).empty()) c.set("multicast", text_of(group));
|
||||||
|
if (!text_of(iface).empty()) c.set("interface", text_of(iface));
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
f.valid = [=]() { return !text_of(server).empty(); };
|
||||||
|
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<SourceKind> make_udps_kind() { return std::make_unique<UdpsKind>(); }
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/udps_source.hpp"
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
static_assert(kTimePacket == UDPS_TIME_PACKET && kTimeFullArray == UDPS_TIME_FULL_ARRAY &&
|
||||||
|
kTimeFirstSample == UDPS_TIME_FIRST_SAMPLE &&
|
||||||
|
kTimeLastSample == UDPS_TIME_LAST_SAMPLE,
|
||||||
|
"model.hpp's kTime* must equal the wire protocol's UDPS_TIME_*");
|
||||||
|
|
||||||
|
SignalInfo to_info(const udps_signal_t& s) {
|
||||||
|
SignalInfo info;
|
||||||
|
info.name = s.name;
|
||||||
|
info.unit = s.unit;
|
||||||
|
info.type_code = s.type_code;
|
||||||
|
info.quant_type = s.quant_type;
|
||||||
|
// Store the effective dimensions `udps_signal_num_elements` uses, so `SignalInfo::elements()`
|
||||||
|
// (rows * cols) gives the same count for any CONFIG, hostile or odd ones included: a zero
|
||||||
|
// dimension means 1 on its own (rows 0, cols 5 is 5 elements), and the product is clamped to
|
||||||
|
// UDPS_MAX_ELEMENTS, which also keeps rows * cols from overflowing uint32.
|
||||||
|
std::uint64_t rows = s.num_rows == 0 ? 1u : s.num_rows;
|
||||||
|
std::uint64_t cols = s.num_cols == 0 ? 1u : s.num_cols;
|
||||||
|
if (rows > UDPS_MAX_ELEMENTS) rows = UDPS_MAX_ELEMENTS;
|
||||||
|
if (cols > UDPS_MAX_ELEMENTS) cols = UDPS_MAX_ELEMENTS;
|
||||||
|
if (rows * cols > UDPS_MAX_ELEMENTS) cols = UDPS_MAX_ELEMENTS / rows;
|
||||||
|
info.rows = static_cast<std::uint32_t>(rows);
|
||||||
|
info.cols = static_cast<std::uint32_t>(cols);
|
||||||
|
info.range_min = s.range_min;
|
||||||
|
info.range_max = s.range_max;
|
||||||
|
info.time_mode = s.time_mode;
|
||||||
|
info.sampling_rate = s.sampling_rate;
|
||||||
|
info.time_signal_idx = s.time_signal_idx;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
UdpsSource::UdpsSource(const SourceConfig& cfg)
|
||||||
|
: server_(cfg.get("server", "127.0.0.1")),
|
||||||
|
port_(static_cast<std::uint16_t>(cfg.get_int("port", 8000))),
|
||||||
|
multicast_(cfg.get("multicast")),
|
||||||
|
interface_(cfg.get("interface")),
|
||||||
|
recv_buffer_(static_cast<int>(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
|
||||||
|
|
||||||
|
UdpsSource::~UdpsSource() { close(); }
|
||||||
|
|
||||||
|
void UdpsSource::open(SourceSink& sink) {
|
||||||
|
close();
|
||||||
|
sink_ = &sink;
|
||||||
|
udps_client_config_t c;
|
||||||
|
udps_client_config_init(&c);
|
||||||
|
c.server_addr = server_.c_str();
|
||||||
|
c.server_port = port_;
|
||||||
|
c.multicast_group = multicast_.empty() ? nullptr : multicast_.c_str();
|
||||||
|
c.interface_addr = interface_.empty() ? nullptr : interface_.c_str();
|
||||||
|
c.recv_buffer_bytes = static_cast<std::uint32_t>(recv_buffer_);
|
||||||
|
c.silence_timeout_s = 2.0;
|
||||||
|
c.reconnect_delay_s = 0.5;
|
||||||
|
c.keepalive_interval_s = 1.0;
|
||||||
|
|
||||||
|
client_ = udps_client_create(&c);
|
||||||
|
if (client_ == nullptr) {
|
||||||
|
throw std::runtime_error("could not create the UDPS client for " + server_ + ":" +
|
||||||
|
std::to_string(port_));
|
||||||
|
}
|
||||||
|
udps_client_set_callbacks(client_, &UdpsSource::config_cb, &UdpsSource::data_cb,
|
||||||
|
&UdpsSource::event_cb, this);
|
||||||
|
udps_client_set_datagram_cb(client_, &UdpsSource::datagram_cb, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UdpsSource::poll(int timeout_ms) {
|
||||||
|
drain_commands();
|
||||||
|
if (client_ != nullptr) udps_client_poll(client_, timeout_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UdpsSource::close() {
|
||||||
|
if (client_ != nullptr) {
|
||||||
|
udps_client_destroy(client_);
|
||||||
|
client_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceStats UdpsSource::stats() const {
|
||||||
|
SourceStats out;
|
||||||
|
if (client_ == nullptr) return out;
|
||||||
|
udps_stats_t s;
|
||||||
|
udps_client_stats(client_, &s);
|
||||||
|
out.packets = s.packets_received;
|
||||||
|
out.frames = s.frames_delivered;
|
||||||
|
out.gaps = s.counter_gaps;
|
||||||
|
out.late = s.late_delivered;
|
||||||
|
out.resequenced = s.late_resequenced;
|
||||||
|
out.stale = s.stale_packets;
|
||||||
|
out.overflows = s.reorder_overflows;
|
||||||
|
out.reconnects = s.reconnects;
|
||||||
|
out.connected = udps_client_is_connected(client_) != 0;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UdpsSource::connected() const {
|
||||||
|
return client_ != nullptr && udps_client_is_connected(client_) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UdpsSource::config_cb(const udps_signal_t* signals, std::uint32_t count,
|
||||||
|
std::uint8_t publish_mode, void* user) {
|
||||||
|
auto* self = static_cast<UdpsSource*>(user);
|
||||||
|
self->signals_.clear();
|
||||||
|
self->signals_.reserve(count);
|
||||||
|
for (std::uint32_t i = 0; i < count; ++i) self->signals_.push_back(to_info(signals[i]));
|
||||||
|
self->sink_->on_config(self->signals_, publish_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UdpsSource::data_cb(const udps_frame_t* f, void* user) {
|
||||||
|
auto* self = static_cast<UdpsSource*>(user);
|
||||||
|
// The frame's signals are the CONFIG's; the cache is rebuilt only if a frame ever disagrees.
|
||||||
|
if (self->signals_.size() != f->num_signals) {
|
||||||
|
self->signals_.clear();
|
||||||
|
for (std::uint32_t i = 0; i < f->num_signals; ++i) {
|
||||||
|
self->signals_.push_back(to_info(f->signals[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self->values_.resize(f->num_signals);
|
||||||
|
for (std::uint32_t i = 0; i < f->num_signals; ++i) {
|
||||||
|
self->values_[i].values = f->values[i].values;
|
||||||
|
self->values_[i].count = f->values[i].count;
|
||||||
|
}
|
||||||
|
Frame fr;
|
||||||
|
fr.counter = f->counter;
|
||||||
|
fr.lost = f->lost;
|
||||||
|
fr.late = f->late != 0;
|
||||||
|
fr.recv_time = f->recv_time;
|
||||||
|
fr.publish_mode = f->publish_mode;
|
||||||
|
fr.num_samples = f->num_samples;
|
||||||
|
fr.num_signals = f->num_signals;
|
||||||
|
fr.signals = self->signals_.data();
|
||||||
|
fr.values = self->values_.data();
|
||||||
|
self->sink_->on_data(fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UdpsSource::event_cb(udps_event_t event, const char* detail, void* user) {
|
||||||
|
auto* self = static_cast<UdpsSource*>(user);
|
||||||
|
SourceEvent e = SourceEvent::Error;
|
||||||
|
if (event == UDPS_EVENT_CONNECTED) e = SourceEvent::Connected;
|
||||||
|
else if (event == UDPS_EVENT_DISCONNECTED) e = SourceEvent::Disconnected;
|
||||||
|
self->sink_->on_event(e, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UdpsSource::datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time,
|
||||||
|
void* user) {
|
||||||
|
auto* self = static_cast<UdpsSource*>(user);
|
||||||
|
if (self->tap_) self->tap_(bytes, len, recv_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||