diff --git a/CLAUDE.md b/CLAUDE.md index 9d82a14..76d822c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e | File | What it covers | | --- | --- | | [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. | -| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly three build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | +| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly one build warning; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | | [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. | ### How the application is put together diff --git a/CMakeLists.txt b/CMakeLists.txt index eb8c066..f3a9785 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,7 +151,7 @@ add_custom_command( # The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the # 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on # generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle -# is small enough per file never to need it), so the build stays at its three warnings. The manual is +# is small enough per file never to need it), so the build stays at its one warning. The manual is # no longer compiled in, so the help bundle's own warning is gone with it. set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings") diff --git a/README.md b/README.md index cea447c..ded6c36 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,17 @@ click on a result fills the address, or **Use MAC** fills the MAC and leaves the saved: only the filled field is. A MAC with no subnet is looked up by a broadcast sent on every up interface: a single limited broadcast leaves through the default route only, and on 2026-11-02 it missed a real board on `net0` while the default route was `wlan0`. The form also groups the acquisition settings (scan rate, external clock, -trigger, restart after an overrun, factory calibration, the digital poll rate), a range per analog input, and the +trigger, restart after an overrun, factory calibration, the digital poll rate, the generator update rate), a range per analog input, and the direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out** (per DAC: a static level, or a sine, square, triangle or ramp from 0.1 to 10 Hz) and **Digital I/O** (a live LED per input bit, a switch per output bit, the counter with a reset); they are insensitive while no board is connected. +The waveform generator writes the DACs at the **generator update rate** (`gen_rate`, 1 to 200 Hz, default 50), which +is separate from the input poll rate (`poll_rate`, which only reads the digital inputs and the counter). Each update +is a synchronous command to the board, so a board that answers slowly achieves less than was asked: the **Analog out** +tab ends with an **Update rate** row that shows the rate the generator actually achieved (measured over about a +second) next to the requested one, or `off` when no generator runs. The waveform frequency itself is still limited +to 0.1 to 10 Hz. Acquisition is hardware-paced only (the scan rate or an external clock), the channels times the rate must stay at or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets `ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone. @@ -2909,7 +2915,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 - switcher's policy is `wide` and each page has a title. With the math + switcher's policy is `wide` and each page has a title and no icon (the switcher's own placeholder icon is hidden). 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 diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 9b21c8d..b295015 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -51,7 +51,7 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. never reaches the UI files. - The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital - input/counter poll and the 20 ms waveform tick. There is no second thread. A connect attempt (discovery, + input/counter poll and the waveform tick (every `1 / gen_rate` s, 20 ms by default; one synchronous DAC write per running channel, so a slow board lowers the rate it achieves, which the Analog out panel reports). There is no second thread. A connect attempt (discovery, handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end the producer thread, and `drain_commands()` runs inside the same `try` for that reason. diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index 9cbff1a..4e78ed1 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -118,7 +118,7 @@ is exported when the option is absent, so GTK's own choice stands. `--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional (`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel, `ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10, -`mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all +`gen_rate` 50 (Hz, 1 to 200: the waveform generator's DAC update rate), `mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1 alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`, `plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 9d24b0d..c594499 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -252,7 +252,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear 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`. + `switcher: … pills=N labels=N images=0 pages=N` reads it as `labels` below `pills`. **No icon at all** + is not what "no `icon-name`" gives you: `AdwViewSwitcherButton` always builds a `GtkImage` and falls + back to `image-missing` (the `[x]` placeholder, which also squeezed the title to "Meas..."), and no + property turns it off. `keep_tabs_icon_free()` (`src/ui_source.cpp`) hides every image in the switcher, + re-hides one that is shown again, and re-runs on the stack's `items-changed` (connected *after* the + switcher's own, so the new button exists) and on the switcher's `map`; `images=0` is the reading. - **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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 8585e82..36faa0d 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -479,9 +479,15 @@ 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=wide pills=4 labels=4 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the +- `switcher: policy=wide pills=4 labels=4 images=0 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 + with text*, so `pills=labels=pages` says every page has a visible title. **`images` must be `0`**: it + counts *mapped* `GtkImage`s inside the switcher. `AdwViewSwitcherButton` builds an icon in every tab, and + a page with a title and no `icon-name` gets `image-missing` -- the `[x]` placeholder; the tabs hide it + (`keep_tabs_icon_free()`, which also covers pages `build_source_panels()` adds). A nonzero `images` + names a tab still drawing an icon or its placeholder (`images=pages` is the unfixed state), and + `pills`/`labels` never saw it. Read it on a source with extra pages (`--source probe` under + `UDPSCOPE_PROBE_SOURCE_KINDS`) as well as on UDPS. `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. @@ -1832,7 +1838,7 @@ kind, so a run without it never shows that kind. The `form` line appears once pe - `kinds=udps,e1608,flatudp,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`, or the env var gate is wrong. - `round_trip=0`: a form's `write` and `read` disagree about a key -- a renamed `SourceConfig` key, or an - absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals. + absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1, plot0 and gen_rate; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals. - `panel disconnected: sensitive=0 present=1`: **disable, don't hide.** `present=0` is a panel that was hidden or never added; `sensitive=1` is a panel that stays live with no source behind it. - `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must @@ -1859,6 +1865,8 @@ 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] generator: requested=100.0 achieved=99.7 panel='100 Hz (requested 100 Hz)' ok=1 +[e1608] generator stopped: achieved=0.0 panel='off (requested 100 Hz)' 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 ``` @@ -1895,6 +1903,17 @@ 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). +- `generator: requested=100.0 achieved=99.7 ... ok=1`, `generator stopped: ... ok=1` + (`UDPSCOPE_PROBE_E1608_GEN=1500`, a run of its own with `gen_rate=100`): `requested` is `gen_rate`, `achieved` + the source's own measurement (generator ticks that wrote, per second of wall time, over a window of about 1 s), + `panel` the subtitle of the Analog out panel's "Update rate" row. `ok` needs `achieved` within 70% to 110% of + `requested` **and** the panel to quote the requested rate; the mock answers at once, so it reads within a + percent of `requested`. Far below is a `run_timers()` that re-arms with a constant (the 20 ms the setting + replaced reads 50) or a tick that runs late; `panel='off'` while `achieved` is non-zero is a panel that + did not refresh (it ticks only while its page is on screen) or one that reads the wrong readout. This line says + nothing about a real board, whose DAC writes are slower; there the same row is the measurement. The stopped + line must read `achieved=0.0` and a panel that starts with `off`; a non-zero value is a rate that + `update_gen_flags()` did not reset when the last generator stopped. - `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 diff --git a/docs/claude/verification.md b/docs/claude/verification.md index e840516..92c7393 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -15,23 +15,21 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**: 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' +A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons' 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 -touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers` -or any other warning is a finding. (The fifth warning that used to appear here was an unused -`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the -`output_after_add` already printed on the same line — which dropped the baseline from five to -four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's +itself supports). History: the help pages' generated `.c` (**516044** bytes) went when the manual +became a Mallard set installed for Yelp; an unused `output_built` in `math_probe()` was deleted as +dead code; and the last two, `-Wmissing-field-initializers` on `MathNode::source` in `math_probe()`, +went when the probe's `MathNode{MathOp::Gain, {}}` / `MathNode{MathOp::LowPass, {}}` named the +field (a partial brace-initialiser on `MathNode` re-adds one: `source` has no default member +initialiser). Re-measure by touching `src/icons/udpscope.gresource.xml` and rebuilding; any +second warning is a finding. And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's `gtk warnings:` line counts GTK's own log (through a writer function — GTK is built with structured logging, so the handler API never sees its messages) and must read `0`; see [probe-readings.md](probe-readings.md). -An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled +An *incremental* build reads **zero**: the generated GResource `.c` is not recompiled when only `src/` changed, so its `-Woverlength-strings` does not reappear. A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it @@ -439,6 +437,15 @@ precedent, not an exception. levels `1.5, 2.5, 1.5` (the log prints the quantised voltage, so `1.4999, 2.4999, 1.4999` is the expected text), the DOUT bytes `0x00, 0x02, 0x00`, one `COUNTER_RESET`, one `DROP`; - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. +- `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt + address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against + `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine on + DAC 0 through the Analog out panel (and switches the side stack to that page, since the panel refreshes on its + frame clock, which does not tick for a page that is off screen), waits 2.5 s, reads the achieved rate and the + panel's "Update rate" row, stops the generator and reads both again 400 ms later. Two lines, in + `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with `gen_rate=100` (the generator ran + for about 2.5 s) and about 125 for a run without the option (default 50 Hz). Short and serial, the mock killed + afterwards. It enables nothing else, so it can run alone. - `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. - `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt diff --git a/src/source/e1608/e1608_kind.cpp b/src/source/e1608/e1608_kind.cpp index 9ba2d16..7d3d851 100644 --- a/src/source/e1608/e1608_kind.cpp +++ b/src/source/e1608/e1608_kind.cpp @@ -77,7 +77,7 @@ bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w)); /// closure shares; the raw widget pointers are valid while the chooser keeps the rows. struct Form { GtkWidget *address, *mac, *subnet, *port, *code, *timeout; - GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate; + GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate, *gen_rate; GtkWidget *mode, *ai[8], *ai_group; GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2]; ChangeCb changed = std::make_shared>(); @@ -121,6 +121,7 @@ struct Form { c.set("auto_restart", on_of(auto_restart) ? "1" : "0"); c.set("calibrate", on_of(calibrate) ? "1" : "0"); c.set("poll_rate", num(value_of(poll_rate))); + c.set("gen_rate", num(value_of(gen_rate))); c.set("mode", diff() ? "diff" : "se"); static const char* const kRange[] = {"", "BIP10V", "BIP5V", "BIP2V", "BIP1V"}; for (int i = 0; i < 8; ++i) { @@ -156,6 +157,7 @@ struct Form { adw_switch_row_set_active(ADW_SWITCH_ROW(auto_restart), s.auto_restart); adw_switch_row_set_active(ADW_SWITCH_ROW(calibrate), s.calibrate); adw_spin_row_set_value(ADW_SPIN_ROW(poll_rate), s.poll_rate); + adw_spin_row_set_value(ADW_SPIN_ROW(gen_rate), s.gen_rate); adw_combo_row_set_selected(ADW_COMBO_ROW(mode), s.diff ? 1 : 0); for (int i = 0; i < 8; ++i) { adw_combo_row_set_selected( @@ -356,6 +358,7 @@ class E1608Kind final : public SourceKind { f->auto_restart = switch_row("_Restart after an overrun", true); f->calibrate = switch_row("Apply _factory calibration", true); f->poll_rate = spin_row("_Input poll rate (Hz)", 0.1, 100, 1, 10, 1); + f->gen_rate = spin_row("_Generator update rate (Hz)", 1, 200, 1, 50, 0); f->mode = combo_row("Input _mode", kMode); for (int i = 0; i < 8; ++i) { const std::string title = "Channel " + std::to_string(i); @@ -384,7 +387,7 @@ class E1608Kind final : public SourceKind { GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port, f->code, f->timeout}); GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart, - f->calibrate, f->poll_rate}); + f->calibrate, f->poll_rate, f->gen_rate}); GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3], f->ai[4], f->ai[5], f->ai[6], f->ai[7]}); GtkWidget* dio = expander("Digital I/O", {f->dir[0], f->dir[1], f->dir[2], f->dir[3], f->dir[4], @@ -395,7 +398,7 @@ class E1608Kind final : public SourceKind { for (GtkWidget* w : {conn, acq, ai, dio}) hook_destroy(w, f); for (GtkWidget* w : {f->address, f->mac, f->subnet}) hook_entry(w, f); - for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->do_init, + for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->gen_rate, f->do_init, f->ao_init[0], f->ao_init[1]}) hook_notify(w, "notify::value", f); for (GtkWidget* w : {f->ext_clock, f->auto_restart, f->calibrate, f->plot_counter}) diff --git a/src/source/e1608/e1608_panels.cpp b/src/source/e1608/e1608_panels.cpp index ecae53e..18408b3 100644 --- a/src/source/e1608/e1608_panels.cpp +++ b/src/source/e1608/e1608_panels.cpp @@ -55,7 +55,7 @@ void set_text(GtkWidget* label, const char* text) { if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text); } -GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) { +GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr, GtkWidget* c = nullptr) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18); gtk_widget_set_margin_top(box, 12); gtk_widget_set_margin_bottom(box, 12); @@ -63,6 +63,7 @@ GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) { gtk_widget_set_margin_end(box, 12); gtk_box_append(GTK_BOX(box), a); if (b != nullptr) gtk_box_append(GTK_BOX(box), b); + if (c != nullptr) gtk_box_append(GTK_BOX(box), c); return box; } @@ -77,6 +78,7 @@ struct AoCtx { E1608Source* src = nullptr; std::shared_ptr ro; AoWidgets w[2]; + GtkWidget* gen_rate = nullptr; ///< the "Update rate" row: the achieved rate against the requested one Hook hooks[2]; bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing unsigned epoch_seen = 0; @@ -148,6 +150,22 @@ void ao_refresh(AoCtx* c) { if (cur == nullptr || std::strcmp(cur, b) != 0) adw_action_row_set_subtitle(ADW_ACTION_ROW(c->w[ch].last), b); } + + // The achieved generator rate: ticks completed per second, measured by the producer thread. Below + // the requested rate, the board's command latency is what limits it. + char r[64]; + const double req = c->src->settings().gen_rate; + const bool running = c->ro->gen_on[0].load() || c->ro->gen_on[1].load(); + const double hz = c->ro->gen_hz.load(); + if (!running) + std::snprintf(r, sizeof r, "off (requested %.0f Hz)", req); + else if (hz <= 0.0) + std::snprintf(r, sizeof r, "measuring... (requested %.0f Hz)", req); + else + std::snprintf(r, sizeof r, "%.0f Hz (requested %.0f Hz)", hz, req); + const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->gen_rate)); + if (cur == nullptr || std::strcmp(cur, r) != 0) + adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), r); } gboolean ao_tick(GtkWidget*, GdkFrameClock*, gpointer user) { @@ -198,7 +216,15 @@ GtkWidget* build_ao(E1608Source* src) { ao_sensitivity(w); } - GtkWidget* root = panel_root(groups[0], groups[1]); + c->gen_rate = adw_action_row_new(); + gtk_widget_set_name(c->gen_rate, "e1608-gen-rate"); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(c->gen_rate), "Update rate"); + adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), "off"); + GtkWidget* gen_group = adw_preferences_group_new(); + adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(gen_group), "Generator"); + adw_preferences_group_add(ADW_PREFERENCES_GROUP(gen_group), c->gen_rate); + + GtkWidget* root = panel_root(groups[0], groups[1], gen_group); g_object_set_data_full(G_OBJECT(root), "e1608-ao-ctx", c, +[](gpointer p) { delete static_cast(p); }); gtk_widget_add_tick_callback(root, ao_tick, c, nullptr); diff --git a/src/source/e1608/e1608_source.cpp b/src/source/e1608/e1608_source.cpp index 7e85e95..fb66793 100644 --- a/src/source/e1608/e1608_source.cpp +++ b/src/source/e1608/e1608_source.cpp @@ -61,6 +61,7 @@ E1608Settings E1608Settings::from(const SourceConfig& c) { s.auto_restart = c.get_bool("auto_restart", true); s.calibrate = c.get_bool("calibrate", true); s.poll_rate = c.get_double("poll_rate", 10.0); + s.gen_rate = c.get_double("gen_rate", 50.0); s.diff = lower(c.get("mode", "se")) == "diff"; bool any_key = false; @@ -133,6 +134,7 @@ std::string E1608Settings::problem() const { } if (aggregate() < kMinAggregateHz) return "The aggregate rate must be at least 0.02 S/s"; if (!(poll_rate >= 0.1 && poll_rate <= 100.0)) return "The input poll rate must be 0.1 to 100 Hz"; + if (!(gen_rate >= 1.0 && gen_rate <= 200.0)) return "The generator update rate must be 1 to 200 Hz"; return {}; } @@ -230,6 +232,7 @@ void E1608Source::fail(const std::string& msg, bool fatal) { any_gen_ = false; readouts_->gen_on[0].store(false); readouts_->gen_on[1].store(false); + gen_rate_reset(); if (fatal) { state_ = State::Halted; } else { @@ -327,7 +330,7 @@ void E1608Source::connect_and_start() { const auto now = Clock::now(); next_slow_ = now; next_status_ = now + std::chrono::milliseconds(500); - next_gen_ = now + std::chrono::milliseconds(20); + next_gen_ = now + gen_period(); slow_read(); state_ = State::Scanning; @@ -409,7 +412,7 @@ void E1608Source::run_timers() { check_status(); } if (any_gen_ && now >= next_gen_) { - next_gen_ = now + std::chrono::milliseconds(20); + next_gen_ = now + gen_period(); // from now, not from the old deadline: a slow board gets no catch-up burst generator_tick(now); } } @@ -475,22 +478,46 @@ void E1608Source::write_ao(int ch, double volts) { readouts_->ao[ch].store(dev_.aout(ch, volts, cal_)); } +E1608Source::Clock::duration E1608Source::gen_period() const { + return std::chrono::duration_cast(std::chrono::duration(1.0 / cfg_.gen_rate)); +} + +void E1608Source::gen_rate_reset() { + gen_win_ticks_ = 0; + gen_win_t0_ = Clock::now(); + readouts_->gen_hz.store(0.0); +} + void E1608Source::update_gen_flags() { + const bool was_gen = any_gen_; any_gen_ = false; for (int ch = 0; ch < 2; ++ch) { const bool on = ao_[ch].mode != AoMode::Level && ao_[ch].run; readouts_->gen_on[ch].store(on); any_gen_ = any_gen_ || on; } + if (any_gen_ != was_gen) gen_rate_reset(); // a generator started or the last one stopped } void E1608Source::generator_tick(Clock::time_point now) { + bool wrote = false; for (int ch = 0; ch < 2; ++ch) { const AoSettings& s = ao_[ch]; if (s.mode == AoMode::Level || !s.run) continue; const double t = std::chrono::duration(now - gen_t0_[ch]).count(); const double phase = std::fmod(t * s.freq_hz, 1.0); write_ao(ch, clamp(s.offset + s.amplitude * wave(s.mode, phase), -10.0, 10.0)); + wrote = true; + } + if (!wrote) return; + // The achieved rate: ticks that wrote, per second of wall time, published once a window closes. + // The DAC writes are synchronous, so a slow board shows up here as a rate below the requested one. + ++gen_win_ticks_; + const double elapsed = std::chrono::duration(Clock::now() - gen_win_t0_).count(); + if (elapsed >= 1.0) { + readouts_->gen_hz.store(gen_win_ticks_ / elapsed); + gen_win_ticks_ = 0; + gen_win_t0_ = Clock::now(); } } diff --git a/src/source/e1608/e1608_source.hpp b/src/source/e1608/e1608_source.hpp index 81bb1e7..dfc7ad3 100644 --- a/src/source/e1608/e1608_source.hpp +++ b/src/source/e1608/e1608_source.hpp @@ -36,6 +36,7 @@ struct E1608Settings { bool auto_restart = true; bool calibrate = true; double poll_rate = 10.0; ///< Hz, for the digital inputs and the counter + double gen_rate = 50.0; ///< Hz, the waveform generator's DAC update rate bool diff = false; bool ai_on[8] = {}; Range ai_range[8] = {}; @@ -62,6 +63,7 @@ struct E1608Readouts { std::atomic counter{0}; std::atomic ao[2]; ///< the last DAC voltage written; NaN before any std::atomic gen_on[2]; + std::atomic gen_hz{0.0}; ///< generator ticks completed per second, over ~1 s; 0 when off std::atomic epoch{0}; ///< bumped at each (re)connect: panels re-sync from it E1608Readouts() { for (int i = 0; i < 2; ++i) { @@ -117,6 +119,8 @@ class E1608Source final : public DataSource { void generator_tick(Clock::time_point now); void write_ao(int ch, double volts); void update_gen_flags(); + void gen_rate_reset(); + Clock::duration gen_period() const; void fail(const std::string& msg, bool fatal); void note(const std::string& msg); int wait_ms(int timeout_ms, Clock::time_point now) const; @@ -153,6 +157,8 @@ class E1608Source final : public DataSource { AoSettings ao_[2]; Clock::time_point gen_t0_[2]; bool any_gen_ = false; + Clock::time_point gen_win_t0_; ///< start of the current rate-measurement window + unsigned gen_win_ticks_ = 0; ///< ticks that wrote since `gen_win_t0_` Clock::time_point retry_at_; Clock::time_point next_slow_, next_status_, next_gen_; diff --git a/src/ui.cpp b/src/ui.cpp index fe5c28d..c6c715d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3060,8 +3060,11 @@ void note_panel_controls(App& app) { // pill count reads 0 and the label count means nothing, rather than passing vacuously. int pills = 0; int drawn_labels = 0; + int drawn_images = 0; const std::function count_drawn = [&](GtkWidget* w) { if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills; + // A *mapped* image is an icon (or its missing-icon placeholder) on a tab: tabs are words only. + if (GTK_IS_IMAGE(w) && gtk_widget_get_mapped(w)) ++drawn_images; // 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; @@ -3074,6 +3077,7 @@ 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; + app.dbg_switcher_images = drawn_images; // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`. app.dbg_switcher_pages = app.side_stack != nullptr @@ -5203,7 +5207,9 @@ void on_activate(GtkApplication* application, gpointer user) { G_CALLBACK(on_side_tab_changed), app); } // 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. + // pages-versus-pills reading below counts them too. The tabs are words, so the switcher's icons + // are hidden first and kept hidden for the pages this call adds. + keep_tabs_icon_free(*app); build_source_panels(*app); // The left side is the events pane *and* the plot, in one row: the pane is a column of its own @@ -6028,6 +6034,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608")) { g_timeout_add(static_cast(std::atoi(env)), e1608_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_GEN")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_gen_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { g_timeout_add(static_cast(std::atoi(env)), e1608_probe_reconnect, &app); } @@ -6240,10 +6249,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 pages=%d size=%dx%d\n", + std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d images=%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_pages, - app.dbg_switcher_w, app.dbg_switcher_h); + app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_images, + 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. diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 95852f4..8437d53 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -444,6 +444,9 @@ struct App { /// 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; + /// How many `GtkImage`s the switcher has *mapped*: a tab is its title and nothing else, so this + /// must be 0 -- an icon, or the missing-icon placeholder GTK draws for a page with no `icon-name`. + int dbg_switcher_images = -1; /// 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. @@ -1560,6 +1563,11 @@ void config_dialog_refresh(ConfigDialog& d); void open_configurations(App& app); void save_layout_now(App& app); +/// The sidebar's tabs are words: hide the icon every `AdwViewSwitcherButton` draws (the +/// missing-icon placeholder, for a page with a title and no `icon-name`), for the buttons that exist +/// and for any page added later. Call once, before `build_source_panels()`. +void keep_tabs_icon_free(App& app); + /// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as /// pages of `side_stack`, once, before the first tick. `apply_source_panels` sets every panel's /// sensitivity from `connected` -- disabled, never hidden: the tab switcher owns visibility. @@ -1873,6 +1881,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 generator's update rate: starts a Sine on DAC 0 through the panel, reads the achieved rate the +/// source measured and the Analog out panel's "Update rate" row (`_report`, 2.5 s later), then stops +/// it and checks both read "off" (`_stopped`). +gboolean e1608_gen_probe(gpointer user); +gboolean e1608_gen_probe_report(gpointer user); +gboolean e1608_gen_probe_stopped(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. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index bb334b2..fe5dc8c 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -467,7 +467,7 @@ gboolean math_probe(gpointer user) { MathChannel gain; gain.name = "math x2"; gain.enabled = true; - gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain}}; + gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain, {}}}; gain.nodes[1].in_a = 0; gain.nodes[1].gain = 2.0; gain.output = 1; @@ -476,14 +476,14 @@ gboolean math_probe(gpointer user) { MathChannel lp; lp.name = "math lp"; lp.enabled = true; - lp.nodes = {MathNode{MathOp::Source, late_name}, MathNode{MathOp::LowPass}}; + lp.nodes = {MathNode{MathOp::Source, late_name}, MathNode{MathOp::LowPass, {}}}; lp.nodes[1].in_a = 0; lp.nodes[1].fc = 20.0; lp.output = 1; channels.push_back(lp); // A third channel so the editor's new form row is built and synced on a baseline run. Assigned field - // by field: a partial brace-initialiser would add a fifth build warning. + // by field: a partial brace-initialiser would add a build warning. MathChannel dn; dn.name = "math denoise"; dn.enabled = true; @@ -1269,8 +1269,8 @@ gboolean mathwrap_probe(gpointer user) { MathChannel gain; gain.name = "math wrap"; gain.enabled = true; - // Field by field: a partial brace-initialiser would add a fourth build warning, and the baseline - // is exactly three. + // Field by field: a partial brace-initialiser would add a build warning, and the baseline + // is exactly one on a clean build. MathNode src; src.op = MathOp::Source; src.source = name; @@ -1923,6 +1923,7 @@ gboolean source_kinds_probe(gpointer user) { c.set("trigger", "rising"); c.set("dir1", "o"); c.set("plot0", "1"); + c.set("gen_rate", "120"); } else if (id == "flatudp") { c.set("port", "44600"); c.set("bind", "10.0.0.8"); @@ -2428,6 +2429,76 @@ gboolean e1608_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// Phase 3 of the generator probe: the generator switched off 400 ms ago; the achieved rate and the +/// panel must both have gone back to "off". +gboolean e1608_gen_probe_stopped(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + GtkWidget* row = find_named(source_panel_content(a, "source-ao"), "e1608-gen-rate"); + const char* sub = row != nullptr ? adw_action_row_get_subtitle(ADW_ACTION_ROW(row)) : nullptr; + const double hz = src->readouts()->gen_hz.load(); + const bool off = sub != nullptr && std::strncmp(sub, "off", 3) == 0; + std::fprintf(stderr, "[e1608] generator stopped: achieved=%.1f panel='%s' ok=%d\n", hz, + sub != nullptr ? sub : "", hz == 0.0 && off ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Phase 2 of the generator probe, 2.5 s after a Sine generator was started on DAC 0: the achieved +/// update rate the source measured, and the text the Analog out panel shows for it. +gboolean e1608_gen_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + GtkWidget* ao = source_panel_content(a, "source-ao"); + GtkWidget* row = find_named(ao, "e1608-gen-rate"); + const char* sub = row != nullptr ? adw_action_row_get_subtitle(ADW_ACTION_ROW(row)) : nullptr; + const double req = src->settings().gen_rate; + const double hz = src->readouts()->gen_hz.load(); + // The mock answers at once, so the achieved rate sits at the requested one; the band is loose. + const bool in_range = hz >= 0.7 * req && hz <= 1.1 * req; + char want[32]; + std::snprintf(want, sizeof want, "(requested %.0f Hz)", req); + const bool shown = sub != nullptr && std::strstr(sub, want) != nullptr && std::strstr(sub, "Hz") != nullptr; + std::fprintf(stderr, "[e1608] generator: requested=%.1f achieved=%.1f panel='%s' ok=%d\n", req, hz, + sub != nullptr ? sub : "", in_range && shown ? 1 : 0); + std::fflush(stderr); + GtkWidget* run = find_named(ao, "e1608-ao0-run"); + if (run != nullptr) adw_switch_row_set_active(ADW_SWITCH_ROW(run), FALSE); + g_timeout_add(400, e1608_gen_probe_stopped, a); + return G_SOURCE_REMOVE; +} + +/// Starts a Sine generator on DAC 0 through the panel's own controls, then reads it 2.5 s later. +gboolean e1608_gen_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) { + std::fprintf(stderr, "[e1608] generator: no e1608 source (run with --source e1608)\n"); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + GtkWidget* ao = source_panel_content(a, "source-ao"); + GtkWidget* mode = find_named(ao, "e1608-ao0-mode"); + GtkWidget* run = find_named(ao, "e1608-ao0-run"); + if (mode == nullptr || run == nullptr) { + std::fprintf(stderr, "[e1608] generator: controls missing (mode=%d run=%d)\n", mode != nullptr ? 1 : 0, + run != nullptr ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + // The panel refreshes on its frame clock, which only ticks while the page is on screen. + if (a->side_stack != nullptr) adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(a->side_stack), "source-ao"); + adw_combo_row_set_selected(ADW_COMBO_ROW(mode), 1); // Sine + adw_switch_row_set_active(ADW_SWITCH_ROW(run), TRUE); + g_timeout_add(2500, e1608_gen_probe_report, a); + return G_SOURCE_REMOVE; +} + /// After the mock dropped the link: has the source reconnected, and does the stream move again? gboolean e1608_probe_resumed(gpointer user) { App* a = static_cast(user); @@ -5843,9 +5914,8 @@ int noise_channel(App* a, const char* name) { // the older feature's proof, so the new channel stays out of the acquisition plan until they have // all run, and only the de-noise comparison is taken with it in. // -// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` is -// what the two `-Wmissing-field-initializers` in this file already are, and the build baseline -// is exactly four warnings. +// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` leaves +// `source` out and warns (`-Wmissing-field-initializers`), and the build baseline is one warning. gboolean noise_probe_setup(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; diff --git a/src/ui_source.cpp b/src/ui_source.cpp index d81b723..d880485 100644 --- a/src/ui_source.cpp +++ b/src/ui_source.cpp @@ -11,6 +11,48 @@ namespace udpscope { namespace ui_impl { +namespace { + +/// Hide every `GtkImage` inside `w`, and keep it hidden. `AdwViewSwitcherButton` always builds an +/// icon -- in both of its layouts -- and a page with no `icon-name` gets `image-missing`, which is +/// the placeholder the sidebar's text tabs used to draw. There is no property or policy that turns +/// the icon off, so the image is hidden (a hidden child takes no space: the label gets the pill) and +/// a `notify::visible` handler puts it back to hidden if the button ever shows it again. +void hide_tab_icons(GtkWidget* w) { + if (GTK_IS_IMAGE(w)) { + if (g_object_get_data(G_OBJECT(w), "udpscope-icon-hidden") == nullptr) { + g_object_set_data(G_OBJECT(w), "udpscope-icon-hidden", GINT_TO_POINTER(1)); + g_signal_connect(w, "notify::visible", G_CALLBACK(+[](GObject* o, GParamSpec*, gpointer) { + GtkWidget* img = GTK_WIDGET(o); + if (gtk_widget_get_visible(img)) gtk_widget_set_visible(img, FALSE); + }), + nullptr); + } + gtk_widget_set_visible(w, FALSE); + return; + } + for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { + hide_tab_icons(c); + } +} + +void on_tab_pages_changed(GListModel*, guint, guint, guint, gpointer sw) { + hide_tab_icons(GTK_WIDGET(sw)); +} + +} // namespace + +void keep_tabs_icon_free(App& app) { + if (app.side_switcher == nullptr || app.side_stack == nullptr) return; + hide_tab_icons(app.side_switcher); + // A page added later gets a button when the model reports it; this handler is connected *after* + // the switcher's own, so the button already exists when it runs. + g_signal_connect_after(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)), "items-changed", + G_CALLBACK(on_tab_pages_changed), app.side_switcher); + // And once more when the switcher is (re)mapped, which also covers a rebuild of the buttons. + g_signal_connect_swapped(app.side_switcher, "map", G_CALLBACK(hide_tab_icons), app.side_switcher); +} + void build_source_panels(App& app) { if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; SourceKind* kind = find_kind(app.source_kind);