Merge feature/gen-rate-and-tabs into feature/source-plugins

This commit is contained in:
Martino Ferrari committed 2026-11-02 14:21:08 +01:00
commit 3ac7a8f230
16 files changed
+274 -40

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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")
+8 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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
+22 -3
View File
@@ -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
+18 -11
View File
@@ -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
+6 -3
View File
@@ -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<std::function<void()>>();
@@ -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})
+28 -2
View File
@@ -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<E1608Readouts> ro;
AoWidgets w[2];
GtkWidget* gen_rate = nullptr; ///< the "Update rate" row: the achieved rate against the requested one
Hook hooks[2];
bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing
unsigned epoch_seen = 0;
@@ -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<AoCtx*>(p); });
gtk_widget_add_tick_callback(root, ao_tick, c, nullptr);
+29 -2
View File
@@ -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<Clock::duration>(std::chrono::duration<double>(1.0 / cfg_.gen_rate));
}
void E1608Source::gen_rate_reset() {
gen_win_ticks_ = 0;
gen_win_t0_ = Clock::now();
readouts_->gen_hz.store(0.0);
}
void E1608Source::update_gen_flags() {
const bool was_gen = any_gen_;
any_gen_ = false;
for (int ch = 0; ch < 2; ++ch) {
const bool on = ao_[ch].mode != AoMode::Level && ao_[ch].run;
readouts_->gen_on[ch].store(on);
any_gen_ = any_gen_ || on;
}
if (any_gen_ != was_gen) gen_rate_reset(); // a generator started or the last one stopped
}
void E1608Source::generator_tick(Clock::time_point now) {
bool wrote = false;
for (int ch = 0; ch < 2; ++ch) {
const AoSettings& s = ao_[ch];
if (s.mode == AoMode::Level || !s.run) continue;
const double t = std::chrono::duration<double>(now - gen_t0_[ch]).count();
const double phase = std::fmod(t * s.freq_hz, 1.0);
write_ao(ch, clamp(s.offset + s.amplitude * wave(s.mode, phase), -10.0, 10.0));
wrote = true;
}
if (!wrote) return;
// The achieved rate: ticks that wrote, per second of wall time, published once a window closes.
// The DAC writes are synchronous, so a slow board shows up here as a rate below the requested one.
++gen_win_ticks_;
const double elapsed = std::chrono::duration<double>(Clock::now() - gen_win_t0_).count();
if (elapsed >= 1.0) {
readouts_->gen_hz.store(gen_win_ticks_ / elapsed);
gen_win_ticks_ = 0;
gen_win_t0_ = Clock::now();
}
}
+6
View File
@@ -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<std::uint32_t> counter{0};
std::atomic<double> ao[2]; ///< the last DAC voltage written; NaN before any
std::atomic<bool> gen_on[2];
std::atomic<double> gen_hz{0.0}; ///< generator ticks completed per second, over ~1 s; 0 when off
std::atomic<unsigned> epoch{0}; ///< bumped at each (re)connect: panels re-sync from it
E1608Readouts() {
for (int i = 0; i < 2; ++i) {
@@ -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_;
+13 -4
View File
@@ -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<void(GtkWidget*)> 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<guint>(std::atoi(env)), e1608_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_GEN")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_gen_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) {
g_timeout_add(static_cast<guint>(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.
+14
View File
@@ -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.
+78 -8
View File
@@ -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<App*>(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<App*>(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<App*>(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<App*>(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<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
+42
View File
@@ -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);