Compare commits
10
Commits
19b7f25b82
...
7b9c62c911
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9c62c911 | ||
|
|
f74df49d33 | ||
|
|
8245324b51 | ||
|
|
dbf145094a | ||
|
|
24bdd6b930 | ||
|
|
43c21a0cc2 | ||
|
|
9426a61919 | ||
|
|
eb4e59ac50 | ||
|
|
16e6d7fa75 | ||
|
|
b6822cd32d |
No files matched your search
@@ -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-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-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. |
|
||||
@@ -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
|
||||
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
|
||||
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**
|
||||
@@ -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
|
||||
`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
|
||||
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 when a board is attached (the mock's header lists the same items):
|
||||
the counter reset command (0x31, from one reference only); the pacer value (the source sends the *aggregate*
|
||||
**This kind has been run against a real board once, on 2026-11-02, with one channel.** The board was at
|
||||
169.254.88.75 on `net0`, `ai0=BIP10V` single-ended with the input grounded, `scan_rate=1000`, for 7 s: the connection
|
||||
and handshake succeeded, the source configured one channel and streamed it at the declared 1000 S/s (about 1250
|
||||
samples in the first 1.25 s, no gaps, no overflows, no `[e1608]` errors), and the readings sat within a few LSB of
|
||||
0 V (about 0.9 to 1.8 mV on a +-10 V range). Nothing else has been run on hardware; everything else is verified only
|
||||
against `tools/mock_e1608.py`, a protocol mock written from two independent implementations of the wire protocol.
|
||||
The following rest on a single reference or the two disagree, and are the first thing to check with more of the board
|
||||
exercised (the mock's header lists the same items):
|
||||
the counter reset command (0x31, from one reference only; not run on the board); the pacer value (the source sends the *aggregate*
|
||||
rate, `round(80e6 / (scan_rate * channels) - 1)`, as one reference does; the other sends the per-channel
|
||||
frequency); the 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
|
||||
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
|
||||
@@ -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
|
||||
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**
|
||||
tab (the trigger and the channels), **each a pill with its own glyph and no text** — the
|
||||
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
|
||||
for Signals (the same picture the toolbar's trigger button carries), drawn as a
|
||||
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
|
||||
tab (the trigger and the channels), **each a text tab** (the sidebar was icon-only for a while; glyphs for "cursors and ruler" and
|
||||
"trigger and channel list" were not understandable enough, so the words won). A source kind's own tabs
|
||||
(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
|
||||
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
|
||||
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
|
||||
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
|
||||
design spec had the switcher showing *titles* and neither page an
|
||||
`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
|
||||
switcher's policy is `wide` and each page has a title. With the math
|
||||
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
|
||||
the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no
|
||||
toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and
|
||||
|
||||
@@ -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.
|
||||
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
|
||||
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
|
||||
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types;
|
||||
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is
|
||||
`Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main
|
||||
thread, the source's lookup goes through `discover_broadcast`);
|
||||
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
|
||||
never reaches the UI files.
|
||||
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
|
||||
|
||||
@@ -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
|
||||
alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`,
|
||||
`plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is
|
||||
required; a MAC with no `address` is looked up by 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
|
||||
mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine
|
||||
per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S`
|
||||
switches, `--code N`); there is no real board in the loop, and the mock's header lists the items whose
|
||||
layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message.
|
||||
switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see
|
||||
the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message.
|
||||
|
||||
```sh
|
||||
python3 tools/mock_e1608.py --port 54711 &
|
||||
|
||||
@@ -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*
|
||||
(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.
|
||||
- **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
|
||||
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,
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,tab-measure,tab-signals}-symbolic.svg`
|
||||
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows}-symbolic.svg`
|
||||
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 --
|
||||
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.
|
||||
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two
|
||||
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed
|
||||
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and
|
||||
`udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They
|
||||
are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it
|
||||
too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's
|
||||
picture, so the pill and the button that turns the trigger on are recognisably about the same thing.
|
||||
The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave
|
||||
rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and
|
||||
reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half
|
||||
that matters.)
|
||||
Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because
|
||||
at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not
|
||||
clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And
|
||||
**GDK paints an icon into a square box whatever the document's aspect** (measured:
|
||||
`gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is
|
||||
letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`,
|
||||
which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is
|
||||
**inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a
|
||||
pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The
|
||||
two tab glyphs measure 29% and 30% ink, inside the range the icons above them set.
|
||||
The sidebar's **tabs have no icons any more**: two glyphs (`udpscope-tab-measure-symbolic`,
|
||||
`udpscope-tab-signals-symbolic`, each a 32x16 pair) were drawn for them and judged not understandable
|
||||
enough, so the tabs are text and both SVGs were deleted. The *trigger's* own icon was redrawn to the
|
||||
Signals glyph's picture at that time and kept it.
|
||||
- **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
|
||||
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*
|
||||
current -- a change in what the reading means, not a defect, and no functional code may depend on
|
||||
those numbers.
|
||||
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.**
|
||||
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a
|
||||
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab
|
||||
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to
|
||||
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick
|
||||
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and
|
||||
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's
|
||||
**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.
|
||||
- **The sidebar's tabs are text: `AdwViewSwitcherPolicy.wide`, a title on every page, no icon.** The
|
||||
built-in pages get their titles in the `.ui` and a source kind's pages from `SourcePanel::title`
|
||||
(`adw_view_stack_add_titled`); the page's *name* still identifies it to `select_side_tab()` and the
|
||||
session file. An icon-only version existed (policy `narrow`, empty titles, the pills named in code by
|
||||
`name_side_tabs()`, because `narrow` still draws a label beside an icon); it was reversed because the
|
||||
glyphs were not understandable. A page added with an empty title is a blank pill, and
|
||||
`switcher: … pills=N labels=N pages=N` reads it as `labels` below `pills`.
|
||||
- **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
|
||||
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36` (in the exit dump, beside
|
||||
`panels:`) — the two
|
||||
sidebar tabs as the switcher actually draws them. **`pills=2 labels=0`** is the claim: two pills are on
|
||||
screen and **no text is drawn** (`labels` counts *mapped labels with text*, because `narrow` keeps an
|
||||
empty label in the pill's layout and a plain mapped count would read 2 either way). `names=2` is the
|
||||
other half — both pills carry a tooltip and an accessible name set by `name_side_tabs()`, so an
|
||||
icon-only pill is not an anonymous button — and `pills=0` means nothing is mapped, which makes the
|
||||
rest of the line meaningless rather than a pass. `policy=narrow` is read rather than assumed. `icon=`
|
||||
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.
|
||||
- `switcher: policy=wide pills=4 labels=4 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the
|
||||
sidebar tabs as the switcher actually draws them, and they are **words**: `labels` counts *mapped labels
|
||||
with text*, so `pills=labels=pages` says every page has a visible title. `pages` is read from the stack
|
||||
(2 for UDPS, 3 for the probe source, 4 for the E-1608 with its Analog out and Digital I/O tabs). A
|
||||
`labels` below `pills` is a page added with an empty title; `pills=0` means nothing is mapped, which
|
||||
makes the rest of the line meaningless rather than a pass. `policy=wide` is read rather than assumed.
|
||||
`size=` is the bar's own size: a text tab costs about 34 px of height, and a width of 308 shows the
|
||||
four tabs fit the 320 px column.
|
||||
- `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
|
||||
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] reconnect: connects=2 connected=1 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_RECONNECT=8000` (the last 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
|
||||
`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the next two) run as `--source e1608` against `tools/mock_e1608.py`
|
||||
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
|
||||
`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`,
|
||||
@@ -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
|
||||
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).
|
||||
- `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]`
|
||||
|
||||
|
||||
@@ -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 clean build emits **three** warnings and no more: two
|
||||
`-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
|
||||
the seven action icons and the two app icons that remain (a note about a generated file, which GCC
|
||||
generated GResource `.c`, whose blob is **9248** against C99's 4095-byte string-literal limit with
|
||||
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
|
||||
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
|
||||
@@ -441,6 +441,16 @@ precedent, not an exception.
|
||||
- **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
|
||||
later checks the frame count moved on. The reading's own entry is in `probe-readings.md`.
|
||||
- `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt
|
||||
address=127.0.0.1`. Start `tools/mock_e1608.py` first on its default port 54211 (the form scans that port; do
|
||||
not pass `--port`) and kill it afterwards. The value is the delay of phase 1; phase 2 follows 2 s after it and
|
||||
phase 3 (the line) 2 s after that, so phase 3 runs 4 s after phase 1 and the delay must stay below about
|
||||
2500 ms for the line to print before the exit at 6500 ms. It builds
|
||||
the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address
|
||||
filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks
|
||||
the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the
|
||||
mock's `00:80:2f:00:16:08`. One `[e1608] scan:` line. It needs a non-loopback up interface with a broadcast
|
||||
address; when the board did not answer a second line says so, and the run proves nothing about the scan.
|
||||
- `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against
|
||||
`tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both
|
||||
need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the
|
||||
|
||||
@@ -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_BROADCAST`, and sends `D` to 255.255.255.255 on each. `poll()` drains every socket without
|
||||
blocking and returns the devices found so far (E-1608 product id only, one entry per MAC, the same
|
||||
reply parsing as `discover`). `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
|
||||
`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.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- The Measure tab: two cursors beside the ruler they read against. Each cursor is a cap over two
|
||||
bold dashes — the mark the toolbar's cursors button carries, at a weight that survives this size.
|
||||
The ruler is two bands with three marks between them and *no end caps*: a closed frame reads as a
|
||||
rectangle, and it costs a fifth of the ink on two lines that say nothing. Both motifs are centred
|
||||
on the same line and share one weight, and everything is inset two pixels. -->
|
||||
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M2 2 H7 V5 H2 Z
|
||||
M3 6 H6 V10 H3 Z
|
||||
M3 11 H6 V14 H3 Z
|
||||
M9 2 H14 V5 H9 Z
|
||||
M10 6 H13 V10 H10 Z
|
||||
M10 11 H13 V14 H10 Z
|
||||
M17 4 H30 V6 H17 Z
|
||||
M17 10 H30 V12 H17 Z
|
||||
M18 6 H20 V10 H18 Z
|
||||
M22 6 H24 V10 H22 Z
|
||||
M26 6 H28 V10 H26 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- The Signals tab: the toolbar's trigger picture — a sine crossing its dashed level — beside the
|
||||
channel list. Sharing that picture is the point: the pill and the button that turns the trigger on
|
||||
should be recognisably about the same thing. A full period and not a hump: the crest and the trough
|
||||
are what make it read as a wave rather than as a hill, and the level's dashes survive between the
|
||||
crossings. Two channel rows on the right, drawn bold like the wave, and everything inset two
|
||||
pixels, because art flush with the cell edge is what a button or a crop clips. -->
|
||||
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M2 6 H3 V10 H2 Z
|
||||
M3 4 H4 V8 H3 Z
|
||||
M4 3 H5 V7 H4 Z
|
||||
M5 2 H6 V6 H5 Z
|
||||
M6 3 H7 V7 H6 Z
|
||||
M7 4 H8 V8 H7 Z
|
||||
M8 6 H9 V10 H8 Z
|
||||
M9 8 H10 V12 H9 Z
|
||||
M10 9 H11 V13 H10 Z
|
||||
M11 10 H12 V14 H11 Z
|
||||
M12 9 H13 V13 H12 Z
|
||||
M13 8 H14 V12 H13 Z
|
||||
M14 6 H15 V10 H14 Z
|
||||
M2 7 H5 V9 H2 Z
|
||||
M7 7 H10 V9 H7 Z
|
||||
M12 7 H15 V9 H12 Z
|
||||
M18 3 H23 V8 H18 Z
|
||||
M24 4 H30 V7 H24 Z
|
||||
M18 10 H23 V15 H18 Z
|
||||
M24 11 H30 V14 H24 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -20,8 +20,6 @@
|
||||
<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-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-resize-rows-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
@@ -82,6 +83,11 @@ struct Form {
|
||||
ChangeCb changed = std::make_shared<std::function<void()>>();
|
||||
bool updating = false;
|
||||
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
|
||||
GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows
|
||||
std::vector<GtkWidget*> results; ///< the result rows of the current scan
|
||||
std::unique_ptr<Scan> scan; ///< non-null while a scan is running
|
||||
guint timer = 0; ///< the GLib source polling `scan`, 0 when none
|
||||
int scan_ms = 0; ///< time spent in the running scan
|
||||
|
||||
bool diff() const { return sel_of(mode) == 1; }
|
||||
int enabled_ai() const {
|
||||
@@ -164,6 +170,24 @@ struct Form {
|
||||
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(); }
|
||||
};
|
||||
|
||||
@@ -185,7 +209,11 @@ void on_edited(GtkEditable*, gpointer user) {
|
||||
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
|
||||
|
||||
/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone.
|
||||
void on_form_destroy(GtkWidget*, gpointer user) { (**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) {
|
||||
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));
|
||||
}
|
||||
|
||||
constexpr int kScanMs = 1500; ///< how long a scan listens
|
||||
constexpr int kTickMs = 50; ///< how often it drains the sockets
|
||||
|
||||
/// A result row's two ways to name the board: what each fills into the form (the other is cleared).
|
||||
struct Pick {
|
||||
FormPtr form;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
void free_pick(gpointer d, GClosure*) { delete static_cast<Pick*>(d); }
|
||||
|
||||
void on_pick_address(AdwActionRow*, gpointer user) {
|
||||
Pick& p = *static_cast<Pick*>(user);
|
||||
p.form->fill(p.value, "");
|
||||
}
|
||||
|
||||
void on_pick_mac(GtkButton*, gpointer user) {
|
||||
Pick& p = *static_cast<Pick*>(user);
|
||||
p.form->fill("", p.value);
|
||||
}
|
||||
|
||||
void add_result(const FormPtr& f, const DeviceInfo& d) {
|
||||
const std::string mac = format_mac(d.mac);
|
||||
GtkWidget* row = adw_action_row_new();
|
||||
gtk_widget_set_name(row, "e1608-result");
|
||||
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str());
|
||||
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str());
|
||||
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE);
|
||||
GtkWidget* use_mac = gtk_button_new_with_label("Use MAC");
|
||||
gtk_widget_set_name(use_mac, "e1608-use-mac");
|
||||
gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER);
|
||||
adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac);
|
||||
g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address),
|
||||
new Pick{f, d.ip}, free_pick, static_cast<GConnectFlags>(0));
|
||||
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, mac},
|
||||
free_pick, static_cast<GConnectFlags>(0));
|
||||
adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row);
|
||||
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE);
|
||||
f->results.push_back(row);
|
||||
}
|
||||
|
||||
void set_scan_status(Form& f, const std::string& text) {
|
||||
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str());
|
||||
}
|
||||
|
||||
/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is
|
||||
/// settled and the scan (its sockets) is released.
|
||||
gboolean on_scan_tick(gpointer user) {
|
||||
const FormPtr& fp = *static_cast<FormPtr*>(user);
|
||||
Form& f = *fp;
|
||||
if (!f.alive || !f.scan) {
|
||||
f.timer = 0;
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
const auto& found = f.scan->poll(); // valid until f.scan.reset() below, after its last use
|
||||
while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]);
|
||||
f.scan_ms += kTickMs;
|
||||
if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE;
|
||||
if (found.empty())
|
||||
set_scan_status(f, "No board found");
|
||||
else if (found.size() == 1)
|
||||
set_scan_status(f, "1 board found");
|
||||
else
|
||||
set_scan_status(f, std::to_string(found.size()) + " boards found");
|
||||
f.scan.reset();
|
||||
f.timer = 0;
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
void free_form_ptr(gpointer d) { delete static_cast<FormPtr*>(d); }
|
||||
|
||||
/// A new scan on the Port field's value: the previous results go, a running scan is replaced.
|
||||
void start_scan(const FormPtr& fp) {
|
||||
Form& f = *fp;
|
||||
if (!f.alive) return;
|
||||
f.stop_scan();
|
||||
for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r);
|
||||
f.results.clear();
|
||||
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE);
|
||||
f.scan = std::make_unique<Scan>(static_cast<int>(value_of(f.port)));
|
||||
f.scan_ms = 0;
|
||||
set_scan_status(f, "Scanning...");
|
||||
f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp),
|
||||
free_form_ptr);
|
||||
}
|
||||
|
||||
void on_scan_clicked(GtkButton*, gpointer user) {
|
||||
start_scan(*static_cast<FormPtr*>(user));
|
||||
}
|
||||
|
||||
class E1608Kind final : public SourceKind {
|
||||
public:
|
||||
std::string id() const override { return "e1608"; }
|
||||
@@ -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[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->timeout});
|
||||
f->scan_row = adw_expander_row_new();
|
||||
gtk_widget_set_name(f->scan_row, "e1608-scan-row");
|
||||
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network");
|
||||
GtkWidget* scan_button = gtk_button_new_with_label("Scan");
|
||||
gtk_widget_set_name(scan_button, "e1608-scan");
|
||||
gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER);
|
||||
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button);
|
||||
g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f),
|
||||
free_form, static_cast<GConnectFlags>(0));
|
||||
|
||||
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
|
||||
f->code, f->timeout});
|
||||
GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart,
|
||||
f->calibrate, f->poll_rate});
|
||||
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;
|
||||
out.rows = {conn, acq, ai, dio};
|
||||
out.description =
|
||||
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)";
|
||||
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), "
|
||||
"or scan the network for boards";
|
||||
out.focus = f->address;
|
||||
out.write = [f](const SourceConfig& c) { f->write(c); };
|
||||
out.read = [f]() { return f->read(); };
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace udpscope::e1608 {
|
||||
@@ -120,6 +123,25 @@ Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) {
|
||||
return fd;
|
||||
}
|
||||
|
||||
/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board
|
||||
/// that `found` did not already hold (one entry per MAC).
|
||||
bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from,
|
||||
std::vector<DeviceInfo>& found) {
|
||||
if (n < 29 || buf[0] != 'D') return false;
|
||||
DeviceInfo info;
|
||||
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
|
||||
if (info.product_id != kProductId) return false;
|
||||
std::copy(buf + 1, buf + 7, info.mac.begin());
|
||||
for (const DeviceInfo& d : found) {
|
||||
if (d.mac == info.mac) return false;
|
||||
}
|
||||
char ip[INET_ADDRSTRLEN] = {};
|
||||
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
|
||||
info.ip = ip;
|
||||
found.push_back(info);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- addresses ------------------------------------------------------------------------------
|
||||
@@ -222,22 +244,73 @@ std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int po
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
if (n < 29 || buf[0] != 'D') continue;
|
||||
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);
|
||||
take_reply(buf, n, from, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
Scan::Scan(int port) {
|
||||
ifaddrs* list = nullptr;
|
||||
if (::getifaddrs(&list) != 0) return;
|
||||
sockaddr_in to{};
|
||||
to.sin_family = AF_INET;
|
||||
to.sin_port = htons(static_cast<std::uint16_t>(port));
|
||||
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
|
||||
const std::uint8_t probe = 'D';
|
||||
std::vector<std::string> done; // an interface with several addresses needs one probe, not several
|
||||
for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) {
|
||||
if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue;
|
||||
const unsigned fl = a->ifa_flags;
|
||||
if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue;
|
||||
const std::string name = a->ifa_name;
|
||||
if (std::find(done.begin(), done.end(), name) != done.end()) continue;
|
||||
Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
|
||||
if (!s.valid()) continue;
|
||||
const int one = 1;
|
||||
::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
|
||||
if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(),
|
||||
static_cast<socklen_t>(name.size())) != 0) {
|
||||
sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
|
||||
me.sin_port = 0;
|
||||
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
|
||||
}
|
||||
if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
|
||||
sizeof to) != 1)
|
||||
continue;
|
||||
done.push_back(name);
|
||||
socks_.push_back(std::move(s));
|
||||
}
|
||||
::freeifaddrs(list);
|
||||
}
|
||||
|
||||
const std::vector<DeviceInfo>& Scan::poll() {
|
||||
for (const Fd& s : socks_) {
|
||||
for (;;) {
|
||||
std::uint8_t buf[128];
|
||||
sockaddr_in from{};
|
||||
socklen_t flen = sizeof from;
|
||||
const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT,
|
||||
reinterpret_cast<sockaddr*>(&from), &flen);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break; // nothing more on this socket for now
|
||||
}
|
||||
take_reply(buf, n, from, found_);
|
||||
}
|
||||
}
|
||||
return found_;
|
||||
}
|
||||
|
||||
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms) {
|
||||
Scan scan(port);
|
||||
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
|
||||
while (ms_left(deadline) > 0) {
|
||||
scan.poll();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
return scan.poll();
|
||||
}
|
||||
|
||||
// --- Link -----------------------------------------------------------------------------------
|
||||
|
||||
void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) {
|
||||
|
||||
@@ -76,6 +76,30 @@ std::vector<std::string> sweep_targets(const std::string& subnet);
|
||||
/// `kProductId`) and one entry per MAC.
|
||||
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms);
|
||||
|
||||
/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default
|
||||
/// route only, so on a host with several interfaces it can miss a board on another one; this opens
|
||||
/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or
|
||||
/// to the interface address when binding to the device is refused), sends `D` to
|
||||
/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the
|
||||
/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan.
|
||||
class Scan {
|
||||
public:
|
||||
explicit Scan(int port);
|
||||
Scan(const Scan&) = delete;
|
||||
Scan& operator=(const Scan&) = delete;
|
||||
/// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC.
|
||||
const std::vector<DeviceInfo>& poll();
|
||||
/// How many interfaces a probe was sent on.
|
||||
std::size_t interfaces() const noexcept { return socks_.size(); }
|
||||
|
||||
private:
|
||||
std::vector<Fd> socks_;
|
||||
std::vector<DeviceInfo> found_;
|
||||
};
|
||||
|
||||
/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
|
||||
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
|
||||
|
||||
/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`).
|
||||
/// Not thread safe; the source's producer thread owns it.
|
||||
class Link {
|
||||
|
||||
@@ -332,12 +332,10 @@ std::vector<SourcePanel> make_e1608_panels(DataSource& source) {
|
||||
SourcePanel ao;
|
||||
ao.id = "ao";
|
||||
ao.title = "Analog out";
|
||||
ao.icon = "audio-volume-high-symbolic";
|
||||
ao.widget = build_ao(src);
|
||||
SourcePanel dio;
|
||||
dio.id = "dio";
|
||||
dio.title = "Digital I/O";
|
||||
dio.icon = "input-dialpad-symbolic";
|
||||
dio.widget = build_dio(src);
|
||||
return {ao, dio};
|
||||
}
|
||||
|
||||
@@ -272,10 +272,15 @@ void E1608Source::connect_and_start() {
|
||||
Mac want{};
|
||||
const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want);
|
||||
if (host.empty() || have_mac) {
|
||||
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() && cfg_.subnet.empty();
|
||||
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()) {
|
||||
for (const DeviceInfo& d : found) {
|
||||
if (d.mac == want) host = d.ip;
|
||||
|
||||
@@ -38,7 +38,6 @@ struct ConfigForm {
|
||||
struct SourcePanel {
|
||||
std::string id; ///< stack page name
|
||||
std::string title;
|
||||
std::string icon; ///< icon name for the tab, may be empty
|
||||
GtkWidget* widget = nullptr;
|
||||
};
|
||||
|
||||
|
||||
+21
-108
@@ -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
|
||||
/// 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.
|
||||
/// 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) {
|
||||
if (!app.dbg_panel_controls.empty()) return;
|
||||
const auto in_bar = [](GtkWidget* w) {
|
||||
@@ -3110,39 +3052,17 @@ void note_panel_controls(App& app) {
|
||||
icon_report(theme, "org.udpscope.app-symbolic",
|
||||
"/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
|
||||
// drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped,
|
||||
// so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0
|
||||
// meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are
|
||||
// icons, not words, in a 300px column.
|
||||
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`
|
||||
// (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.
|
||||
// What the switcher is drawing: the sidebar's tabs are words (`wide` policy, a title per page, no
|
||||
// icon). The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, so this
|
||||
// reads on the first tick. `mapped`, not `visible`: visibility is per widget, so a label whose
|
||||
// *parent* is hidden still reads visible; `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 drawn_labels = 0;
|
||||
const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) {
|
||||
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
|
||||
// "mapped" alone would count the empty ones and read 2 whether the pills were icons or words.
|
||||
// A *mapped* label with text: an empty label would be counted by "mapped" alone.
|
||||
if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') {
|
||||
++drawn_labels;
|
||||
}
|
||||
@@ -3154,14 +3074,12 @@ void note_panel_controls(App& app) {
|
||||
if (app.side_switcher != nullptr) count_drawn(app.side_switcher);
|
||||
app.dbg_switcher_pills = pills;
|
||||
app.dbg_switcher_labels = drawn_labels;
|
||||
int named_pills = 0;
|
||||
for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher)
|
||||
: nullptr;
|
||||
w != nullptr; w = gtk_widget_get_next_sibling(w)) {
|
||||
const char* tip = gtk_widget_get_tooltip_text(w);
|
||||
if (tip != nullptr && tip[0] != '\0') ++named_pills;
|
||||
}
|
||||
app.dbg_switcher_names = named_pills;
|
||||
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`.
|
||||
app.dbg_switcher_pages =
|
||||
app.side_stack != nullptr
|
||||
? static_cast<int>(g_list_model_get_n_items(
|
||||
G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))))
|
||||
: 0;
|
||||
if (app.side_switcher != 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) {
|
||||
App* app = static_cast<App*>(user);
|
||||
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);
|
||||
sync_source_panels(*app);
|
||||
// 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_CALLBACK(on_side_tab_changed), app);
|
||||
}
|
||||
// The source kind's own tabs (a generator, digital I/O). Before the first tick: that is when
|
||||
// `name_side_tabs()` puts the words on the pills, and it must see these pages too.
|
||||
// The source kind's own tabs (a generator, digital I/O), added before the first tick so the
|
||||
// pages-versus-pills reading below counts them too.
|
||||
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
|
||||
@@ -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")) {
|
||||
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")) {
|
||||
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_cursors, app.dbg_toolbar_switch_cursors,
|
||||
app.dbg_panel_drove_toolbar);
|
||||
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d "
|
||||
"size=%dx%d\n",
|
||||
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d pages=%d size=%dx%d\n",
|
||||
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_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
|
||||
app.dbg_switcher_h);
|
||||
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_pages,
|
||||
app.dbg_switcher_w, app.dbg_switcher_h);
|
||||
// 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`),
|
||||
// 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).
|
||||
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] page icons: %s\n", app.dbg_page_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
|
||||
// this is where their values are read, so a wrong threshold or a window that moved under a
|
||||
|
||||
+7
-20
@@ -54,25 +54,17 @@
|
||||
child of either scroller: the one control that chooses what you are looking at must
|
||||
not be the first thing to scroll away.
|
||||
|
||||
The policy is `narrow` and the **titles are empty**: `narrow` still draws a label
|
||||
beside an icon (measured: a 48px "Measure" inside a 54px pill), so an empty title is
|
||||
what makes a pill an icon. The pages' *names* are what identify them to
|
||||
`select_side_tab()` and the session file, and the words are re-attached to the pills in
|
||||
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. -->
|
||||
The tabs are words: the policy is `wide` and every page has a title, no icon. (They
|
||||
were icon-only glyphs once; the glyphs were not understandable enough.) The pages'
|
||||
*names* are what identify them to `select_side_tab()` and the session file; a source
|
||||
kind's own tabs are added in code with their `SourcePanel::title`. -->
|
||||
<object class="GtkBox" id="side_column">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="width-request">300</property>
|
||||
<child>
|
||||
<object class="AdwViewSwitcher" id="side_switcher">
|
||||
<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-bottom">4</property>
|
||||
<property name="margin-start">6</property>
|
||||
@@ -85,10 +77,7 @@
|
||||
<child>
|
||||
<object class="AdwViewStackPage">
|
||||
<property name="name">measure</property>
|
||||
<property name="title"></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="title">Measure</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
@@ -105,9 +94,7 @@
|
||||
<child>
|
||||
<object class="AdwViewStackPage">
|
||||
<property name="name">signals</property>
|
||||
<property name="title"></property>
|
||||
<!-- The trigger and the channel list it acts on. -->
|
||||
<property name="icon-name">udpscope-tab-signals-symbolic</property>
|
||||
<property name="title">Signals</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
|
||||
+11
-15
@@ -440,21 +440,13 @@ struct App {
|
||||
/// works, and every other line of the dump still passes.
|
||||
std::string dbg_toolbar_icons;
|
||||
std::string dbg_app_icons;
|
||||
/// The two sidebar pages' own icons and whether the theme resolved them, read on the first tick
|
||||
/// like the two lines above.
|
||||
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.
|
||||
/// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The tabs
|
||||
/// are words (`wide` policy, a title per page), so the label count must equal the page count.
|
||||
int dbg_switcher_pills = -1;
|
||||
int dbg_switcher_labels = -1;
|
||||
int dbg_switcher_names = -1;
|
||||
/// The pills' icon as it is *rendered*: a 32x16 glyph in a 32px box is drawn 1:1, which is what
|
||||
/// keeps its 2px bars crisp.
|
||||
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.
|
||||
/// How many pages the sidebar stack holds: `pills` and `labels` must both equal it.
|
||||
int dbg_switcher_pages = -1;
|
||||
/// The switcher's own size, beside it: what the tab bar costs the column.
|
||||
int dbg_switcher_w = -1;
|
||||
int dbg_switcher_h = -1;
|
||||
/// 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.
|
||||
int dbg_arm_sensitive = -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
|
||||
/// *titlebar*, not a child of the window, so walking down from the window does not reach it.
|
||||
GtkWidget* toolbar = nullptr;
|
||||
@@ -1883,6 +1873,12 @@ gboolean e1608_probe(gpointer user);
|
||||
gboolean e1608_probe_report(gpointer user);
|
||||
gboolean e1608_probe_reconnect(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,
|
||||
/// 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
|
||||
|
||||
+150
-18
@@ -104,9 +104,8 @@ gboolean exit_probe_step3(gpointer user) {
|
||||
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_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
|
||||
// keeps its 2px bars crisp rather than blurred by a fractional scale. Read here and not on the
|
||||
// first tick, where the window has not been allocated yet and every widget still measures 0x0.
|
||||
// The switcher's size as allocated: read here and not on the first tick, where the window has not
|
||||
// been allocated yet and every widget still measures 0x0.
|
||||
if (a->side_switcher != nullptr) {
|
||||
a->dbg_switcher_w = gtk_widget_get_width(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;
|
||||
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"
|
||||
// was about. The panel's own height cannot stand in for it: it also covers the
|
||||
// heading, the hint and the box margins.
|
||||
@@ -2474,6 +2458,154 @@ gboolean e1608_probe_reconnect(gpointer user) {
|
||||
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`
|
||||
/// vector `Name[k]`, a waveform `Name` at `rate * count`.
|
||||
struct FlatudpExpect {
|
||||
|
||||
+3
-5
@@ -27,11 +27,9 @@ void build_source_panels(App& app) {
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget);
|
||||
const std::string name = "source-" + p.id;
|
||||
// An empty title on purpose, as for the built-in tabs: the pill is an icon, and
|
||||
// `name_side_tabs()` puts `title` on it as tooltip and accessible name.
|
||||
adw_view_stack_add_titled_with_icon(
|
||||
ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), "",
|
||||
p.icon.empty() ? "applications-engineering-symbolic" : p.icon.c_str());
|
||||
// The tab is its title: the sidebar's pills are words, not glyphs.
|
||||
adw_view_stack_add_titled(ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(),
|
||||
p.title.c_str());
|
||||
app.source_panels.push_back({name, p.title, p.widget});
|
||||
}
|
||||
// Disconnected until the first Connected event says otherwise.
|
||||
|
||||
Reference in new issue
Block a user