10 Commits
Author SHA1 Message Date
Martino Ferrari 7b9c62c911 Merge feature/e1608-scan into feature/source-plugins 2026-11-02 11:51:25 +01:00
Martino FerrariandClaude Opus 4.6 f74df49d33 e1608: scan review fixes (EINTR in Scan::poll, cfg-based broadcast test, no per-tick copy)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:27:00 +01:00
Martino FerrariandClaude Opus 4.6 8245324b51 docs: reconcile the E-1608 README with the real-board run and the scan lookup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:22:47 +01:00
Martino FerrariandClaude Opus 4.6 dbf145094a e1608: probe and docs for the network scan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:20:16 +01:00
Martino FerrariandClaude Opus 4.6 24bdd6b930 e1608: a Scan network row in the connection form, with click-to-fill results
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:17:01 +01:00
Martino FerrariandClaude Opus 4.6 43c21a0cc2 e1608: discover boards on every interface, not only the default route's
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:14:47 +01:00
Martino Ferrari 9426a61919 docs: implementation plan for the E-1608 network scan 2026-11-02 10:07:50 +01:00
Martino Ferrari eb4e59ac50 Merge feature/text-tabs into feature/source-plugins 2026-11-02 10:03:28 +01:00
Martino FerrariandClaude Opus 4.6 16e6d7fa75 ui: sidebar tab selector uses text labels instead of icons
The two drawn tab glyphs were judged not understandable enough; the
switcher goes to the wide policy with titles only, and the glyphs,
their gresource entries and the icon-naming code are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 09:49:55 +01:00
Martino Ferrari b6822cd32d docs: tidy the scan spec 2026-11-02 09:42:54 +01:00
23 changed files with 1200 additions and 323 deletions

No files matched your search

+1 -1
View File
@@ -61,4 +61,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, and how the Mallard manual is opened. |
+20 -14
View File
@@ -117,7 +117,12 @@ only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-che
The second built-in kind is the **MCC E-1608** Ethernet DAQ (8 single-ended or 4 differential 16-bit inputs, two 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 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), and groups the acquisition settings (scan rate, external clock, 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), a range per analog input, and the trigger, restart after an overrun, factory calibration, the digital poll 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 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** and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out**
@@ -127,12 +132,17 @@ Acquisition is hardware-paced only (the scan rate or an external clock), the cha
or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets 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. `ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone.
**This kind has not been run against a board.** It is verified against `tools/mock_e1608.py`, a protocol mock written **This kind has been run against a real board once, on 2026-11-02, with one channel.** The board was at
from two independent implementations of the wire protocol. The following rest on a single reference or the two 169.254.88.75 on `net0`, `ai0=BIP10V` single-ended with the input grounded, `scan_rate=1000`, for 7 s: the connection
disagree, and are the first thing to check when a board is attached (the mock's header lists the same items): and handshake succeeded, the source configured one channel and streamed it at the declared 1000 S/s (about 1250
the counter reset command (0x31, from one reference only); the pacer value (the source sends the *aggregate* 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 rate, `round(80e6 / (scan_rate * channels) - 1)`, as one reference does; the other sends the per-channel
frequency); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only); 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 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 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 are worth a look on a board even though the mock's header does not flag them: the calibration memory layout and
@@ -2886,11 +2896,9 @@ now — they are listed so they are not mistaken for anything else:
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 built
sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals** sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals**
tab (the trigger and the channels), **each a pill with its own glyph and no text** — the tab (the trigger and the channels), **each a text tab** (the sidebar was icon-only for a while; glyphs for "cursors and ruler" and
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list "trigger and channel list" were not understandable enough, so the words won). A source kind's own tabs
for Signals (the same picture the toolbar's trigger button carries), drawn as a (the E-1608's **Analog out** and **Digital I/O**) sit beside them as text too. Both panes also have a **locked** mode, which is what a returning user gets: the
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
accessible name. Both panes also have a **locked** mode, which is what a returning user gets: the
trigger pane shows one line saying what it is set to, the signal list shows only the signals that are trigger pane shows one line saying what it is set to, the signal list shows only the signals that are
on, and neither offers a control that changes the configuration — each heading has an `[edit]` button on, and neither offers a control that changes the configuration — each heading has an `[edit]` button
to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling
@@ -2901,9 +2909,7 @@ now — they are listed so they are not mistaken for anything else:
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. The
design spec had the switcher showing *titles* and neither page an switcher's policy is `wide` and each page has a title. With the math
`icon-name`; `narrow` alone does not do it (libadwaita still draws a label beside the icon,
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 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 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
+3 -1
View File
@@ -44,7 +44,9 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
has neither key, so a legacy session is read as UDPS and nothing existing moves. 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` - **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, (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; 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 `e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
never reaches the UI files. never reaches the UI files.
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the - The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
+4 -3
View File
@@ -122,12 +122,13 @@ is exported when the option is absent, so GTK's own choice stands.
drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1 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`, 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 `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 broadcast, or by a sweep of `subnet` (`a.b.c`). 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 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 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` per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S`
switches, `--code N`); there is no real board in the loop, and the mock's header lists the items whose switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see
layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message. 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 ```sh
python3 tools/mock_e1608.py --port 54711 & python3 tools/mock_e1608.py --port 54711 &
+15 -36
View File
@@ -51,38 +51,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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 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.
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the - **Five 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}-symbolic.svg`
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last are the five 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.
The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above. The sidebar's **tabs have no icons any more**: two glyphs (`udpscope-tab-measure-symbolic`,
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two `udpscope-tab-signals-symbolic`, each a 32x16 pair) were drawn for them and judged not understandable
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed enough, so the tabs are text and both SVGs were deleted. The *trigger's* own icon was redrawn to the
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and Signals glyph's picture at that time and kept it.
`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"`,
@@ -264,16 +246,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not* allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
current -- a change in what the reading means, not a defect, and no functional code may depend on current -- a change in what the reading means, not a defect, and no functional code may depend on
those numbers. those numbers.
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.** - **The sidebar's tabs are text: `AdwViewSwitcherPolicy.wide`, a title on every page, no icon.** The
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a built-in pages get their titles in the `.ui` and a source kind's pages from `SourcePanel::title`
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab (`adw_view_stack_add_titled`); the page's *name* still identifies it to `select_side_tab()` and the
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to session file. An icon-only version existed (policy `narrow`, empty titles, the pills named in code by
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick `name_side_tabs()`, because `narrow` still draws a label beside an icon); it was reversed because the
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and glyphs were not understandable. A page added with an empty title is a blank pill, and
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's `switcher: … pills=N labels=N pages=N` reads it as `labels` below `pills`.
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an - **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
+21 -23
View File
@@ -479,27 +479,14 @@ Two lines of the self-check output are assertions in disguise:
substitutes a missing-image placeholder, the button still works, and every other line of the substitutes a missing-image placeholder, the button still works, and every other line of the
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
is the only check that it names something that exists. is the only check that it names something that exists.
- `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36` (in the exit dump, beside - `switcher: policy=wide pills=4 labels=4 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the
`panels:`) — the two sidebar tabs as the switcher actually draws them, and they are **words**: `labels` counts *mapped labels
sidebar tabs as the switcher actually draws them. **`pills=2 labels=0`** is the claim: two pills are on with text*, so `pills=labels=pages` says every page has a visible title. `pages` is read from the stack
screen and **no text is drawn** (`labels` counts *mapped labels with text*, because `narrow` keeps an (2 for UDPS, 3 for the probe source, 4 for the E-1608 with its Analog out and Digital I/O tabs). A
empty label in the pill's layout and a plain mapped count would read 2 either way). `names=2` is the `labels` below `pills` is a page added with an empty title; `pills=0` means nothing is mapped, which
other half — both pills carry a tooltip and an accessible name set by `name_side_tabs()`, so an makes the rest of the line meaningless rather than a pass. `policy=wide` is read rather than assumed.
icon-only pill is not an anonymous button — and `pills=0` means nothing is mapped, which makes the `size=` is the bar's own size: a text tab costs about 34 px of height, and a width of 308 shows the
rest of the line meaningless rather than a pass. `policy=narrow` is read rather than assumed. `icon=` four tabs fit the 320 px column.
is the box the pills' `GtkImage` is *rendered* in, which proves the 32px override stuck: the tab
glyphs are 32x16 documents, and GDK paints an icon into a square box whatever the document's aspect,
so the artwork is drawn 32x16 at 1:1 inside that 32x32 box — letterboxed, not stretched, which is what
keeps its 2px bars crisp. It reads `0x0` on the first tick, where nothing has been allocated yet, and
is therefore read by the exit probe with the rest of the sidebar's geometry. `size=` is the bar's own
size, and it is the answer to "what did the wider glyphs cost": `36` is what it was with 16px icons,
because the pill's fixed height takes a 32px box without growing — the glyph gets twice the width for
no vertical room at all.
- `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)` (in the exit
dump, beside `toolbar icons:`) — each tab's own glyph and `gtk_icon_theme_has_icon()`'s verdict, read
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks
exactly like a page with nothing in it.
- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit - `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit
dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal
the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window
@@ -1872,11 +1859,12 @@ The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no
[e1608] scan socket closed by the board [e1608] scan socket closed by the board
[e1608] reconnect: connects=2 connected=1 ok=1 [e1608] reconnect: connects=2 connected=1 ok=1
[e1608] resumed: frames_before=612 frames_after=662 ok=1 [e1608] resumed: frames_before=612 frames_after=662 ok=1
[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
``` ```
`UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and `UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and
`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the last two) run as `--source e1608` against `tools/mock_e1608.py` `UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the next two) run as `--source e1608` against `tools/mock_e1608.py`
with the options in `verification.md`. The line between `decode` and `reconnect` is the source's own stderr message with the options in `verification.md`; `UDPSCOPE_PROBE_E1608_SCAN=1000` (the `scan` line) is a run of its own. The line between `decode` and `reconnect` is the source's own stderr message
about the drop, not a probe line (its text is the error the source met, once per distinct message). `connected=0` throughout, with about the drop, not a probe line (its text is the error the source met, once per distinct message). `connected=0` throughout, with
`actions: skipped (not connected)`, is the no-board run; it is a reading and not a failure, and it also prints `actions: skipped (not connected)`, is the no-board run; it is a reading and not a failure, and it also prints
the one `[e1608] handshake with ...: Connection refused` line, `stream: ... signals=0 expect=1 ... ok=0`, the one `[e1608] handshake with ...: Connection refused` line, `stream: ... signals=0 expect=1 ... ok=0`,
@@ -1907,6 +1895,16 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign
drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart
the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped
proves nothing). proves nothing).
- `scan: found=1 ... ok=1`: the form's **Scan network** row against the mock. `found` is the result rows after
the first scan; `0` with the second line `did not answer` is an environment with no up, non-loopback
broadcast interface that reaches the mock (or a refused broadcast), **not a pass**. `ip` is the listed address
of the expected board and `mac` the MAC the probe looked for.
- `status='1 board found'`: the row's subtitle at the end of the first scan (`N boards found` with more
than one). `No board found` with a mock running is the probe's sockets not reaching it.
- `fill_addr=0`: a click on a result did not set only the address (the MAC or subnet kept a stale value), or it
told the dialog something other than once. `fill_mac=0`: the same for **Use MAC**.
- `rescan_replaced=0`: the results of the first scan were kept or doubled by the second, so `start_scan` did not
remove them.
## `[flatudp]` ## `[flatudp]`
+12 -2
View File
@@ -17,8 +17,8 @@ Two baselines every run is read against. The UDPS runs must be **unchanged by th
a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it. a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it.
A clean build emits **three** warnings and no more: two A clean build emits **three** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons' `-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons'
generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with generated GResource `.c`, whose blob is **9248** against C99's 4095-byte string-literal limit with
the seven action icons and the two app icons that remain (a note about a generated file, which GCC the five action icons and the two app icons that remain (a note about a generated file, which GCC
itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is
now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that
bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by
@@ -441,6 +441,16 @@ precedent, not an exception.
- **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop.
- `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms - `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`. 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 - `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 `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 need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the
@@ -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.
@@ -22,7 +22,7 @@ host with several interfaces; the existing "find by MAC" lookup has the same fla
(`SO_BINDTODEVICE`, falling back to binding the interface address if the call is refused), enables (`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 `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 blocking and returns the devices found so far (E-1608 product id only, one entry per MAC, the same
reply parsing as `discover`). `done(ms)` is not part of the class: the caller owns the clock. reply parsing as `discover`). The caller owns the clock.
- `discover()` keeps its signature. A new overload or helper `discover_broadcast(port, wait_ms)` is - `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 `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. subnet still uses `sweep_targets` + `discover` as today.
@@ -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

-2
View File
@@ -20,8 +20,6 @@
<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/apps/org.udpscope.app.svg</file> <file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
+133 -4
View File
@@ -11,6 +11,7 @@
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector>
namespace udpscope { namespace udpscope {
namespace { namespace {
@@ -82,6 +83,11 @@ struct Form {
ChangeCb changed = std::make_shared<std::function<void()>>(); ChangeCb changed = std::make_shared<std::function<void()>>();
bool updating = false; bool updating = false;
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing 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; } bool diff() const { return sel_of(mode) == 1; }
int enabled_ai() const { int enabled_ai() const {
@@ -164,6 +170,24 @@ struct Form {
refresh(); 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(); } bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); }
}; };
@@ -185,7 +209,11 @@ void on_edited(GtkEditable*, gpointer user) {
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); } 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. /// "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 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) { void hook_destroy(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form, g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form,
@@ -202,6 +230,96 @@ void hook_entry(GtkWidget* w, const FormPtr& f) {
static_cast<GConnectFlags>(0)); 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 { class E1608Kind final : public SourceKind {
public: public:
std::string id() const override { return "e1608"; } std::string id() const override { return "e1608"; }
@@ -253,8 +371,18 @@ class E1608Kind final : public SourceKind {
f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3); 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->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3);
GtkWidget* conn = expander("Connection", {f->address, f->mac, f->subnet, f->port, f->code, f->scan_row = adw_expander_row_new();
f->timeout}); 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, GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart,
f->calibrate, f->poll_rate}); f->calibrate, f->poll_rate});
GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3], GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3],
@@ -283,7 +411,8 @@ class E1608Kind final : public SourceKind {
ConfigForm out; ConfigForm out;
out.rows = {conn, acq, ai, dio}; out.rows = {conn, acq, ai, dio};
out.description = out.description =
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)"; "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.focus = f->address;
out.write = [f](const SourceConfig& c) { f->write(c); }; out.write = [f](const SourceConfig& c) { f->write(c); };
out.read = [f]() { return f->read(); }; out.read = [f]() { return f->read(); };
+85 -12
View File
@@ -9,11 +9,14 @@
#include <chrono> #include <chrono>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <ifaddrs.h>
#include <net/if.h>
#include <netdb.h> #include <netdb.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <netinet/tcp.h> #include <netinet/tcp.h>
#include <poll.h> #include <poll.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <thread>
#include <unistd.h> #include <unistd.h>
namespace udpscope::e1608 { namespace udpscope::e1608 {
@@ -120,6 +123,25 @@ Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) {
return fd; 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 } // namespace
// --- addresses ------------------------------------------------------------------------------ // --- addresses ------------------------------------------------------------------------------
@@ -222,22 +244,73 @@ std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int po
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
break; break;
} }
if (n < 29 || buf[0] != 'D') continue; take_reply(buf, n, from, found);
DeviceInfo info;
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
if (info.product_id != kProductId) continue;
std::copy(buf + 1, buf + 7, info.mac.begin());
bool dup = false;
for (const DeviceInfo& d : found) dup = dup || d.mac == info.mac;
if (dup) continue;
char ip[INET_ADDRSTRLEN] = {};
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
info.ip = ip;
found.push_back(info);
} }
return 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 ----------------------------------------------------------------------------------- // --- Link -----------------------------------------------------------------------------------
void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) { void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) {
+24
View File
@@ -76,6 +76,30 @@ std::vector<std::string> sweep_targets(const std::string& subnet);
/// `kProductId`) and one entry per MAC. /// `kProductId`) and one entry per MAC.
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms); 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`). /// 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. /// Not thread safe; the source's producer thread owns it.
class Link { class Link {
-2
View File
@@ -332,12 +332,10 @@ std::vector<SourcePanel> make_e1608_panels(DataSource& source) {
SourcePanel ao; SourcePanel ao;
ao.id = "ao"; ao.id = "ao";
ao.title = "Analog out"; ao.title = "Analog out";
ao.icon = "audio-volume-high-symbolic";
ao.widget = build_ao(src); ao.widget = build_ao(src);
SourcePanel dio; SourcePanel dio;
dio.id = "dio"; dio.id = "dio";
dio.title = "Digital I/O"; dio.title = "Digital I/O";
dio.icon = "input-dialpad-symbolic";
dio.widget = build_dio(src); dio.widget = build_dio(src);
return {ao, dio}; return {ao, dio};
} }
+8 -3
View File
@@ -272,10 +272,15 @@ void E1608Source::connect_and_start() {
Mac want{}; Mac want{};
const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want); const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want);
if (host.empty() || have_mac) { if (host.empty() || have_mac) {
const std::vector<std::string> targets = const int wait_ms = std::min(cfg_.timeout_ms, 1500);
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host}; // 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 = const std::vector<DeviceInfo> found =
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500)); 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()) { if (host.empty()) {
for (const DeviceInfo& d : found) { for (const DeviceInfo& d : found) {
if (d.mac == want) host = d.ip; if (d.mac == want) host = d.ip;
-1
View File
@@ -38,7 +38,6 @@ struct ConfigForm {
struct SourcePanel { struct SourcePanel {
std::string id; ///< stack page name std::string id; ///< stack page name
std::string title; std::string title;
std::string icon; ///< icon name for the tab, may be empty
GtkWidget* widget = nullptr; GtkWidget* widget = nullptr;
}; };
+21 -108
View File
@@ -2975,64 +2975,6 @@ std::string icon_report(GtkIconTheme* theme, const char* name, const char* resou
/// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the /// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the
/// window is alive, neither of which is true when the dump runs -- so the dump can print a fact /// window is alive, neither of which is true when the dump runs -- so the dump can print a fact
/// instead of asking GTK about a destroyed widget. /// instead of asking GTK about a destroyed widget.
/// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the
/// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by
/// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader.
static const char* side_tab_title(const App& app, const char* name) {
if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure";
if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals";
if (name != nullptr) {
for (const SourcePanelPage& p : app.source_panels) {
if (p.name == name) return p.title.c_str();
}
}
return "";
}
/// Names the switcher's pills and takes their empty labels out of the layout: an empty `GtkLabel`
/// still asks for its own width, which leaves the icon off-centre in a pill meant to be one glyph.
/// Called from the first tick, because the pills are `AdwViewSwitcher`'s own and exist only once the
/// widget has been mapped.
void name_side_tabs(App& app) {
if (app.side_tabs_named || app.side_switcher == nullptr) return;
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
// The switcher's children *are* its pills, in page order, and `AdwViewSwitcherButton` is not a
// public type (there is no `ADW_IS_VIEW_SWITCHER_BUTTON`), so the two lists are walked together by
// index rather than checked by type.
GtkWidget* pill = gtk_widget_get_first_child(app.side_switcher);
for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages);
pill = gtk_widget_get_next_sibling(pill), ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* title = side_tab_title(app, adw_view_stack_page_get_name(page));
g_object_unref(page);
if (title[0] == '\0') continue;
gtk_widget_set_tooltip_text(pill, title);
gtk_accessible_update_property(GTK_ACCESSIBLE(pill), GTK_ACCESSIBLE_PROPERTY_LABEL, title, -1);
const std::function<void(GtkWidget*)> hide_labels = [&](GtkWidget* w) {
if (GTK_IS_LABEL(w)) gtk_widget_set_visible(w, FALSE);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
hide_labels(c);
}
};
hide_labels(pill);
// The tabs' glyphs are 32x16 documents, and the pill has the width for them: drawn at 32 they are
// 1:1, so both motifs sit side by side at the size they were authored at and the 2px bars stay
// crisp. A 16px box would scale the pair down to a smear, and a fractional scale would blur it.
const std::function<void(GtkWidget*)> size_images = [&](GtkWidget* w) {
if (GTK_IS_IMAGE(w)) gtk_image_set_pixel_size(GTK_IMAGE(w), 32);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
size_images(c);
}
};
size_images(pill);
app.side_tabs_named = true;
}
}
void note_panel_controls(App& app) { void note_panel_controls(App& app) {
if (!app.dbg_panel_controls.empty()) return; if (!app.dbg_panel_controls.empty()) return;
const auto in_bar = [](GtkWidget* w) { const auto in_bar = [](GtkWidget* w) {
@@ -3110,39 +3052,17 @@ void note_panel_controls(App& app) {
icon_report(theme, "org.udpscope.app-symbolic", icon_report(theme, "org.udpscope.app-symbolic",
"/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg"); "/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg");
// The two sidebar pages' own icons, named and resolved the same way, and what the switcher is // What the switcher is drawing: the sidebar's tabs are words (`wide` policy, a title per page, no
// drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, // icon). The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, so this
// so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0 // reads on the first tick. `mapped`, not `visible`: visibility is per widget, so a label whose
// meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are // *parent* is hidden still reads visible; `mapped` requires every ancestor, which is what "on
// icons, not words, in a 300px column. // screen" means -- and it is why the pill count is read the same way: if nothing is mapped the
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject` // pill count reads 0 and the label count means nothing, rather than passing vacuously.
// (only its `child` is a widget), so `gtk_widget_get_first_child()` on the stack yields the pages'
// scrolled windows and this line read empty the first time it was written.
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
for (guint i = 0; pages != nullptr && i < g_list_model_get_n_items(pages); ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* page_name = adw_view_stack_page_get_name(page);
const char* icon = adw_view_stack_page_get_icon_name(page);
app.dbg_page_icons += std::string(app.dbg_page_icons.empty() ? "" : " ") +
(page_name != nullptr ? page_name : "?") + "=" +
(icon != nullptr ? icon : "-") +
(icon != nullptr && gtk_icon_theme_has_icon(theme, icon) ? "(ok)"
: "(MISSING)");
g_object_unref(page);
}
// `mapped`, not `visible`: visibility is per widget, so a label whose *parent* is hidden still
// reads visible, and in the narrow policy that is exactly the case. `mapped` requires every
// ancestor, which is what "on screen" means -- and it is why the pill count is read the same way:
// if nothing is mapped the pill count reads 0 and the label count means nothing, rather than
// passing vacuously.
int pills = 0; int pills = 0;
int drawn_labels = 0; int drawn_labels = 0;
const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) { const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) {
if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills; if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills;
// A *mapped* label with text: libadwaita's narrow pill keeps an empty label in the layout, so // A *mapped* label with text: an empty label would be counted by "mapped" alone.
// "mapped" alone would count the empty ones and read 2 whether the pills were icons or words.
if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') { if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') {
++drawn_labels; ++drawn_labels;
} }
@@ -3154,14 +3074,12 @@ void note_panel_controls(App& app) {
if (app.side_switcher != nullptr) count_drawn(app.side_switcher); if (app.side_switcher != nullptr) count_drawn(app.side_switcher);
app.dbg_switcher_pills = pills; app.dbg_switcher_pills = pills;
app.dbg_switcher_labels = drawn_labels; app.dbg_switcher_labels = drawn_labels;
int named_pills = 0; // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`.
for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher) app.dbg_switcher_pages =
: nullptr; app.side_stack != nullptr
w != nullptr; w = gtk_widget_get_next_sibling(w)) { ? static_cast<int>(g_list_model_get_n_items(
const char* tip = gtk_widget_get_tooltip_text(w); G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))))
if (tip != nullptr && tip[0] != '\0') ++named_pills; : 0;
}
app.dbg_switcher_names = named_pills;
if (app.side_switcher != nullptr) { if (app.side_switcher != nullptr) {
g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr); g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr);
} }
@@ -3456,11 +3374,6 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
gboolean tick(gpointer user) { gboolean tick(gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
if (app->window == nullptr) return G_SOURCE_REMOVE; if (app->window == nullptr) return G_SOURCE_REMOVE;
// Before the reading, and not after it: the pills are `AdwViewSwitcher`'s own widgets and exist
// only once the switcher is mapped, so this is the first frame that can name them -- and their
// page titles are deliberately empty, so a frame that read before this ran would read pills with
// no text and no name either.
name_side_tabs(*app);
note_panel_controls(*app); note_panel_controls(*app);
sync_source_panels(*app); sync_source_panels(*app);
// The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown // The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown
@@ -5289,8 +5202,8 @@ void on_activate(GtkApplication* application, gpointer user) {
g_signal_connect(app->side_stack, "notify::visible-child-name", g_signal_connect(app->side_stack, "notify::visible-child-name",
G_CALLBACK(on_side_tab_changed), app); G_CALLBACK(on_side_tab_changed), app);
} }
// The source kind's own tabs (a generator, digital I/O). Before the first tick: that is when // The source kind's own tabs (a generator, digital I/O), added before the first tick so the
// `name_side_tabs()` puts the words on the pills, and it must see these pages too. // pages-versus-pills reading below counts them too.
build_source_panels(*app); build_source_panels(*app);
// The left side is the events pane *and* the plot, in one row: the pane is a column of its own // The left side is the events pane *and* the plot, in one row: the pane is a column of its own
@@ -6118,6 +6031,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe_reconnect, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe_reconnect, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_scan_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), flatudp_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), flatudp_probe, &app);
} }
@@ -6324,12 +6240,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger, app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger,
app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors, app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors,
app.dbg_panel_drove_toolbar); app.dbg_panel_drove_toolbar);
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d " std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d pages=%d size=%dx%d\n",
"size=%dx%d\n",
app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"), app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"),
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names, app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_pages,
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w, app.dbg_switcher_w, app.dbg_switcher_h);
app.dbg_switcher_h);
// The help affordance layer: what the contextual-help toggle left on the surfaces, and where the // The help affordance layer: what the contextual-help toggle left on the surfaces, and where the
// guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`), // guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`),
// so neither asks a destroyed widget anything. // so neither asks a destroyed widget anything.
@@ -6463,7 +6377,6 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// critical (which is how the first version of this line earned four of them). // critical (which is how the first version of this line earned four of them).
std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str()); std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str());
std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str()); std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str());
std::printf("[udpscope] page icons: %s\n", app.dbg_page_icons.c_str());
std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str()); std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str());
// The measurements pane, with the numbers the engine computed: a probe adds measurements and // The measurements pane, with the numbers the engine computed: a probe adds measurements and
// this is where their values are read, so a wrong threshold or a window that moved under a // this is where their values are read, so a wrong threshold or a window that moved under a
+7 -20
View File
@@ -54,25 +54,17 @@
child of either scroller: the one control that chooses what you are looking at must child of either scroller: the one control that chooses what you are looking at must
not be the first thing to scroll away. not be the first thing to scroll away.
The policy is `narrow` and the **titles are empty**: `narrow` still draws a label The tabs are words: the policy is `wide` and every page has a title, no icon. (They
beside an icon (measured: a 48px "Measure" inside a 54px pill), so an empty title is were icon-only glyphs once; the glyphs were not understandable enough.) The pages'
what makes a pill an icon. The pages' *names* are what identify them to *names* are what identify them to `select_side_tab()` and the session file; a source
`select_side_tab()` and the session file, and the words are re-attached to the pills in kind's own tabs are added in code with their `SourcePanel::title`. -->
code — tooltip and accessible name — by `name_side_tabs()` on the first tick, so an
icon-only pill is still a named thing rather than an anonymous button.
Each page's icon is a glyph of its own, and each one is a *pair*: the Measure tab is
`udpscope-tab-measure-symbolic` (the cursors and the ruler they read against), and the
Signals tab is `udpscope-tab-signals-symbolic` (the trigger's edge and the channel list
below it). They are drawn for the tabs rather than reusing the icons the toolbar and the
Preferences window already carry: a glyph the toolbar uses for one thing means two things
the moment a tab shows it too. -->
<object class="GtkBox" id="side_column"> <object class="GtkBox" id="side_column">
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<property name="width-request">300</property> <property name="width-request">300</property>
<child> <child>
<object class="AdwViewSwitcher" id="side_switcher"> <object class="AdwViewSwitcher" id="side_switcher">
<property name="stack">side_stack</property> <property name="stack">side_stack</property>
<property name="policy">narrow</property> <property name="policy">wide</property>
<property name="margin-top">4</property> <property name="margin-top">4</property>
<property name="margin-bottom">4</property> <property name="margin-bottom">4</property>
<property name="margin-start">6</property> <property name="margin-start">6</property>
@@ -85,10 +77,7 @@
<child> <child>
<object class="AdwViewStackPage"> <object class="AdwViewStackPage">
<property name="name">measure</property> <property name="name">measure</property>
<property name="title"></property> <property name="title">Measure</property>
<!-- The cursors and the ruler: what the pane brackets, and what it
reads the bracketed window with. -->
<property name="icon-name">udpscope-tab-measure-symbolic</property>
<property name="child"> <property name="child">
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property> <property name="hscrollbar-policy">never</property>
@@ -105,9 +94,7 @@
<child> <child>
<object class="AdwViewStackPage"> <object class="AdwViewStackPage">
<property name="name">signals</property> <property name="name">signals</property>
<property name="title"></property> <property name="title">Signals</property>
<!-- The trigger and the channel list it acts on. -->
<property name="icon-name">udpscope-tab-signals-symbolic</property>
<property name="child"> <property name="child">
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property> <property name="hscrollbar-policy">never</property>
+11 -15
View File
@@ -440,21 +440,13 @@ struct App {
/// works, and every other line of the dump still passes. /// works, and every other line of the dump still passes.
std::string dbg_toolbar_icons; std::string dbg_toolbar_icons;
std::string dbg_app_icons; std::string dbg_app_icons;
/// The two sidebar pages' own icons and whether the theme resolved them, read on the first tick /// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The tabs
/// like the two lines above. /// are words (`wide` policy, a title per page), so the label count must equal the page count.
std::string dbg_page_icons;
/// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The label
/// count is the one that says the pages are icons rather than words: `narrow` hides them, and the
/// titles stay set because they are the pages' names for a screen reader and for the tooltip.
int dbg_switcher_pills = -1; int dbg_switcher_pills = -1;
int dbg_switcher_labels = -1; int dbg_switcher_labels = -1;
int dbg_switcher_names = -1; /// How many pages the sidebar stack holds: `pills` and `labels` must both equal it.
/// The pills' icon as it is *rendered*: a 32x16 glyph in a 32px box is drawn 1:1, which is what int dbg_switcher_pages = -1;
/// keeps its 2px bars crisp. /// The switcher's own size, beside it: what the tab bar costs the column.
int dbg_switcher_icon_w = -1;
int dbg_switcher_icon_h = -1;
/// The switcher's own size, beside it: the pills carry 32px icon boxes now, so the tab bar's height
/// is what it costs.
int dbg_switcher_w = -1; int dbg_switcher_w = -1;
int dbg_switcher_h = -1; int dbg_switcher_h = -1;
/// The panes' own switches and the header bar's buttons, read together by the exit probe: the two /// The panes' own switches and the header bar's buttons, read together by the exit probe: the two
@@ -473,8 +465,6 @@ struct App {
/// thing in the body that *is* disabled with the mode. /// thing in the body that *is* disabled with the mode.
int dbg_arm_sensitive = -1; int dbg_arm_sensitive = -1;
int dbg_switcher_policy = -1; int dbg_switcher_policy = -1;
/// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped.
bool side_tabs_named = false;
/// The header bar, so the dump can report the order the controls are in: a header bar is a /// The header bar, so the dump can report the order the controls are in: a header bar is a
/// *titlebar*, not a child of the window, so walking down from the window does not reach it. /// *titlebar*, not a child of the window, so walking down from the window does not reach it.
GtkWidget* toolbar = nullptr; GtkWidget* toolbar = nullptr;
@@ -1883,6 +1873,12 @@ gboolean e1608_probe(gpointer user);
gboolean e1608_probe_report(gpointer user); gboolean e1608_probe_report(gpointer user);
gboolean e1608_probe_reconnect(gpointer user); gboolean e1608_probe_reconnect(gpointer user);
gboolean e1608_probe_resumed(gpointer user); gboolean e1608_probe_resumed(gpointer user);
/// 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);
/// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then, /// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then,
/// against `tools/mock_flatudp.py`, the stream, the value continuity, the wrong-size accounting and /// against `tools/mock_flatudp.py`, the stream, the value continuity, the wrong-size accounting and
/// the capture button (`_report`, a second later). `_gap` starts a 100 ms poll that waits for the /// the capture button (`_report`, a second later). `_gap` starts a 100 ms poll that waits for the
+150 -18
View File
@@ -104,9 +104,8 @@ gboolean exit_probe_step3(gpointer user) {
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel); a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel); a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel); a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel);
// The switcher's icon as it is *rendered*: a 32x16 glyph in a 32px box draws 1:1, which is what // The switcher's size as allocated: read here and not on the first tick, where the window has not
// keeps its 2px bars crisp rather than blurred by a fractional scale. Read here and not on the // been allocated yet and every widget still measures 0x0.
// first tick, where the window has not been allocated yet and every widget still measures 0x0.
if (a->side_switcher != nullptr) { if (a->side_switcher != nullptr) {
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher); a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher); a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
@@ -180,21 +179,6 @@ gboolean exit_probe_step3(gpointer user) {
? 1 : 0; ? 1 : 0;
a->dbg_panel_drove_toolbar = a->dbg_toolbar_switch_cursors; a->dbg_panel_drove_toolbar = a->dbg_toolbar_switch_cursors;
} }
for (GtkWidget* pill = a->side_switcher != nullptr ? gtk_widget_get_first_child(a->side_switcher)
: nullptr;
pill != nullptr; pill = gtk_widget_get_next_sibling(pill)) {
const std::function<void(GtkWidget*)> measure_icon = [&](GtkWidget* node) {
if (GTK_IS_IMAGE(node) && gtk_widget_get_mapped(node)) {
a->dbg_switcher_icon_w = std::max(a->dbg_switcher_icon_w, gtk_widget_get_width(node));
a->dbg_switcher_icon_h = std::max(a->dbg_switcher_icon_h, gtk_widget_get_height(node));
}
for (GtkWidget* c = gtk_widget_get_first_child(node); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
measure_icon(c);
}
};
measure_icon(pill);
}
// The height of one signal row, which is the thing "the channel toggle is too tall" // The height of one signal row, which is the thing "the channel toggle is too tall"
// was about. The panel's own height cannot stand in for it: it also covers the // was about. The panel's own height cannot stand in for it: it also covers the
// heading, the hint and the box margins. // heading, the hint and the box margins.
@@ -2474,6 +2458,154 @@ gboolean e1608_probe_reconnect(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
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;
}
/// The mock's channels, as the model must list them: a scalar `Name`, the elements of a `channels` /// The mock's channels, as the model must list them: a scalar `Name`, the elements of a `channels`
/// vector `Name[k]`, a waveform `Name` at `rate * count`. /// vector `Name[k]`, a waveform `Name` at `rate * count`.
struct FlatudpExpect { struct FlatudpExpect {
+3 -5
View File
@@ -27,11 +27,9 @@ void build_source_panels(App& app) {
GTK_POLICY_AUTOMATIC); GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget);
const std::string name = "source-" + p.id; const std::string name = "source-" + p.id;
// An empty title on purpose, as for the built-in tabs: the pill is an icon, and // The tab is its title: the sidebar's pills are words, not glyphs.
// `name_side_tabs()` puts `title` on it as tooltip and accessible name. adw_view_stack_add_titled(ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(),
adw_view_stack_add_titled_with_icon( p.title.c_str());
ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), "",
p.icon.empty() ? "applications-engineering-symbolic" : p.icon.c_str());
app.source_panels.push_back({name, p.title, p.widget}); app.source_panels.push_back({name, p.title, p.widget});
} }
// Disconnected until the first Connected event says otherwise. // Disconnected until the first Connected event says otherwise.