Skip to content

fix(gui): FFT FPS and the dBm range survive a restart. Principle VIII. - #6140

Merged
ten9876 merged 18 commits into
aethersdr:mainfrom
on8st:hl2/pan-settings-persist
Oct 6, 2026
Merged

ten9876 merged 18 commits into
aethersdr:mainfrom
on8st:hl2/pan-settings-persist

Conversation

@on8st

@on8st on8st commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Update, d60f4660 (2026-10-05): main 12df6943 merged in; one conflict, in docs/architecture/radio-capabilities-map.md, both sides kept. No code of this PR changed.

Update, dabf83e3 (2026-10-05): one fix on top of the reviewed head: the 3D floor resync after a zoom is no longer adopted or stored as the operator's range. Maintainer ruling of 2026-10-05: this PR owns dbmRanges[pan], its helpers and its accepted range; #6129 rebases onto it. Line references below are at 7a5ca748; MainWindow_Wiring.cpp gains four lines after :4269 since.

Update after review, 7a5ca748 (2026-10-05). The change request is applied, and it narrows this PR:

  • Only the Hermes-Lite 2 is covered now. Storing and restoring FFT FPS and the dBm range is gated on an owner the backend declares, not on backend identity. The HL2 declares both. Icom, ANAN, RTL-SDR and the demo backend declare none and behave as on main. A demo run that showed FFT FPS restored at 295bff6e will, by the code, not reproduce at this head; that was not run.
  • feat(anan): remember where the operator left the radio. Principle III. #6129 (open) adds the same dbmRanges[pan] = {min, max} table with a different accept-set (min < max, -250..100), a different gate (!radioOwnsDbmScale: ANAN, Icom) and a restore into the widget instead of the pan model. With the HL2-only opt-in no radio is claimed by both PRs any more. The two accept-sets still differ and this PR adopts whichever the maintainer rules. A trial merge of 7a5ca748 with feat(anan): remember where the operator left the radio. Principle III. #6129 at 04762258 conflicts in MainWindow_Wiring.cpp and the two touchpoint files; ClientDisplaySettings.h merges as text and then holds two definitions of DbmRange, dbmRange() and saveDbmRange(). The comparison is in the review reply.
  • A stored dBm value is validated before it is narrowed to a float (NaN, the infinities and ±1e308 are refused through the real store and reader).
  • The source-text pins match whole call statements.

Refs #5678 (HL2 parity; rows P16, the dBm range, and the FPS half of P7 in our status table). No issue exists for this. #5111 (the Flex side) is not touched.

Limits, first

  • Where it is stored, since you ruled on that for fix(hl2): open DIGU/DIGL with AGC off, correct §18. Principle VIII. #6119: in the radio's settings document, not a flat key. Two optional tables, fftFps[pan] and dbmRanges[pan] = {min, max}, in the existing per-radio ClientDisplay feature document that already holds waterfallRates. No schema bump.
  • Who may store and restore is declared per feature, by the backend. FFT FPS: a new optional record, PanFrameRateShaping, field clientPersistsFrameRate, read through RadioCapabilities::clientPersistsPanFrameRate(). The dBm range: a new field in the existing PanAmplitudeModel, clientPersistsDbmRange, read through RadioCapabilities::clientPersistsDbmRange(). Same shape as BackendPanAveraging::clientPersistsAveraging (RTL M1: confirmed receive state, free capture browsing and finer zoom #5924). No new top-level capability bool: the count stays at the frozen 70.
  • Two second conditions remain, and you may want them changed. FFT FPS also needs RadioModel::shapesDisplayRatesLocally(), as the averaging path does: the restore sits where the local shaper is seeded. The range also needs absolute bins, inside its accessor: the client writes the range into the pan model, which is wrong on a radio whose bins are scaled by it or whose backend publishes its own range. Neither condition grants anything without the declaration.
  • The waterfall-rate gate on main is not touched. It still reads shapesDisplayRatesLocally() alone.
  • Half of what was observed. FFT AVG and Wt Avg are not fixed on the HL2. RTL M1: confirmed receive state, free capture browsing and finer zoom #5924 gave them a table in this document (fftAverages), kept only for a backend that declares clientPersistsAveraging; the HL2 declares it false.
  • Not seen in a running app at this head, on any radio. The defect was (HL2, 2026-10-01, 2 of 2 restarts: FPS 12 came back 25; a 120 dB scale came back -40 / 90 dB).
  • It changes what the pan model holds, on an HL2 only: an operator's range is now written into PanadapterModel. That range is absolute dB (dBFS under a dBm label).
  • Families: the Hermes-Lite 2 only, for both settings. It is the radio the defect was observed on, and it stores nothing itself. Flex, Icom, ANAN, RTL-SDR and the demo backend declare no owner: nothing is stored or restored for them, as on main. Widening to another family is one declaration in that family's backend, for its owner or the maintainer to make; feat(anan): remember where the operator left the radio. Principle III. #6129 claims the ANAN side of the range.
  • Yours to move: the FPS bounds 5..60 (the slider's own) and the plausibility bounds, moved unchanged from MainWindow_Wiring.cpp:168 on main to core/DbmRangePlausibility.h.

What was wrong on main

A Flex stores both and reports them on connect. A radio with no display engine reports nothing (observed on the HL2, and fixed here for the HL2 only; line references are at 7a5ca748):

  • FFT FPS stays at the widget default (SpectrumWidget.h:1666, 25) and pan wiring seeds the shaper from the widget (MainWindow_Wiring.cpp:3320).
  • The dBm range: pan wiring primes the widget from the model (MainWindow_Wiring.cpp:4074, MainWindow_Session.cpp:1855), the model starts at -130 / -40 (PanadapterModel.h:279), and on a radio that does not own the scale nothing ever moves it: on an HL2 the range command is attempted and dropped at the command plane (the HL2 leaves radioOwnsDbmScale at its default true, RadioCapabilities.h:439, so the gate at MainWindow_Wiring.cpp:3779 does not stop it), and nothing echoes.
  • Of a pan's display settings only the waterfall rate (core/ClientDisplaySettings.h:90-132) and, since RTL M1: confirmed receive state, free capture browsing and finer zoom #5924, FFT averaging for a backend that declares it (:48-88) have a client store.

The change

  • FFT FPS, where the backend declares the client its owner, is saved by the Display slider (before the adaptive-throttle return), Clone to all Pans and Reset to Defaults, and restored into the widget before the shaper is seeded from it.
  • The dBm range, where the backend declares the client its owner, is saved on a scale request or a finished drag, and restored into the pan model before the widget is primed from it, at both prime sites. FFT Floor Auto's own moves are not stored.
  • The pending-write key gains the field. It was (radio, pan), so two fields of one pan edited inside the 250 ms window replaced each other.
  • dbm_range_plausibility_test now includes the real predicate instead of a hand copy. One row added to the touchpoint manifest (240 with main at 12df6943 merged in).
  • A Flex is unchanged, and so are Icom, ANAN, RTL-SDR and the demo backend: none declares an owner, so every new reader returns nothing and every new writer returns before the store. Save and restore of each setting ask one predicate (MainWindow::clientPersistsFftFps(), MainWindow::clientOwnsPanDbmRange()), each reading one RadioCapabilities accessor.
  • docs/architecture/radio-capabilities-map.md has the new record and field with each family's value.

Tests

client_display_pan_settings_test (new, socket-free, real store): round trip per radio and pan; the Flex-shaped scope; ownership of both settings read through the two accessors off real HL2, Icom, ANAN and Flex backends (the HL2 stores and restores; the others and an undeclared backend write nothing, read nothing and leave a held document byte for byte); refusals on write and read; a newer schema; coexistence with an fftAverages table in #5924's shape; two fields in one deferral window.

spectrum_overlay_fft_fps_bounds_test (new, real widget, in a TestSettingsProfile): the slider runs the stored bounds.

One source-text pin block, and its claim: the order and presence of the save and restore calls inside MainWindow methods, which link into no test, and that the save and the restore of each setting ask the one predicate.

Fail-before, one build and one run per row. The two ownership rows are at 7a5ca748; the other rows were measured at 295bff6e and not run again:

mutation failing checks
this branch 0
main's wiring, and a store that neither reads nor writes the new tables 17
pan wiring does not restore FFT FPS 1 (source text)
the store ignores who owns the value on read 1
the pending-write key has no field 2
both ownership accessors return true for every backend 33 (8 each for Icom, ANAN, Flex and an undeclared backend, and the relative-bins check)
both ownership accessors return false for every backend 8 (7 HL2, and the absolute-bins control)
a stored range is not validated on read 1
a scale request does not adopt the range 1 (source text)
the writer replaces the whole document 6, fftAverages and the waterfall rate among them
the stored FPS maximum is 61 1 (the slider bounds test)

Full ctest, macOS, Qt 6.12.0, at d60f4660 (main 12df6943 merged in, no code of this PR changed): 645 of 646 pass at -j8; the one failure is peripheral_auth_dialog_test (#6008), which also fails alone. The run before it, at 7a5ca748 (main 85f2a124 merged in): 644 of 646 pass at -j8. Two failures, neither from this PR: peripheral_auth_dialog_test (#6008; diagnostic on #6134), which also fails alone, and vkamp_connection_test (#5254), which passed when rerun alone. hl2_receiver_count_restart_paced_test and libre_radar_test passed at -j8 in this run.

Static checks (static-checks.yml, stdin from /dev/null): the script steps of the Static checks job pass, including the touchpoint manifest (240, up to date) and check_capability_records.py --strict (70 booleans, at the frozen 70); colour ratchet +0. check_a11y on the three changed GUI files: no findings. The Sanitizer option configures job was not run locally.

With the open PRs

Not verified

  • The restore in a running app, on any radio, at this head.
  • The older rows of the fail-before table at this head (see Tests).
  • Linux and Windows: built and tested on macOS only.
  • The 3D view in a running app. Its floor resync after a zoom is excluded from the adopt and the store by a guard (dabf83e3) that is pinned as source text only; the path was not driven with frames. Reset to Defaults does not reset the dBm range; it did not before.

Known red on main: peripheral_auth_dialog_test (#6008) fails on this Mac at this head, as it did on clean main at 22507580 ("Clear code or Status is clipped at the default dialog size": a serial-port name on this host widens the page; diagnostic on #6134). Clean main at 85f2a124 was not built here. hl2_pan_create_async_test passed; vkamp_connection_test (#5254) failed at -j8 and passed alone.

73, ON8ST

🤖 Generated with Claude Code

on8st and others added 4 commits October 1, 2026 12:36
On a Hermes-Lite 2, FFT FPS 12 came back as 25 after a clean quit and
relaunch, and a dBm scale of -2.19 top / 120 dB came back as -40 / 90.
A Flex stores these per pan and reports them on connect, so the client
deliberately keeps no copy (aethersdr#2465, aethersdr#4126). A radio whose display the
engine shapes stores nothing and reports nothing: the widget kept its
default, and pan wiring primed the scale from the PanadapterModel's
built-in -130..-40.

The waterfall rate already had the answer: the per-radio ClientDisplay
feature document, written only where shapesDisplayRatesLocally() holds.
The two values join it there, keyed by pan slot, with no schema bump
(each table is optional, every writer is read-modify-write).

- FFT FPS: saved by the Display slider, Clone to all Pans and Reset to
  Defaults; restored into the widget before pan wiring seeds the shaper.
- dBm range: only where clientOwnsDbmRange() holds, which adds
  panBinsAbsolute(), so an Icom, whose backend publishes its range,
  stays out. An operator's range goes into the pan model as well as the
  store, and the stored one goes into the model before the first prime,
  so every site that re-seeds from the model agrees. FFT Floor Auto's
  own moves are not stored.
- One DeferredSettingsWrites key per field: two fields of one pan
  edited inside 250 ms would have shared a key.

FFT AVG and Wt Avg were lost in the same observation and are not
covered: the open RTL work already stores them under `fftAverages`
with a capability of its own, and a second shape under that key would
break both. The test pins that these writers leave that table alone.

dbmRangeLooksPlausible() moves from a file-static to
core/DbmRangePlausibility.h, since the store validates with it;
dbm_range_plausibility_test drops its hand copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict: docs/architecture/aetherd-touchpoints.md totals, regenerated
with tools/gen_touchpoint_manifest.py (234 headers, --check clean). The
branch's long comments are cut to the five-line rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict: docs/architecture/aetherd-touchpoints.md, the totals line.
Regenerated with tools/gen_touchpoint_manifest.py: main's 235 headers
plus core/DbmRangePlausibility.h, 236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le VIII.

The slider-bounds check read SpectrumOverlayMenu.cpp as text. The menu
links into tests, so spectrum_overlay_fft_fps_bounds_test reads the real
slider's range against ClientDisplaySettings::kFftFpsMin/Max. The
remaining source-text block covers only call order inside MainWindow
methods, which no test can construct, and says so.

Comments and the HERMES.md section the branch adds state what is true
now; history stays in the PR body. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st
on8st requested a review from a team as a code owner October 4, 2026 01:19
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this one — the limits-first write-up made it quick to confirm nothing in your change is at fault here.

What failed

Only Static checks, and only one step inside it: aetherd touchpoint manifest (static-checks.yml:180, python tools/gen_touchpoint_manifest.py --check). Every other step in that job passed, and Sanitizer option configures is green. build, check-macos and check-windows were still in progress when I looked, so I can't speak for them yet.

::error::aetherd-touchpoints.md is stale — run `python tools/gen_touchpoint_manifest.py`

This is merge skew, not your code

Your PR body says the manifest is clean locally, and that is accurate for your tree. I reproduced it: at 2903ab4e the check prints manifest up to date. CI disagrees because the Checkout PR branch step passes no ref:, so a pull_request run checks out GitHub's merge ref — your head merged with current main — not your head.

The two sides each added one row and each bumped the same Totals line to the same text, so git merged that line conflict-free while the table gained both rows:

revision Totals line says table rows
merge base 52b6f7ad 235 235
your head 2903ab4e 236 236
main 3e4b14e2 236 236
the merge CI builds 236 237 ❌

The fix, and the order matters

Regenerating at your current head will just write 236 again, because core/PeripheralRemovalGuard.h isn't in your tree yet. Merge first:

git merge origin/main
python tools/gen_touchpoint_manifest.py
git commit -s docs/architecture/aetherd-touchpoints.md

Expected result:

**Totals:** 237 touchpoint headers (198 core, 39 models) — 237/237 tagged, 0/237 converted.

I ran exactly that in a throwaway worktree: the merge is conflict-free, --check then passes, and the only file that changes is docs/architecture/aetherd-touchpoints.md — one line, the Totals. Both rows survive with their descriptions intact (DbmRangePlausibility.h with 1 includer, PeripheralRemovalGuard.h with 3). So this is a one-commit follow-up, not a rebase ordeal.

Small bookkeeping nit while you're in there: the PR body's "One row added to the touchpoint manifest (236)" becomes 237 after the merge.

No Copilot or reviewer comments on the PR, so nothing flagged there that bears on this.

Heads-up for your other open PRs: anything that adds a new engine header under src/core/ or src/models/ will hit this same pattern whenever main adds one too, since the Totals line is a single shared line that merges silently. Worth regenerating after every merge from main, not just once.

🤖 Generated with Claude Code


🤖 aethersdr-agent · cost: $5.9324 · model: claude-opus-5

on8st and others added 4 commits October 4, 2026 03:46
main gained aethersdr#6138, aethersdr#6115 and aethersdr#6027 since this branch was cut. No
textual conflict; the touchpoint manifest is regenerated in the next
commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…VIII.

This branch and main each added one row and each set Totals to 236,
so the merge held 237 rows under a Totals of 236 and the manifest
check failed. Regenerated with tools/gen_touchpoint_manifest.py on the
merged tree: 237 headers (198 core, 39 models).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained aethersdr#6127 (squelch scale) after the first merge. No textual
conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…VIII.

aethersdr#6127 added a row on main and this branch has one of its own, so the
merged tree again held one row more than its Totals line said.
Regenerated with tools/gen_touchpoint_manifest.py: 238 headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Static checks failed at the touchpoint manifest, and then the PR conflicted in the same file. Merge skew: this branch and main each added a row and each wrote the Totals line. Merged main at a8cfd990 and regenerated the manifest with the tool: 238 headers, --check up to date. Head b1cdeab7: 622 of 623 pass, only peripheral_auth_dialog_test (#6008) fails. weather_radar_loading_test timed out once at -j8 and passed alone.

73, ON8ST

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Issue fit

No linked issue, and the body says so plainly ("Refs #5678 ... No issue exists for this"). GOVERNANCE.md does not require one for a bug fix with a clear root cause, and the root cause here is stated precisely and checks out: on a non-Flex backend the FFT FPS stays at SpectrumWidget's built-in 25 and the pan model stays at its built-in -130 / -40 (PanadapterModel.h), because nothing reports either and only waterfallRates had a client store. Both halves of the stated defect are addressed, and the scope limits (FFT AVG / Wt Avg left to #5924, FFT Floor Auto's own moves not stored, Reset-to-Defaults still not resetting the range) are disclosed rather than glossed.

The design choice that carries the PR is the right one: the remembered range goes into the pan model, not the widget. I checked all eight setDbmRange(pan->minDbm(), pan->maxDbm()) sites in src/gui — MainWindow_Wiring.cpp:2715/4011/4164/4173, MainWindow_Session.cpp:1836, MainWindow_KiwiSdr.cpp:449, MainWindow.cpp:8732/10765 — and every one reads the model. So the two restore calls cover the six re-seed sites the PR doesn't touch, including createPansSequentially's layout-change prime. That is why there is no "sibling site left behind" finding here.

2. Scope

File / group What it changes Claimed in body? Verdict
src/core/ClientDisplaySettings.h fftFps + dbmRanges tables, clientOwnsDbmRange, pendingWriteKey yes in scope
src/core/DbmRangePlausibility.h (new) predicate moved out of MainWindow_Wiring.cpp, unchanged yes ("yours to move") in scope
src/gui/MainWindow.h, _Wiring.cpp, _Session.cpp save/restore wiring; the predicate's old home deleted yes in scope
tests/ ×3 + tests.cmake two new targets, dbm_range_plausibility_test now includes the real predicate yes in scope
docs/HERMES.md, aetherd-icom-civ-backend-design.md prose for the two new tables yes in scope
docs/architecture/aetherd-touchpoint* one row for the new header, 237→238 yes in scope

Everything in the diff is explained by the stated fix. No CHANGELOG.md entry (correct — it must not have one). No new protocol verb, wire message, CLI flag or capability bool; the two new JSON fields are optional members of an existing schema-1 document, and panBinsAbsolute() is read, not added. The - lines are a clean removal: dbmRangeLooksPlausible moves to a header byte-for-byte (I diffed the bodies) and the deleted test comment is replaced by prose that is less flattering to the test, not more. No guard was dropped.

I re-ran python3 tools/gen_touchpoint_manifest.py --check on the head: "manifest up to date" — so the 238/199 count is real, not a stale post-merge number.

Socket-test entry (§2 disclosure). The PR adds no socket test. client_display_pan_settings_test constructs Hl2Backend, FlexBackend and IcomCivBackend purely to read capabilities(); grep for QTcpServer|QTcpSocket|QUdpSocket|QLocalServer|listen(|bind(|connectToHost|Fake over both new test sources returns nothing. Transitively, FlexBackend's constructor starts the PanadapterStream thread and PanadapterStream::init() does new QUdpSocket(this) — but the three bind() calls live at PanadapterStream.cpp:212/292/345 in start paths the test never reaches, so that is an inert socket object, not an opened one. The cited precedent noise_floor_auto_adjust_gate_test constructs the same three backends and the new test correctly copies its TestSettingsProfile-before-QCoreApplication guard. Nothing to flag.

3. Blockers

None. Two items below are routed to you rather than to the author, because neither is the author's to decide.

Needs maintainer decision

1. #6129 already adds dbmRanges to this same document, with an incompatible accept-set. (inline on ClientDisplaySettings.h:137) The author's "With the open PRs" section checks #5924 and #5919 but not #6129, which is open and persists the ANAN pan dBm range in the same ClientDisplay doc, same dbmRanges table, same min/max keys, same DbmRange struct and same dbmRange() / saveDbmRange() method names — with different bounds and different signatures:

#6129 this PR
accepts min < max, min ≥ -250, max ≤ 100 min ≥ -180, max ≤ 80, 10 ≤ max-min ≤ 180
signature dbmRange(scope, panIndex) dbmRange(scope, panIndex, clientOwned)

On an ANAN both are live: AnanBackend.cpp:392 sets binsAbsolute = true, so this PR's clientOwnsDbmRange() is engaged on exactly the radio #6129 targets. A range of -200 / -100 is valid for #6129's writer and silently rejected by this reader (min < -180 → nullopt → fall back to the built-in -130 / -40), i.e. the operator's scale "doesn't stick" — the bug both PRs exist to fix. The same-named members with different arities also mean the author's stated conflict strategy ("both add members at one place; keep both") cannot be applied here. Neither PR is wrong alone; the two accept-sets and one of the two signatures need reconciling before both land, and you may want to pick the sequencing.

2. The FFT FPS predicate, which the author already flagged for you. shapesDisplayRatesLocally() is m_backend != nullptr && m_flexBackend == nullptr (RadioModel.h:1333) — a family check, and it includes the Sim/demo backend, which the body's family list ("HL2, ANAN, Icom, RTL-SDR") omits. Worth weighing against docs/agents/settings.md:97-98 ("capability-shaped, never a family-name check") and the line just above it: "Flex and Sim declare explicitly empty — a CI test guards this". That sentence is scoped to RadioStateMemory / the OperatingState document, so it does not bind the ClientDisplay doc and this is not a violation — but the project's own sanctioned client-memory gate deliberately excludes Sim, while this gate includes it, and the PR widens that pre-existing waterfallRates seam to two more fields. The dBm range half is already capability-shaped (panBinsAbsolute()), which is why an Icom stays out. The author offered to move FPS behind a declared flag; your call.

4. Nits (non-blocking)

  • The adopt comment over-claims the echo. (inline, MainWindow_Wiring.cpp:4203) PanadapterModel::setRange emits levelChanged only when a bound actually changed (PanadapterModel.cpp:197-207), so when the operator re-requests the range the model already holds, no synthetic echo fires and the armed handshake sits to kDbmRangeHandshakeTimeoutMs. Not a regression — on main the HL2 handshake always times out, so this strictly improves it — but the comment reads as unconditional.
  • double→float narrowing happens before the finiteness check. (inline, ClientDisplaySettings.h:129) Credit: security-audit (CodeGuard), which I confirmed against the code. A stored {"min": -1e300} is narrowed out of float range (formally UB, [conv.double]) before dbmRangeLooksPlausible runs; in practice every target yields ±inf and std::isfinite rejects it, so nothing misbehaves today. One-line fix in the inline comment. Sanitizer-class, not functional.
  • The body's :3610 citation points at the wrong gate. sendDbmRangeCommand returns early on !radioOwnsDbmScale (MainWindow_Wiring.cpp:3716), and HL2 never declares that field so it defaults true (RadioCapabilities.h:390). The command is therefore attempted on an HL2 and dropped at the command plane, not suppressed by that gate. Effect is the same; the sentence isn't.
  • The source-text pin is brittle in two ways. (inline, client_display_pan_settings_test.cpp:242) count("scheduleClientFftFpsSave(") == 4 counts the definition alongside the three call sites, so a future comment that merely mentions the name turns the test red; and the adjacent ordering assertion compares two indexOf results, which passes vacuously if the first literal is absent (the neighbouring contains check covers that today, so it is fragility, not a hole).
  • Reset to Defaults saves FPS 25 beside the existing sw->setFftFps(25) and does not touch the dBm range — consistent with the waterfall-rate line next to it, and the asymmetry is disclosed.

5. What I tried to break, and could not

  • The stale-head trap. The provided checkout was at b1cdeab7 while the task listed 613f9c3 on top, so I fetched pull/6140/head from aethersdr/AetherSDR (the checkout's origin is a fork) and pinned the SHA: the real head is b1cdeab7, and git diff against the merge-base reproduces the posted diff exactly. Reviewed against that.
  • "The restore is defeated by a later re-seed." wirePanDisplayStatus restores FPS into the widget and then, after the shapesDisplayRatesLocally() block, unconditionally does sw->setFftFps(pan->fps()). I expected that to clobber the restore. It does not: RadioModel::requestPanDisplayRates calls pan->setDisplayRates(fps, wfRate) synchronously in the locally-shaped branch (RadioModel.cpp:6147), so pan->fps() already equals the restored value and the later line is a no-op.
  • Re-entrancy in the restore's flush(). DeferredSettingsWrites::flush() does std::exchange(m_pending, {}) before iterating, so a write scheduled from inside a flush lands in a fresh map. Safe. It does flush other pans'/fields' pending writes too, but each lambda captures its own scope, so they land on the right radio.
  • Radio-swap and disconnect inside the 250 ms window. Every scheduled lambda captures scope by value and the key is length-prefixed on both family and radioId (size() + ':' + value), so the encoding is injective and a swap cannot cross-write or collide. The three field names are distinct, which is the bug the key change fixes.
  • Document destruction. Both writers are read-modify-write on one table row, refuse version > 1 / Corrupt / Unavailable, and check setFeature's bool. I confirmed the test's fixture 6 proves an unknown fftAverages table (#6129/#5924's shape) survives both new writers and that the untouched waterfall writer preserves the new tables.
  • Tests that pass against the unfixed code. The refusal block would fail with the validators emptied (it asserts nullopt for a fractional FPS, an out-of-range FPS, a string FPS, an implausible range, a half range and a non-object), the ownership block reads panBinsAbsolute() off three real backends rather than restating constants, and the mutation table gives a distinct failing count per row — including 17 for "main's wiring", which is the right shape for a fail-before.
  • CI. All 5 checks are green on this exact head (build, check-macos, check-windows, Static checks, Sanitizer option configures), which also settles my one link concern: the new spectrum_overlay_fft_fps_bounds_test is not in AETHER_SETTINGS_CONSUMERS while the sibling it copies (spectrum_overlay_dax_availability_test) is — but that list only adds aether_sqlite3 for targets compiling the settings sources directly, and the new target links aethercore, so it builds on all three platforms. Noting it only because the author built locally on macOS alone. Standard caveat: ctest in ci.yml is -R-filtered, so green is not "the suite passed" — the 622/623 figure is the author's local run, which I cannot reproduce.

Everything above is reasoned from reading the code at b1cdeab7, not observed at runtime. I have no build, no test run and no way to drive the GUI, so the restore-on-restart behaviour the PR is named for is exactly the thing I could not verify — and the author says the same ("Not verified: the restore in a running app, on any radio").

6. Recommendation

Needs maintainer decision — on the #6129 collision above all, and secondarily on the FPS predicate shape the author explicitly put to you. The code itself is in good order: the model-as-single-source choice is what makes the six untouched re-seed sites correct for free, the store honours every bullet in docs/agents/settings.md "Radio-Scoped Feature Documents" (exact read, newer-schema refusal, checked write, empty-radio_id guard), validation runs on both the way in and the way out, and the test suite pins the refusals and the multi-table coexistence rather than restating the implementation. I found no blocker and no regression.

Concrete next step: rule on whether dbmRanges is #6129's or this PR's, since both cannot define dbmRange()/saveDbmRange() and both are live on an ANAN. Whichever lands second should take the first's accept-set rather than adding a second one. The four nits are all one-line changes and none of them need to gate a merge.

Nice work on the limits section — "not seen in a running app", the per-mutation failing counts, and flagging your own predicate as the thing you might have wrong are what made this reviewable.


🤖 aethersdr-agent · cost: $20.5682 · model: claude-opus-5

Comment thread src/core/ClientDisplaySettings.h
Comment thread src/core/ClientDisplaySettings.h Outdated
Comment thread src/gui/MainWindow_Wiring.cpp Outdated
Comment thread tests/client_display_pan_settings_test.cpp Outdated
on8st and others added 4 commits October 4, 2026 06:17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ple VIII.

PanadapterModel::setRange emits levelChanged only on a change, so a request
for the range the model already holds leaves the handshake to its timeout,
as on main. The comment said the echo always completes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iple VIII.

A stored double outside float's range was narrowed before the finiteness
test ran. dbmRange() now refuses it as a double. Results are unchanged for
every value a float holds, including one that rounds onto a bound.

The source-text pins match whole call statements instead of counting a
prefix that also matched the definition, and both order helpers require
both literals to be present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

The body missed #6129, and two of its sentences were wrong (the :3610 gate; Sim left out of the FPS families). All three are corrected there.

#6129, for your ruling. Read at bb459020:

#6129 this PR
key, shape dbmRanges[pan] = {min, max} same
accepts min < max, -250..100 -180..80, span 10..180
gate !radioOwnsDbmScale shaped locally and panBinsAbsolute()
families ANAN, Icom HL2, ANAN, RTL-SDR
restores into the widget the pan model

In 2D no drag emits a range this reader refuses: each passes clampDbmRange (SpectrumWidget.cpp:448, bounds :359-362, the predicate's own numbers). The 3D request path is unchecked.

Options: this PR takes #6129's set; #6129 takes this one's; one shared reader and writer; the second to land adapts. I would do the last. Reading the wider set here is the same choice, so nothing is changed.

The FPS predicate waits for your ruling too.

Nits, at ddb16104 (main at a8797baa merged): narrowing guarded; the comment was wrong and is corrected; pins rewritten. The narrowing has no runtime fail-before: it returned nullopt before too.

Full ctest 621 of 623: peripheral_auth_dialog_test (#6008) and vkamp_connection_test (#5254's two checks; it passed one commit earlier). Neither is this PR's.

73, ON8ST

main gained aethersdr#5924, which adds the fftAverages table to the ClientDisplay
document. Three keep-both conflicts:

- ClientDisplaySettings.h: main's FftAverage, fftAverage, saveFftAverage
  and decodeAverage stay as they are, beside this branch's fftFps and
  dbmRanges members; one private section holds both sides' helpers. The
  class comment now names fftAverages as well.
- MainWindow.h: scheduleClientFftAverageSave beside this branch's four
  declarations.
- MainWindow_Wiring.cpp: scheduleClientFftAverageSave after this branch's
  four functions.

The pending-write keys stay distinct (main's starts with "fft:", this
branch's end in the field name). Touchpoint manifest regenerated: 238,
unchanged by the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

The full ctest is not clean on this Mac: at 295bff6e 643 of 645 pass, and the two failures are peripheral_auth_dialog_test (#6008; diagnostic on #6134) and vkamp_connection_test (#5254, its two known checks); two more, hl2_receiver_count_restart_paced_test and libre_radar_test (a timeout), failed at -j8 and passed when rerun alone. #5924 landed and this PR conflicted with it in three files, not the one the body predicted: ClientDisplaySettings.h, MainWindow.h and MainWindow_Wiring.cpp. Merged main at a325fb99 and kept both sides in each: #5924's fftAverages members and scheduleClientFftAverageSave are untouched, with fftFps and dbmRanges beside them; the class comment now names all four tables. The body's test line and its #5924/#5919 bullet are updated to match. The touchpoint manifest was regenerated with the tool (238, --check up to date) and the other static checks pass. Nothing about #6129 or the FPS predicate is changed; both still wait for your ruling.

73, ON8ST

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue fit: The proposed save/restore path addresses the reported FPS/range loss, with an ownership-contract blocker. At 295bff6eb54609512b04ea1bd0244acd3cd45f22, FPS is saved on slider, clone and reset intents and restored before rate shaping; the dBm range is restored into the pan model before widget priming. The PR references #5678 rather than closing a dedicated issue. Its overlap with #6129 needs the disclosed maintainer decision before both designs can land.

Scope

File/group Claimed? Verdict
ClientDisplaySettings.h Yes: optional fftFps/dbmRanges tables and distinct pending-write keys In scope; eligibility issue below
DbmRangePlausibility.h Yes: share the existing production predicate Relevant extraction; limits unchanged
MainWindow.h, _Wiring.cpp, _Session.cpp Yes: save/restore and model adoption Relevant operator-intent and reconnect wiring
Three test sources and tests/tests.cmake Yes: settings, slider bounds and real plausibility helper Relevant coverage; isolation gap below
HERMES/Icom documentation and touchpoint tags/manifest Yes: ownership, tables and new helper include Explained support; manifest regenerated consistently

No unrelated wire verb, default change, dependency or changelog entry was found. The authored commits are implementation/tests plus integration and manifest maintenance. The scope checklist is accurate. The two persistent JSON fields are a new storage contract even though they extend an existing schema-1 document.

Blocker

  1. The new persistent fields must use declared ownership. src/gui/MainWindow_Wiring.cpp:3138 enables FPS persistence through shapesDisplayRatesLocally(), and :3153-3156 adds only absolute-bin semantics for the dBm range. The underlying predicate at src/models/RadioModel.h:1341 is m_backend != nullptr && m_flexBackend == nullptr. Neither backend identity nor bin units declare whether this radio stores these settings itself.

    The precise requirement in docs/agents/settings.md, Settings Authority Policy, is: “The deciding test is whether THIS radio can save and restore the value — a declared capability, not a family assumption.” Reusing the older waterfall-rate exception expands that exception to two more fields; disclosing it as “you may want it changed” does not satisfy the rule. The adjacent FFT-averaging path already requires the declared clientPersistsAveraging field in addition to local shaping (MainWindow_Wiring.cpp:3201-3205).

    Keep the ClientDisplay document and its atomic whole-document writes, but introduce/use declared per-feature ownership for FPS and range, apply it consistently to save and restore, and test both opt-in and opt-out. Follow the capability-record contract instead of adding a frozen boolean. This finding is about the new contract, not an observed clobber of a currently supported radio.

Non-blocking nits / maintainer decisions

  • Jeremy: reconcile #6129 before landing both storage implementations. src/core/ClientDisplaySettings.h:171 reads the same dbmRanges[pan] = {min,max} namespace with different ownership and validity rules than #6129. The body now states that conflict honestly. Choose one accept-set/ownership contract and preserve valid existing documents when integrating; “keep both” is not a coherent resolution.
  • tests/spectrum_overlay_fft_fps_bounds_test.cpp:18-20 constructs SpectrumOverlayMenu without TestSettingsProfile. Its constructor reaches ThemeManager, then AppSettings, which creates the normal configuration directory and invokes legacy-path migration. This is a widget test with settings side effects, not an inert header-only test. Add the same profile guard used by the neighboring settings test before QApplication, add tests to its include directories, and add the target to AETHER_SETTINGS_CONSUMERS as docs/agents/tests-ci.md requires. I did not run it against the operator's profile or establish that it overwrites the SQLite store.
      TestSettingsProfile profile(QStringLiteral("spectrum-overlay-fft-fps-bounds"));
      if (!profile.isValid()) {
          return 1;
      }
      QApplication app(argc, argv);
    Also include "TestSettingsProfile.h".
  • docs/HERMES.md:2196's new “FFT AVG and Wt Avg are not remembered” sentence is stale after the merged #5924 code present in this head: scheduleClientFftAverageSave uses its declared persistence flag. Describe which settings this PR adds without claiming the averaging feature is still absent.

Verification and attacks

  • Built the exact PR head's full AetherSDR application and all three new CMake test targets with GCC 16.2 / Qt 6.12.0. client_display_pan_settings_test, spectrum_overlay_fft_fps_bounds_test, and dbm_range_plausibility_test all returned 0. The widget target ran only under an external private settings/config profile because of the isolation nit above.
  • Attacked the actual slider contract by changing only kFftFpsMax from 60 to 61 in the scratch header: the new widget test returned 1 with FAIL: slider maximum 60 is kFftFpsMax. Restored the header, rebuilt, and the test returned 0. Separately, the settings test's store/coalescing portion, linked to the production settings implementation under native Qt 6.11.2 (omitting only the backend-construction capability block), failed twice when the pending-write key lost its field discriminator; it passed before and after restoration. The final exact CMake test above includes the omitted backend block.
  • Drove exact-head demo processes 2208948 and 2209640, offscreen with isolated settings and AETHER_AUTOMATION_NO_TX=1. Opened panMenuDisplayBtn, set the real displayFftFpsSlider to 12, and get pan returned "fps":12. Quit, launched a new process using the same private profile, explicitly connected DEMO-0001, and again received "fps":12. get radio confirmed "model":"AetherSDR Demo", "serial":"DEMO-0001", "transmitting":false. Both review instances were closed; no live radio was connected.
  • A separate writer/reader process probe using the exact production settings implementation retained {"dbmRanges":{"0":{"max":-2.190000057220459,"min":-122.19000244140625}},"fftFps":{"0":12}} across process exit. The new settings test also exercises invalid values, distinct field writes, and preservation of unreadable/newer documents. Demo does not advertise absolute pan bins, so I could not drive its dBm restore path; that backend-specific GUI wiring was inspected, not reproduced. No exact merge-base GUI run or hardware/cross-platform run is claimed.

The four architecture gates pass at both merge base and head. Comparing per-file findings shows the same 102 tracked engine warnings; the new helper changes only the scanned-file count, and its touchpoint row is explained in the body. No new socket-owning test was found; backend objects in the settings test are only constructed to read capabilities. No model-invocable code-review skill was available; this was a manual audit plus local validation.

Required CI checks succeeded, but earliest head-run creation 2026-10-04 07:01:23 UTC is older than current main 85f2a124 (22:40:53 UTC). Those pull_request jobs tested an older merge result; protection is strict: false. No complete-current-main-suite claim is made.

Recommendation: Request changes for declared persistence ownership, followed by a maintainer ruling on the overlap with #6129. The focused tests support the store/coalescing implementation; they do not authorize widening persistence by backend identity.

— Jeremy KK7GWY + Codex

@ten9876 ten9876 removed their assignment Oct 4, 2026
on8st and others added 3 commits October 5, 2026 01:16
The touchpoint manifest conflicted (both sides added rows and wrote the
Totals line); it is regenerated with tools/gen_touchpoint_manifest.py
after the merge, not edited by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpectrumOverlayMenu reaches ThemeManager and then AppSettings from its
constructor, so the widget test opened the normal configuration
directory. It now takes a TestSettingsProfile before QApplication, has
tests/ on its include path and is listed in AETHER_SETTINGS_CONSUMERS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Storing and restoring a pan FFT FPS and dBm range was gated on
RadioModel::shapesDisplayRatesLocally(), which is backend identity, and
for the range on absolute bins. Both now ask a declared owner in a
per-feature capability record, as BackendPanAveraging does for averaging:

- PanFrameRateShaping (new record), clientPersistsFrameRate, read through
  RadioCapabilities::clientPersistsPanFrameRate();
- PanAmplitudeModel::clientPersistsDbmRange, read through
  RadioCapabilities::clientPersistsDbmRange(), which also requires
  absolute bins because the client writes the range into the pan model.

Only the Hermes-Lite 2 declares either. Flex, Icom, ANAN, RTL-SDR and the
demo backend keep the behaviour of main: nothing stored, nothing
restored. That is narrower than before, when every non-Flex backend was
covered. Save and restore of each field go through one MainWindow
predicate. The ClientDisplay document, its schema and its whole-document
writes are unchanged, and the waterfall-rate gate is not touched.

Tests read the accessors off real HL2, Icom, ANAN and Flex capabilities
against the real store. docs/HERMES.md no longer says FFT AVG and Wt Avg
have no store: they have one behind clientPersistsAveraging, which the
HL2 declares false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Three things first, none of them good news:

  • This PR is now narrower than the one you reviewed. Only the Hermes-Lite 2 declares the client as owner of FFT FPS and of the dBm range. Icom, ANAN, RTL-SDR and the demo backend are back to what main does: nothing stored, nothing restored. Your demo run will not reproduce at this head: the demo backend declares no owner, so by the code DEMO-0001 set to 12 fps comes back at the widget's 25. I did not run it.
  • The restore has still not been seen in a running app at this head, on any radio. No HL2 was connected for this change.
  • The full ctest is not clean on this Mac: 644 of 646 at -j8. Details under Tests.

The blocker: declared ownership

Done in 7a5ca748, following BackendPanAveraging::clientPersistsAveraging. No new top-level bool: check_capability_records.py --strict reports 70 boolean(s), at the frozen 70.

setting record field accessor (the one read by save and restore)
FFT FPS PanFrameRateShaping, new, optional clientPersistsFrameRate, no default RadioCapabilities::clientPersistsPanFrameRate()
dBm range PanAmplitudeModel, existing clientPersistsDbmRange, default false RadioCapabilities::clientPersistsDbmRange()

No record described the frame rate, so FFT FPS gets one small record of its own. The range's owner sits in PanAmplitudeModel, the record that already describes the dBm axis.

family panFrameRateShaping clientPersistsDbmRange
Hermes-Lite 2 engaged, true true
Flex absent (the radio stores and reports it) record absent
Icom absent record absent
ANAN absent false
RTL-SDR absent false
Demo absent record absent

All six backends state the frame-rate record in their own capabilities(); the three that engage PanAmplitudeModel state the range field. Widening to another family is one declaration in that family's backend, for its owner or for you to make.

In MainWindow each field has one predicate, used by its save and its restore: clientPersistsFftFps() (the slider, Clone to all Pans, Reset to Defaults, and the restore in wirePanDisplayStatus) and clientOwnsPanDbmRange() (adopt-and-save, and the restore at both prime sites). ClientDisplaySettings::clientOwnsDbmRange(shapedLocally, panBinsAbsolute) is removed.

Two things you may want changed:

  • shapesDisplayRatesLocally() is still a second condition for FFT FPS, as it is in the averaging path you pointed at: the restore sits in the block that seeds the local shaper. It no longer grants anything by itself. The range no longer reads it at all. The waterfall-rate gate on main is not touched.
  • Absolute bins stay a second condition for the range, inside the accessor. The client writes the operator's range into the pan model. On a radio whose bins are scaled by that range, or whose backend publishes its own, that write would change what the display means. So a declaration over relative bins owns nothing. PanAmplitudeModel's other two fields have defaults, so the new one has a default too (false); the new record's field has none, as in BackendPanAveraging.

The ClientDisplay document, its schema 1, the two tables and the read-modify-write on the whole document are unchanged.

#6129

With the opt-in on the HL2 only, this PR no longer acts on an ANAN or an Icom. #6129 at 04762258 gates on !radioOwnsDbmScale (ANAN and Icom), which is false on an HL2. So no radio is claimed by both any more. What is left is that both write dbmRanges[pan] = {min, max} with different rules:

#6129 at 04762258 this PR at 7a5ca748
who stores and restores !radioOwnsDbmScale: ANAN, Icom declared owner with absolute bins: HL2
accepted on write min < max, min >= -250, max <= 100 finite, min >= -180, max <= 80, 10 <= max - min <= 180
accepted on read narrowed to float, then the same test each double must fit a float, then the same test as on write
saved on drag finished scale request and drag finished; not FFT Floor Auto's moves
restored into the widget the pan model, before the widget is primed
signatures dbmRange(scope, pan), saveDbmRange(scope, pan, min, max) the same names with a clientOwned argument

A range such as -200 / -100, or a 5 dB span, is valid there and refused here. A trial merge of the two heads conflicts in MainWindow_Wiring.cpp and the two touchpoint files. ClientDisplaySettings.h merges as text and then holds both definitions of DbmRange, dbmRange() and saveDbmRange(); I did not compile that. Nothing is reconciled in code here. This PR adopts whichever accept-set you rule.

Nits

  • Settings isolation (19e4e193): spectrum_overlay_fft_fps_bounds_test takes TestSettingsProfile before QApplication as you wrote it, has tests on its include path and is in AETHER_SETTINGS_CONSUMERS. The other two tests of this PR: client_display_pan_settings_test already had the profile and the list entry; dbm_range_plausibility_test constructs no application object, no backend and no settings.
  • docs/HERMES.md: the sentence now says FFT AVG and Wt Avg have their own table behind clientPersistsAveraging, which the HL2 declares false, so they are not remembered on this radio.

Merge

The PR conflicted with main. Merged 85f2a124 in 57a6f01f; the one conflict was the touchpoint manifest, regenerated with the tool after the merge (240 headers, --check up to date). Nothing main changed in this PR's files touches this code.

Tests at 7a5ca748

macOS arm64, Qt 6.12.0. Full ctest: 644 of 646 pass at -j8. vkamp_connection_test (#5254) failed at -j8 and passed alone. peripheral_auth_dialog_test (#6008) fails alone too, as it did on clean main at 22507580 on this Mac (the diagnostic is on #6134); clean main at 85f2a124 was not built.

client_display_pan_settings_test reads both accessors off real Hl2Backend, IcomCivBackend, AnanBackend and FlexBackend capabilities, against the real store: the HL2 stores and restores both; the other three, and an undeclared backend, write nothing, read nothing from a document that holds both rows, and leave that document byte for byte. ANAN is the row that separates a declaration from absolute bins.

Two mutations, one build and one run each, never pushed:

mutation failing checks
both accessors return true for every backend 33 (8 each for Icom, ANAN, Flex and the undeclared backend, and the relative-bins check)
both accessors return false for every backend 8 (7 HL2, and the absolute-bins control)

Static checks: every script step passes with stdin from /dev/null; colour ratchet +0; check_a11y.py on the three changed GUI files, no findings.

Not verified

  • The restore in a running app, on any radio, at this head.
  • Linux and Windows: built and tested on macOS only.
  • The Sanitizer option configures job was not run locally.
  • The older rows of the body's mutation table were measured at 295bff6e and not run again.

73, ON8ST

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 7a5ca748, which arrived while my review of 295bff6e was in progress. Everything below is against 7a5ca748.

Issue fit: yes, against its own stated intent (Refs #5678 rows P16 and the FPS half of P7; no issue is linked). On a Hermes-Lite 2, FFT FPS and the dBm range are now stored in, and restored from, the per-radio ClientDisplay document. Each sits behind a declared owner in a per-feature capability record. Every other family keeps main's behaviour: nothing stored, nothing restored.

Scope

File / group Change Claimed? Verdict
src/core/backends/RadioCapabilities.h:100-108, 150-154, 263, 284-298 new optional PanFrameRateShaping{clientPersistsFrameRate} record; PanAmplitudeModel::clientPersistsDbmRange (default false); two accessors Yes (the reply to the previous review) In scope. New capability surface. No new top-level bool: check_capability_records.py --strict → "70 boolean(s), at the frozen 70"
Six backends' capabilities() HL2 engages both; Flex/Icom/ANAN/RTL/Sim state absent or false explicitly Yes In scope, per the ADDING A FIELD contract (RadioCapabilities.h:228-231)
docs/architecture/radio-capabilities-map.md:189, 559, 593 both fields recorded Yes In scope
src/core/ClientDisplaySettings.h fftFps[pan], dbmRanges[pan], per-field pendingWriteKey Yes In scope
src/core/DbmRangePlausibility.h (new) predicate moved unchanged out of MainWindow_Wiring.cpp Yes In scope
src/gui/MainWindow.h, _Wiring.cpp, _Session.cpp save, restore and adopt, each behind one predicate Yes In scope
3 tests + tests/tests.cmake store and ownership, slider bounds, the real predicate Yes In scope
docs/HERMES.md, Icom design doc, touchpoint tags and manifest prose plus one manifest row Yes In scope
8 merge: origin/main commits, latest 57a6f01f (85f2a124) keep-both resolutions; manifest regenerated Yes Fine

No CHANGELOG.md entry. Socket preflight: client_display_pan_settings_test constructs the HL2, Icom, ANAN and Flex backends only to read capabilities(). Their sockets are never bound.

Blockers

None. The previous review's blocker, ownership inferred from "not a Flex", is resolved in 7a5ca748, and I verified the fix rather than taking it from the reply (see below).

Needs maintainer decision (@ten9876)

  1. #6129 and this PR still both define dbmRanges[pan] = {min, max} in the same document, with the same dbmRange()/saveDbmRange() names, different arities and different accept-sets:

    Accepts
    this PR −180..80, span 10..180
    #6129 min < max, −250..100

    They no longer claim the same radio: this PR covers HL2 only, and #6129 covers !radioOwnsDbmScale (ANAN, Icom). The author reports, without compiling it, that a textual merge leaves both definitions in ClientDisplaySettings.h. Whichever lands second will not build until the two are reconciled. Rule on one accept-set and one owner of the helpers; the author has said this PR will adopt whichever you choose.

  2. The new capability surface. Two persistence-owner declarations (panFrameRateShaping, PanAmplitudeModel::clientPersistsDbmRange) follow the BackendPanAveraging::clientPersistsAveraging shape. Because they are third-party-visible capability fields, they need your sign-off, though the shape is the one canon prescribes. shapesDisplayRatesLocally() remains a second condition for FFT FPS, as in the averaging path, and no longer grants anything on its own.

Nits (non-blocking)

  • 3D emit not marked as auto-floor. src/gui/SpectrumWidget.cpp:3781: the 3D DSS zoom-floor emit of dbmRangeChangeRequested is not flagged as an auto-floor change (pendingAutoNoiseFloorDbmRange()). On an HL2 in 3D it would therefore be adopted and stored like an operator edit (MainWindow_Wiring.cpp, the adoptClientOwnedDbmRange call after sendDbmRangeCommand). The 2D auto-floor path is correctly excluded: autoFloorChange → localOnly → return before adopt. The author calls the 3D path "unchecked", so either a guard or a sentence in Limits.
  • Untested sibling. The waterfall-rate save/restore on main still uses the bare shapesDisplayRatesLocally() gate. That is out of scope here, and correctly untouched, but it is now the odd one out: worth a follow-up issue.

What I tried to break / verified

  • Built this head's app and all three targets (Linux, Qt 6.12.0; main 85f2a124 is in the branch). client_display_pan_settings_test, dbm_range_plausibility_test and spectrum_overlay_fft_fps_bounds_test all exit 0.

  • Static gates at this head:

    • check_capability_records.py --strict → 70 booleans, at the frozen 70;
    • gen_touchpoint_manifest.py --check → "manifest up to date";
    • check_command_plane.py → 369, baseline 369;
    • check_engine_boundary.py → 102 findings, 0 would block;
    • git merge-tree against main → clean (the earlier manifest conflict is gone).
  • Mutation: HL2 PanFrameRateShaping{true} → {false} makes client_display_pan_settings_test exit 1 with "FAIL: hl2: FFT FPS owner as declared", "…stored and restored only by its owner", "…read only by its owner". The file was restored (sha256 matches git show HEAD:) and touched.

  • Settings isolation fixed: with a scratch HOME/XDG_CONFIG_HOME, spectrum_overlay_fft_fps_bounds_test now creates 0 files there. At 295bff6e, the same run created $HOME/.config/AetherSDR.

  • Bridge, demo only, opt-out proven across a relaunch: isolated settings, AETHER_AUTOMATION_NO_TX=1, socket /tmp/aethersdr-pr6140. connect local serial DEMO-0001 → "model": "AetherSDR Demo", "serial": "DEMO-0001".

    Step get pan / store
    invoke displayFftFpsSlider setValue 12 "fps": 12
    SQLite store after kill -TERM no ClientDisplay row
    relaunch, reconnect DEMO-0001 "fps": 25

    At 295bff6e the identical sequence stored sim|DEMO-0001|ClientDisplay|1|{"fftFps":{"0":12}} and restored 12. The declaration is doing what the author says, and the demo now does what main does.

  • Could not drive the opt-in (HL2) restore: the demo cannot reach it and there is no HL2 here. That half rests on the unit tests and on reading the code; the author says the same.

  • Earlier attacks that still hold at this head:

    • 2D auto-floor moves are never adopted;
    • the restore is not overwritten by the later sw->setFftFps(pan->fps()), because requestPanDisplayRates sets the model synchronously on the local path;
    • all eight setDbmRange(pan->minDbm(), pan->maxDbm()) re-seed sites read the restored model.

Recommendation: no code blocker remains. Needs your ruling on the #6129 dbmRanges overlap and your sign-off on the two capability fields.

— Jeremy KK7GWY + Claude

@ten9876

ten9876 commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Maintainer ruling: this PR owns dbmRanges[pan] and the dbmRange()/saveDbmRange() helpers in ClientDisplaySettings, along with its accepted range. #6129 will rebase onto this version.

— Jeremy KK7GWY + Claude

The 3D (DSS) floor resync after a bandwidth zoom requests a dBm range
computed from the first trusted post-zoom frame. It is not an operator
edit of the scale, so MainWindow no longer adopts it into the pan model
or stores it in ClientDisplay on a backend that declares
clientPersistsDbmRange (the Hermes-Lite 2).

SpectrumWidget marks that one request while it emits it; MainWindow
reads the mark only at the adopt. The handshake, the stream decoder
range and the display pan set command are unchanged, so a Flex sends
the same wire text. The 2D auto-floor mark is not reused: it routes a
request to the local-only branch, which sends nothing.

client_display_pan_settings_test pins the mark, its single reader and
the 2D return-before-adopt as source text: SpectrumWidget and
MainWindow link into no test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

The nit holds, and it is a guard, not a Limits sentence (dabf83e3). The 3D site is the floor resync after a bandwidth zoom: once the zoom settles, the first trusted frame's measured floor sets a new range (up to four bounded steps). The zoom is the operator's; the range is not. On an HL2 in 3D it was adopted into the pan model and stored like an edit.
Changed: SpectrumWidget marks that one request while it emits it, and MainWindow reads the mark only at the adopt. The 2D mark (pendingAutoNoiseFloorDbmRange()) is not reused: it routes to the local-only branch, which sends nothing, and that would change a Flex. Handshake, decoder range and display pan set … min_dbm= max_dbm= are untouched, so a Flex sends the same text; on an HL2 this request now does what it does on main.
Test: client_display_pan_settings_test pins the mark, its single reader, send-before-read and the 2D return-before-adopt. Limit: as source text, because SpectrumWidget and MainWindow link into no test; the 3D path was not driven with frames, and not seen in a running app. Mutation (adopt unconditionally again): three of those checks fail.
Other movers, read only: the scale arrows and both drag releases are operator gestures and adopt; the 2D auto-floor and the headroom recovery never reach the adopt; Clone to all Pans and Reset to Defaults do not set a range; the band-stack restore sends the range as a Flex command, which an HL2 drops, so it is neither adopted nor stored there (as on main; a gap, not fixed here).
Side effect: SpectrumWidget.h is now a changed file, so the advisory a11y step reports two findings on lines this PR does not touch (the zoom band/segment buttons, present on main).
macOS, Qt 6.12.0, dabf83e3: 645 of 646 pass; peripheral_auth_dialog_test (#6008) fails, also serially, as on the previous head. Static checks all exit 0.
The body's "Not verified" line for the 3D view and its update paragraph say this now, and record your ruling on dbmRanges.

73, ON8ST

One conflict, docs/architecture/radio-capabilities-map.md, in the dBm-axis
table: main rewrote the Icom cell of the squelchLevelScale row for the
IC-7300MK2's measured map (aethersdr#6180), and this branch added the
clientPersistsDbmRange() row directly above it. Both kept: this branch's
row unchanged, then main's squelchLevelScale row as main has it. Main's
IC-7300MK2 paragraph merged as text.

IcomCivBackend.cpp merged without conflict: main changed the
squelchLevelScale assignment and the preamp/ATT reads, this branch one
panFrameRateShaping line earlier in capabilities(). Main did not touch
RadioCapabilities.h or PanAmplitudeModel.

Touchpoint manifest regenerated after the merge: 240 headers, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

The PR conflicted when the two Icom fixes landed (9281d1ec, 12df6943): docs/architecture/radio-capabilities-map.md, one hunk, where main rewrote the Icom cell of the squelchLevelScale row and this branch had added the clientPersistsDbmRange() row directly above it. Merged main at 12df6943 in d60f4660 and kept both: this PR's row unchanged, then main's row as main has it; nothing main changed touches PanAmplitudeModel, RadioCapabilities.h or the Icom range path, and the touchpoint manifest regenerates to the same 240. macOS, Qt 6.12.0, d60f4660: 645 of 646 pass at -j8; peripheral_auth_dialog_test (#6008) fails, also serially, as on the previous head (clean main at 12df6943 was not built here). Static checks all exit 0, check_capability_records.py --strict at the frozen 70.

73, ON8ST

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head d60f4660 (main 12df6943 merged in), and again as a merge with current main 5f09371e. Verdict: APPROVE. I found no blocker. Merge is gated by something other than code, see "What happens next".

Thank you for the narrowing. The change I would have asked for after reading the two maintainer reviews is the one you made: the backend declares the owner, the save and the restore of each setting read the one accessor, and every family that declares nothing keeps main's behaviour. The "Limits, first" section is accurate, and the "Not verified" list matched what I could and could not reproduce.

Scope

File / group Claimed? Verdict
core/ClientDisplaySettings.h (fftFps, dbmRanges, per-field pendingWriteKey) yes in scope
core/DbmRangePlausibility.h (predicate moved out of MainWindow_Wiring.cpp, plus dbmNarrowsToFloat) yes in scope
RadioCapabilities.h + six capabilities() yes in scope. New third-party-visible fields, same shape as clientPersistsAveraging; no new top-level bool
MainWindow.h, _Wiring.cpp, _Session.cpp, SpectrumWidget.{h,cpp} yes in scope. The SpectrumWidget change is one flag set around one existing emit
3 tests + tests.cmake yes in scope. tests/ is Tier 3
docs and touchpoint manifest yes in scope

No CHANGELOG.md entry, no wire verb, no default changed. Deleted lines: only the predicate that moved to the header (bodies identical) and the hand copy in the test. Highest CODEOWNERS tier touched is Tier 3 (src/, tests/, docs/ outside docs/agents/).

Fix, not preference. It restores a defect observed on an HL2 (FPS 12 came back 25; a 120 dB scale came back -40 / 90 dB). Nothing about a default, a layout or a workflow changes, and a Flex is unchanged by construction: clientPersistsPanFrameRate() and clientPersistsDbmRange() are false for it, and every new reader returns nothing and every new writer returns before the store.

#5554 backend gate: none of the six patterns is added: no new family-string or command-plane branch, no raw Flex wire text, no dynamic_cast to a backend, no capability without behaviour behind it, no HL2 scaffolding copy, no keying verb.

What I tried to break

Build: MinGW, Qt 6.12.0, RelWithDebInfo, the PR merged with main 5f09371e (clean git merge-tree).

  • Static gates on that merge result: gen_touchpoint_manifest.py --check up to date; check_capability_records.py --strict 70, at the frozen 70; check_command_plane.py 369 = baseline; check_test_registration.py, check_ci_test_gate.py, check_qsettings.py, check_network_timeouts.py ok; check_engine_boundary.py 102 findings, 0 would block. Required CI ran on the merge ref at 2026-10-05 02:52Z; main has moved 9 commits since, so I ran the above rather than rely on it.
  • Tests: dbm_range_plausibility_test and spectrum_overlay_fft_fps_bounds_test pass. client_display_pan_settings_test fails 6 checks on this machine, all multi-line source-text pins (see the first nit). With the four source files converted to LF it passes, so the cause is line endings, not behaviour.
  • Mutations, one build each: the pending-write key without its field: +2 failures over the 6 line-ending ones, matching your table. Owner ignored in the store's read and write: +26. The tests notice.
  • Driven through the real MainWindow, demo only. The demo declares no owner, so I patched SimBackend::capabilities() locally and uncommitted to declare what the HL2 declares (PanFrameRateShaping{true}, and a PanAmplitudeModel with binsAbsolute and clientPersistsDbmRange). Isolated AETHER_SETTINGS_DIR, AETHER_AUTOMATION_NO_TX=1, connect local serial DEMO-0001 → "model": "AetherSDR Demo", "transmitting": false.
    • Baseline get pan: fps 25, -130 / -40.
    • invoke displayFftFpsSlider setValue 12, then two clickAt SpectrumWidget 1110 5 on the dBm scale's up arrow: fps 12, minDbm -130, maxDbm -20.
    • Closed through titlebar close. The store held sim|DEMO-0001|ClientDisplay|1|{"dbmRanges":{"0":{"max":-20,"min":-130}},"fftFps":{"0":12}}.
    • Relaunched on the same store, reconnected DEMO-0001: fps 12, minDbm -130, maxDbm -20, and the Display panel's displayFftFpsSlider reads 12.
    • What this proves: the save sites, the flush on quit, and both restores work through the real wiring, across a process boundary, on Windows. What it does not: it is the demo with an HL2-shaped declaration, not an HL2. The demo vends a RadioConnection and may echo a range itself, so the model values alone prove little; the store row, which only this PR writes, and the restore after relaunch are the evidence.
  • Read hostilely, and it held:
    • The slider's programmatic syncs (SpectrumOverlayMenu.cpp) use QSignalBlocker, so a restore cannot re-emit fftFpsChanged and re-save.
    • The slider save sits before the adaptive-throttle return.
    • restoreClientOwnedDbmRange flushes pending writes first, so a deferred edit is not read back stale.
    • The 3D floor resync mark is set only around the one emit (SpectrumWidget.cpp:3781) and the adopt skips it; the 2D auto-floor returns at localOnly before the adopt.
    • Out-of-range, fractional, non-finite and wrong-typed stored values are all refused through the real store.
  • Not done: an HL2 on the air; Linux; 3D with live frames; Reset to Defaults and Clone to all Pans (pinned as source text only); the older rows of your fail-before table.

Collateral: the maintainer's blocker (ownership by backend identity) is resolved at 7a5ca748, and I checked it myself against real backend objects and the mutation above, not from the reply. His 3D-emit nit is resolved at dabf83e3. The bot's nits were fixed in 39cf03bb and e60d1daf. The #6129 overlap is settled by his 2026-10-05 ruling that this PR owns dbmRanges[pan] and its helpers.

Non-blocking, for the future

  • Windows checkouts with core.autocrlf=true: readSource() in client_display_pan_settings_test.cpp does not normalise \r\n, so the multi-line pins fail there. CI checks out LF and is unaffected. QString::fromUtf8(file.readAll()).remove(QLatin1Char('\r')) fixes it; other source-text tests share the exposure.
  • Squash citation: the change implements Principle III (the client persists only state the radio does not store, decided by what this radio can save and restore), and the review turned on exactly that. I suggest III on the squash subject over VIII.
  • docs/agents/settings.md, Settings Authority Policy: it says an HL2's client memory is persisted "ONLY through RadioStateMemory's OperatingState document". The waterfall rate, FFT AVG and now this live in ClientDisplay, which the maintainer asked you to keep. That is a docs follow-up for whoever owns docs/agents/, not something for this PR.
  • No issue is filed for the use case; Refs #5678 and the observed HL2 repro carry it, which is enough here.

What happens next

No code blocker remains. I am approving the code. I am deliberately not merging, and not trying to clear the standing review. That is our reasoning, and these are the three parts of it:

  1. The CHANGES_REQUESTED is his, and it is still active. It was filed 2026-10-04 against 295bff6e; reviewDecision is still CHANGES_REQUESTED. Its one blocker (ownership by backend identity) was fixed in 7a5ca748, and his own 2026-10-05 review at that head says "Blockers: None". Its three non-blocking items are also done (the TestSettingsProfile isolation, the stale HERMES.md sentence, the #6129 ruling). But he followed it with a COMMENTED review, not an approval or a dismissal, so whether it still stands is his to say. Our approval does not clear it and we should not route around it.
  2. His 2026-10-05 review names him for a decision I cannot find on the PR. Under "Needs maintainer decision (@ten9876)", item 2: the two new third-party-visible capability fields (PanFrameRateShaping::clientPersistsFrameRate, PanAmplitudeModel::clientPersistsDbmRange) "need your sign-off". His 2026-10-05 00:30 ruling covers dbmRanges ownership only. I found no ruling on the capability fields.
  3. The HL2 restore has not been seen on an HL2, by anyone. My drive was the demo with an HL2-shaped declaration.

@ten9876, two explicit questions for you, since you asked for them:

  • Do you sign off on the two capability fields? I read them against the BackendPanAveraging::clientPersistsAveraging shape and the frozen count of 70, and they match.
  • Does your 2026-10-05 review at 7a5ca748 stand in place of your 2026-10-04 CHANGES_REQUESTED? If so, please dismiss it or re-review so the PR can merge. If you want anything further first, please say what.

Until you answer, this stays unmerged.

Suggested squash subject: fix(gui): FFT FPS and the dBm range survive a restart on the Hermes-Lite 2. Principle III. (#6140)

73,


👨🏼‍💻 Co-authored by Claude Sonnet 5.5

@NF0T NF0T removed their assignment Oct 6, 2026

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line references are at the PR head. Reviewed at head d60f4660, and as a merge of that head with current main 5f09371e. The merge is clean, and it brings in the 52 px title bar and the -g1/test-source build changes, both of which touch MainWindow. Everything I ran, I ran on that merge.

Issue fit: yes, against the PR's own stated intent. No issue is linked; it Refs #5678 (rows P16 and the FPS half of P7). On a Hermes-Lite 2, FFT FPS and the dBm range are stored in the per-radio ClientDisplay document and restored from it. Each sits behind an owner the backend declares, and each declaration has one accessor that both the save and the restore read. Every other family keeps main's behaviour. The earlier blocker (ownership inferred from "not a Flex") is resolved; I checked the six capabilities() declarations myself rather than taking the reply's word for it.

Scope

File / group Claimed? Verdict
src/core/ClientDisplaySettings.h: fftFps, dbmRanges, per-field pendingWriteKey yes in scope
src/core/DbmRangePlausibility.h (new): predicate moved verbatim from MainWindow_Wiring.cpp, plus dbmNarrowsToFloat yes in scope; the - side of the move is byte-identical
src/core/backends/RadioCapabilities.h + six backends' capabilities() yes in scope; new third-party-visible capability surface (see below)
src/gui/MainWindow.h, MainWindow_Wiring.cpp, MainWindow_Session.cpp yes in scope
src/gui/SpectrumWidget.{h,cpp}: one flag set around the 3D zoom-floor emit yes (update block, dabf83e3) in scope
3 tests + tests/tests.cmake (both new targets in AETHER_SETTINGS_CONSUMERS) yes in scope
docs/HERMES.md, the Icom design doc, the caps map, touchpoint tags and manifest yes in scope
13 merge: origin/main / manifest-regen commits yes fine

Everything in the diff is explained by the body. There is no CHANGELOG.md entry, no wire verb, and no changed default. The only removed code is the predicate that moved to the header, plus the hand copy in dbm_range_plausibility_test. No guard was removed.

Blockers

None.

Needs maintainer decision (@ten9876)

These two items were raised in your 2026-10-05 review and in @NF0T's approval. I can't find either answered on the PR, so I'm leaving them with you rather than restating a verdict:

  1. Sign-off on the two capability fields. PanFrameRateShaping::clientPersistsFrameRate is a new optional record (RadioCapabilities.h:100-108); PanAmplitudeModel::clientPersistsDbmRange is a new field (:150-154). They follow the shape of BackendPanAveraging::clientPersistsAveraging, and the boolean count stays at the frozen 70.
  2. Whether your 2026-10-04 CHANGES_REQUESTED at 295bff6e still stands. Its blocker is fixed at 7a5ca748, and your own review at that head says "Blockers: None". reviewDecision is still CHANGES_REQUESTED, though.

Nits (non-blocking)

  • Coverage boundary: neither model write is pinned. src/gui/MainWindow_Wiring.cpp:3175 is the pan->setRange(minDbm, maxDbm) inside adoptClientOwnedDbmRange, and :3201 is the pan->setRange(...) inside restoreClientOwnedDbmRange. Neither is checked by any test. I deleted the adopt's model write and kept its store write: client_display_pan_settings_test still exits 0, and nothing in the test mentions setRange. "Restore into the pan model, not the widget" is the design point that separates this PR from #6129, and it is now proven only by @NF0T's patched-demo drive and by reading the code. I am not asking for another source-text pin, because docs/agents/tests-ci.md:82-88 prefers behavioural seams and MainWindow has none. I'm naming the gap so it isn't read as covered. If a seam turns up later (for example, the adopt/restore pair moving into a small helper that takes a PanadapterModel*), that is the place for the test.
  • Known sibling gap, already disclosed by the author. The band-stack restore at src/gui/MainWindow.cpp:11084-11087 still sends display pan set … min_dbm= as a raw command. An HL2 drops it, so a band-stack recall neither moves nor stores the range there. This is unchanged from main and worth its own issue.

What I verified, and what I tried to break

  • Built AetherSDR and the three new test targets on the merge with 5f09371e (Linux, GCC, Qt 6.12.0, shared core, RelWithDebInfo -g1). client_display_pan_settings_test, spectrum_overlay_fft_fps_bounds_test and dbm_range_plausibility_test all pass.

  • Ran the static gates on that merge, because required CI ran at 2026-10-05 02:52Z and main has moved since:

    • gen_touchpoint_manifest.py --check: up to date.
    • check_capability_records.py --strict: 70, at the frozen 70.
    • check_command_plane.py: 369 = baseline.
    • check_engine_boundary.py: 102 findings, 0 would block.
  • Socket preflight: no added or modified test opens, binds, listens or connects. The backends in client_display_pan_settings_test are constructed only to read capabilities().

  • Mutation: the adopt without its model write survives (nit above). Afterwards I restored the file; its sha256 matches git show HEAD:, and I touched it.

  • Bridge drive, demo only, on the merged head. Settings were isolated (AETHER_SETTINGS_DIR, scratch HOME), with AETHER_AUTOMATION_NO_TX=1 and socket /tmp/aethersdr-pr6140. I connected with raw-JSON connect local serial DEMO-0001 and confirmed "model":"AetherSDR Demo", "serial":"DEMO-0001", "transmitting":false.

    step result
    get pan active "fps":25, -130 / -40
    panMenuDisplayBtn click, displayFftFpsSlider setValue 12 "fps":12
    kill -TERM, then read the store radio_settings holds no rows: nothing was written for a backend that declares no owner
    relaunch on the same store, reconnect DEMO-0001 "fps":25, -130 / -40

    So the opt-out holds on Linux at current main. Both instances were mine and both are closed; no live radio was touched.

  • Read hostilely, and it held:

    • All three dbmRangeChangeRequested emit sites:
      • the 2D auto-floor (SpectrumWidget.cpp:4132) returns at localOnly before the adopt;
      • the scale arrows (:9879) are an operator gesture;
      • the 3D zoom-floor resync (:3782) is excluded by the flag.
    • Both dbmRangeDragFinished sites (:11288 3D floor drag, :11332 scale drag) are operator gestures.
    • The flag is sound by construction: a same-thread connect is a direct call, the flag brackets the single emit, and the slot reads it synchronously. That is reasoned from code; I did not drive 3D with frames.
    • Profile-load holds and !ownedByClient both return before the adopt.
    • Both prime sites (MainWindow_Wiring.cpp:4073, MainWindow_Session.cpp:1854) restore before sw->setDbmRange(pan->minDbm(), …). The isConnected() term in clientOwnsPanDbmRange() makes the pre-connect wirePanadapter call a no-op, and the session prime covers it.
    • The FPS slider save sits before the adaptive-throttle return. fpsReported (:3262-3270) does not save, so neither the cap nor a radio report reaches the store.
  • Not done: an HL2 on the air (none here), the opt-in path on Linux (@NF0T drove it on Windows with a patched demo), 3D with live frames, Windows/macOS builds.

Recommendation: needs maintainer decision. The code is clean, with no blocker at this head or on the merge with current main. What remains is the two rulings above, which are @ten9876's to make.

— Jeremy KK7GWY + Claude

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer sign-off at d60f4660. I accept the two capability fields, PanFrameRateShaping::clientPersistsFrameRate and PanAmplitudeModel::clientPersistsDbmRange, in the shape of clientPersistsAveraging. This approval supersedes my 2026-10-04 CHANGES_REQUESTED at 295bff6e, whose blocker was fixed in 7a5ca748. The non-blocking coverage nit in my review today stands as a follow-up.

— Jeremy KK7GWY + Claude

@ten9876
ten9876 merged commit 2e3d7ae into aethersdr:main Oct 6, 2026
5 checks passed
tropo1234 added a commit to tropo1234/AetherSDR that referenced this pull request Oct 6, 2026
An ANAN-G2 reports no frequency, mode, tuning step or output level of its
own, so every connect started from defaults. The client now keeps each of
them per radio, each behind a declared capability:

- Tuning: AnanBackend declares the Tuning domain. Frequency and mode are
  restored at connect and captured once per settled tune and on each mode
  change. A stored value is refused, not clamped: a mode modeFromString()
  cannot map, or a frequency at or above the DDS Nyquist. A radio with no
  document starts at 14.175 MHz USB instead of 10 MHz WWV (maintainer
  ruling).
- Tuning step: rides the Tuning domain as tuningStepHz wherever no command
  plane carries `slice set step=`. RadioModel restores it at connect, seeds
  it into each new slice and captures any slice's step change, as it does
  for the CW surface. ANAN, HL2 and legacy RTL get it; Flex, Icom,
  settings-active RTL and the demo are unchanged. The flat TuningStepSize
  key stays a widget preference and is no longer read back into a slice.
- Radio output level: a new ClientSettingsDomain, ReceiveOutputLevel
  (bit 9), and RestoredRadioState::receiveOutputLevelPct, whose sentinel is
  -1 so a deliberately silenced speaker survives as 0. Seeded into both the
  backend and RadioModel::lineoutGain(). OperatingState schema 2 -> 3.
- dBm range: ANAN declares PanAmplitudeModel::clientPersistsDbmRange, so
  aethersdr#6140's store and restore cover it.

Master volume: RadioModel::activeOutputVolumePercent() is the one rule for
the level the operator hears -- MasterVolume with PC Audio on, the line-out
gain with it off, on every family. The hotkeys, FlexControl, HID, wheel,
MIDI and TCI all read it. applyMasterVolume() writes MasterVolume only with
PC Audio on, so moving the radio's level no longer overwrites the PC level.
The PC-Audio toggle re-applies the level where the radio declares
ReceiveOutputLevel, instead of comparing the family string.

The step_up/step_down shortcuts now go through applyOperatorTuningStep(), so
the chosen step reaches the slice and the RX applet; on a Flex that sends
`slice set N step=`, as the STEP buttons already did.

The span is not remembered: the connect dialog's DDC0 rate always wins
(maintainer ruling), so a G2 zoom is lost on reconnect, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jensenpat pushed a commit that referenced this pull request Oct 7, 2026
An ANAN-G2 reports no frequency, mode, tuning step or output level of its
own, so every connect started from defaults. The client now keeps each of
them per radio, each behind a declared capability:

- Tuning: AnanBackend declares the Tuning domain. Frequency and mode are
  restored at connect and captured once per settled tune and on each mode
  change. A stored value is refused, not clamped: a mode modeFromString()
  cannot map, or a frequency at or above the DDS Nyquist. A radio with no
  document starts at 14.175 MHz USB instead of 10 MHz WWV (maintainer
  ruling).
- Tuning step: rides the Tuning domain as tuningStepHz wherever no command
  plane carries `slice set step=`. RadioModel restores it at connect, seeds
  it into each new slice and captures any slice's step change, as it does
  for the CW surface. ANAN, HL2 and legacy RTL get it; Flex, Icom,
  settings-active RTL and the demo are unchanged. The flat TuningStepSize
  key stays a widget preference and is no longer read back into a slice.
- Radio output level: a new ClientSettingsDomain, ReceiveOutputLevel
  (bit 9), and RestoredRadioState::receiveOutputLevelPct, whose sentinel is
  -1 so a deliberately silenced speaker survives as 0. Seeded into both the
  backend and RadioModel::lineoutGain(). OperatingState schema 2 -> 3.
- dBm range: ANAN declares PanAmplitudeModel::clientPersistsDbmRange, so
  #6140's store and restore cover it.

Master volume: RadioModel::activeOutputVolumePercent() is the one rule for
the level the operator hears -- MasterVolume with PC Audio on, the line-out
gain with it off, on every family. The hotkeys, FlexControl, HID, wheel,
MIDI and TCI all read it. applyMasterVolume() writes MasterVolume only with
PC Audio on, so moving the radio's level no longer overwrites the PC level.
The PC-Audio toggle re-applies the level where the radio declares
ReceiveOutputLevel, instead of comparing the family string.

The step_up/step_down shortcuts now go through applyOperatorTuningStep(), so
the chosen step reaches the slice and the RX applet; on a Flex that sends
`slice set N step=`, as the STEP buttons already did.

The span is not remembered: the connect dialog's DDC0 rate always wins
(maintainer ruling), so a G2 zoom is lost on reconnect, as on main.

Squashed-from: #6129

Co-authored-by: Sotiris <299180028+tropo1234@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ten9876 added a commit to jensenpat/AetherSDR that referenced this pull request Oct 9, 2026
… and headroom recovery writes only for Auto Floor on an owned pan. Principle II.

- Both dBm-range gestures adopt the operator's range again on a backend
  with no command plane (HL2, aethersdr#6140): a dropped dispatch no longer returns
  before adoptClientOwnedDbmRange(), which is a no-op on a radio-owned range.
- An accepted range reply keeps a radio status received after the write
  (Principle II; FlexLib's handlers ignore an accepted reply). A rejection
  falls back to the range the radio last confirmed, not an earlier request
  still in flight.
- Headroom recovery, floor and ceiling, rewrites min_dbm/max_dbm only with
  Auto Floor on and only on a pan this session owns (maintainer ruling).
  A clipped floor holds baseline acquisition only while a recovery can
  still come, so a foreign pan or a floor at -180 dBm no longer stalls
  auto floor. Recovery helpers never move an endpoint inward.
- The rewire epoch is a SpectrumWidget member, not a dynamic property, and
  one ownership predicate serves the widget and the dispatch path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 9, 2026
Flex FFT headroom recovery can lower the peak ceiling while trying to
unclip the noise floor. Ordinary radio status also leaves the GUI
encoder cache stale, and accepted reply-only range writes do not
converge the canonical pan model. This repair preserves the opposite
encoder endpoint, validates before decoder mutation, and completes range
writes on the current radio reply.

Refs #6238. **This is a partial repair: complete band/Go To/preamp
trace-bounce removal is not claimed, and the issue remains open.**

## Linked display reports

The six open KN7K panadapter display reports are linked below, together
with the related Auto Floor OFF report #5525. These are reference links,
not closing claims; the PR's implementation and validation scope remain
the partial range/controller repair described below.

| Issue | Relationship to this PR |
|---|---|
| [#5685 — FFT peaks
clipping](#5685) | Primary
clipping report addressed by the bounded headroom/range repair; KN7K's
exact Windows/8600 environment remains unverified. |
| [#6219 — FFT peaks clipping #5685 —
reminder](#6219) | KN7K
confirms the same clipping report persists in 26.10.1; linked with
#5685. |
| [#5204 — Inconsistent CW signal on
waterfall](#5204) | Its RX
flat-topped FFT screenshot is relevant to clipping. Its CW
TX/waterfall-width symptom is not tested by this PR. |
| [#4221 — Waterfall a little jumpy/not
smooth](#4221) | Related
waterfall cadence report, linked for tracking; not reproduced or claimed
fixed by this range repair. |
| [#3970 — Keep S and Noise Floor readings in minimized Panadapter
applet](#3970) | Related
display request, linked for tracking; minimized-readout behavior is
unchanged and untested here. |
| [#2371 — Two receivers do not center when switching between
them](#2371) | Related
panadapter centering report, linked for tracking; centering behavior is
unchanged and untested here. |
| [#5525 — Reference level rises on frequency changes with Auto Floor
OFF](#5525) | Related
report by @aaldocampana-art. The current auto-floor-ON proof does not
establish a fix for the OFF case. |

Earlier KN7K reports [#3483 — band-to-band floor
positioning](#3483), [#3731
— FFT artifacts](#3731),
and [#3804 — post-TX floor
recovery](#3804) remain
historical context. Those closed reports are not reopened or
reclassified as fixed by this PR.

## Change

- Expand only a clipped encoder endpoint, bounded by FlexLib's -180/+20
dBm endpoints and the client's 180 dB aperture. Detect sparse ceiling
clipping using the actual decoder row scale. Saturated ranges become
no-ops, and a recovery helper never moves an endpoint inward.
- **Headroom recovery (floor and ceiling) rewrites `min_dbm`/`max_dbm`
only while Auto Floor is on, and only on a pan this session owns.** Flex
stores the range per band, so with Auto Floor off the client never
changes the operator's range. This was a maintainer ruling. It also
changes the floor recovery `main` ran with Auto Floor off.
- Refresh the per-pan encoder cache from ordinary status. Rejected,
superseded, rewired or foreign-pan callbacks cannot complete a newer
request. **A radio status received after the write wins over the
request, accepted or not** (Principle II; FlexLib's
`SetLowDbmReply`/`SetHighDbmReply` ignore an accepted reply). With no
such status, an accepted reply-only write converges the model, and a
rejection falls back to the range the radio last confirmed.
- Validate and confirm dispatch before committing decoder/handshake
state. Both existing RadioModel command entry points retain confirmed
foreign-pan refusal. On a backend with no command plane (HL2), both dBm
gestures still adopt and persist the operator's client-owned range
(#6140).
- Keep the native auto-floor presentation axis separate from encoder
headroom. Reacquire once after the existing 750 ms settling interval
plus three fresh floors within 1 dB over at least 100 ms; restore
ordinary transient rejection afterward. Hold headroom requests during
settling. A clipped floor blocks baseline acquisition only while a
recovery can still come: on a foreign pan, with Auto Floor off, or with
the floor at -180 dBm, the baseline acquires instead of stalling.
- Extend existing trace diagnostics with decoder metadata, pre-EMA input
statistics and the last native 2D draw's floor/axis. These local
counters do not identify VITA wire encoding generations.

The generated touchpoint change records reuse of the existing core dBm
validator by the GUI range helper. No new engine dependency, raw
command-plane site, capability or bridge verb is added; the frozen
raw-site count remains 369. Host absolute-bin and Kiwi paths retain
their existing acquisition behavior.

## Proof

Agent automation bridge, native Cocoa/Qt 6.12/Metal, RX-only FLEX-8400M,
firmware 4.2.18.41174. MW broadcast signals, native band buttons,
keyboard Go To, local band-stack bookmarks, and RF-gain controls were
exercised. These are maintainer 8400M screenshots, **not KN7K's**. They
are separate ambient RX captures, not a controlled RF amplitude
comparison.

| Regression | Observed repair / remaining result |
|---|---|
| R1: encoder ceiling | Starting at `-178/-88`, automatic upper recovery
reached `-178/-40`, keeping the minimum fixed. The after image shows
intact AM peaks around `-42 dBm` instead of the old `-88` plateau. |
| R2: floor recovery | `-100/-16` recovered to `-124/-16`; the maximum
stayed `-16`. Presentation span stayed 84 dB while the encoder aperture
expanded. |
| R3: status cache / reply-only completion | After ordinary status
`-80/-10`, recovery requested `-104/-10`, then `-128/-10`, with
successful replies and no matching self-status range echo in the
captured bracket. The model and decoder metadata both converged to
`-128/-10`. |
| R4: invalid / refused writes | Tests reject an invalid `-202/-112`
range, NaN, dispatch refusal and a confirmed foreign pan before state
commit. Invalid ranges were not sent to hardware. |
| R5: band / keyboard / bookmarks | The controller guards are
demonstrated, but trace transients persist: 40m→80m reached 35.26%
before settling near 25%; a keyboard transition reached 63.31%;
bookmarks reached 29.52%. |
| R6: preamp | 0→32 dB retained encoder `-124/-16`, preventing the
intermediate candidate's false expansion to `-148/-16`. The rendered
trace still moved (24.77–36.30%, ending 25.49% at an 84 dB presentation
span). Reverse and 86% target cases also retained transients. |

Before: earlier unfixed maintainer 8400M reproduction, `abc5b26`, with
encoder ceiling `-88 dBm`.

![Before: clipped encoder
ceiling](https://raw.githubusercontent.com/jensenpat/AetherSDR/aether-6238-panadapter-proof/.pr-assets/6238-before.png)

After: current production changes, automatic ceiling recovery and intact
MW signal peaks.

![After: recovered AM signal
peaks](https://raw.githubusercontent.com/jensenpat/AetherSDR/aether-6238-panadapter-proof/.pr-assets/6238-after.png)

Native captures were made before committing as `30882701-dirty`; only a
source comment and stronger test assertions changed before signed commit
`7725ebe2` was rebuilt and tested. Rendered telemetry describes draw
submission, not scanout; the after image was inspected separately. Raw
logs and the full evidence archive remain local at the operator's
request.

## Validation and limits

- Full build with `cmake --build <build-dir> -j22` passed at signed
commit `7725ebe2`.
- `QT_QPA_PLATFORM=offscreen ctest --test-dir <build-dir>
--no-tests=error`: **7/7 selected tests passed** at `793ab77c` on
Linux/Qt 6.12: `panadapter_dbm_range_test`,
`spectrum_preview_logic_test`, `dbm_range_plausibility_test`,
`noise_floor_auto_adjust_gate_test`, `pan_frame_guard_test`,
`client_display_pan_settings_test` (the #6140 HL2 pin), and opt-in
`spectrum_confirmed_geometry_test`. The last one needs
`-DAETHER_BUILD_SPECTRUM_GESTURE_TEST=ON` at configure. Offscreen widget
checks are logic/gesture proof, not GPU rendering proof.
- Each landing-pass fix goes red when reverted: the HL2 adopt
(`client_display_pan_settings_test`), radio-status-wins
(`panadapter_dbm_range_test`), the Auto Floor gate, the ownership gate,
and the -180 dBm limit (`spectrum_confirmed_geometry_test`).
- Demo bridge (Linux, `DEMO-0001`, `transmitting: false`): clicking the
dBm strip's up arrow moved the pan model and encoder to -130/-50 and
then -130/-60, with the display axis following. This held before and
after a disconnect and reconnect, which exercises the rewire epoch.
- Scratch mutation builds fail when validation, endpoint preservation,
accepted-reply completion, settle timing or clipped-baseline protection
is removed. The widget mutant fails three relevant assertions;
production binaries were not replaced.
- Strict engine boundary, generated touchpoints, strict capability
records, strict command plane, strict test registration, frozen CI test
gate and generated bridge docs passed.
- RX session cleanup confirmed TX/tune idle, owned app stopped, shared
RF gain and line-out mute restored, front-panel client retained, and
radio reservation released.
- CI, KN7K's Windows/FLEX-8600/firmware-4.1.5 environment,
resize/y_pixels stress, a second owned pan and post-TX behavior remain
unverified. Auto Floor OFF report #5525 is outside this proof.

The remaining bounce needs raw VITA frame/pixel correlation with
decoding range and TCP status timing. The agent automation bridge has no
raw VITA capture/replay surface; the noninteractive packet-capture
privilege check required a password, and no pcap was captured. A trace
hold/blanking policy or automatic display-span change would require
maintainer UX direction. This PR introduces neither and should not close
the complete bounce investigation.

Generated with OpenAI Codex (GPT-6 Astra)

---------

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
#6255)

Refs #5678 (HL2 parity rows), #5263 (the drop notice). No `Fixes` line:
I found no issue for this.

## Limits first

- **Nothing here was seen in the running app or on a radio.** The defect
is a source reading of `main`; the fix is tested on `RadioModel` with an
injected backend, and the four `MainWindow` call sites are pinned as
text only.
- **This is not an M4 conversion.** The `display panafall set` text is
not replaced by a typed intent. It moves out of `MainWindow_Wiring.cpp`
into the two wire sites `RadioModel` already has.
`tools/check_command_plane.py`: `MainWindow_Wiring.cpp` 20 to 14,
`RadioModel.cpp` unchanged at 135.
- **One design choice is yours: the Black Level gate.** It reads
`RadioCapabilities::panBinsAbsolute()`, the same proxy for the row unit
the renderer reads (`radio-capabilities-map.md` calls it a proxy). If
the row unit gets its own signal later, this gate should follow it. An
undeclared `panAmplitude` reads as not absolute, so a backend that
computes dB rows and forgets the declaration gets a false notice, not a
missing one.
- **On Icom, Black Level keeps its notice, also when it comes from Clone
to all Pans or Reset display defaults.** Reset turns auto black on, so
the value it writes is not in use at that moment; the notice is still
raised there, as it is on `main`. I did not make that case quieter.
- **ANAN and RTL-SDR are reached** (both declare `binsAbsolute`), and
the test does not run with their capabilities: only Hermes-Lite 2, Icom
and Flex.
- **Two other false notices on a Hermes-Lite 2 are not touched:** the
dBm scale (`sendDbmRangeCommand`, beside the dBm-range work of my #6140,
now merged) and `cw break_in` (left open by #6111).
- **The test ends in a source-text pin** (six checks). It names its
claim.
- The branch is rebased onto `121340c0`, with no conflict; head
`182bb99c` adds two review commits on `1935624d`. Two open PRs edit the
same files: #6241 merges with this branch without conflict; #6199
conflicts with it only on adjacent new lines in the `tests/tests.cmake`
test list (it also conflicts with `main` itself in `RadioModel.cpp`,
independently of this PR). Neither touches the four sites, the two
setters, or the gain and black text. Neither merged tree was built.

## What was wrong on main

On a backend with no Flex command plane, four Display-panel controls set
their value on the `SpectrumWidget`, which draws it, and then also sent
Flex wire text:

| control | site in `src/gui/MainWindow_Wiring.cpp` | text |
|---|---|---|
| WtrFall Gain | `wirePanadapter`,
`SpectrumOverlayMenu::wfColorGainChanged` handler | `display panafall
set <wf> color_gain=N` |
| Black Level | `wirePanadapter`,
`SpectrumOverlayMenu::wfBlackLevelChanged` handler | `display panafall
set <wf> black_level=N` |
| Clone to all Pans | `MainWindow::cloneDisplaySettingsToAllPans` |
both, per target pan |
| Reset display defaults | `wirePanadapter`,
`SpectrumOverlayMenu::displaySettingsReset` handler | `color_gain=50`,
`black_level=15` |

Each went through `RadioModel::sendCommand()` to `sendCmd()`, which has
no command plane to write to, logs the drop and emits `commandDropped`.
`MainWindow` shows that once per session as "nothing was sent to the
radio". So the notice was raised for a control that had acted, and the
one-shot was spent.

`RadioModel::setWaterfallColorGain()` and `setWaterfallBlackLevel()`
already return early without a command plane. The four sites did not use
them: those setters address the active pan, the sites address a named
one.

Manual Black Level acts on a Hermes-Lite 2 since #6115
(`WaterfallLevelMap::manualBlackThreshold` on absolute-dB rows).

## The change

- `RadioModel` gains `setWaterfallColorGainFor(panId, gain, autoBlack)`
and `setWaterfallBlackLevelFor(panId, level)`. All four setters go
through two private senders, `sendWaterfallColorGain()` and
`sendWaterfallBlackLevel()`, which hold the only wire site and the only
command-plane gate for each key.
- The four sites call those setters after the widget call, and pass the
pan's `wfAutoBlack()` with the gain. They no longer build wire text.
- **WtrFall Gain** is withheld without a command plane where the client
draws it: auto black on, or `panBinsAbsolute()`. With manual black on
any other row (Icom), every row is black whatever the gain, so it goes
to `sendCmd()` and is reported.
- **Black Level** is withheld without a command plane only where
`panBinsAbsolute()` is true (Hermes-Lite 2, ANAN, RTL-SDR). Elsewhere it
goes to `sendCmd()` and is reported. On Icom the manual black point is
still in tile units against a dB row, so every position draws black: the
slider does nothing there and says so.
- The connect-time replay (`setWaterfallBlackLevel()` from
`panadapterInfoChanged`) stays quiet on every backend with no command
plane, as on `main`. No operator moved anything.
- No capability is added and no family is named.
- `docs/architecture/radio-capabilities-map.md` records the flag's new
consumer.

**A Flex is sent what it is sent today**: same text, same pan, same
order. The sites used `sendCommand()` and the setters use `sendCmd()`;
the only thing `sendCommand()` adds is the pan-ownership gate, which
matches `display pan set ` and not `display panafall set `.

## How this sits with the #5919 review

That review kept the notice for an operator-moved control the backend
cannot apply, because nothing happened and the notice says so. It was
about receive controls; nobody has ruled on display text, and I am not
reading it as covering this.

The rule used here is narrower than "no command plane, no notice": the
send is withheld only where the control has acted in the client. Where
it has not (Black Level on Icom, and WtrFall Gain on Icom with manual
black), the notice stays and the test pins it. If you would rather keep
the notice on these four until pan commands get a typed intent, close
this and I will leave them.

## Tests

`pan_display_drop_notice_test` (new, socket-free, 41 checks). The
injected backend carries `capabilities()` read off a real `Hl2Backend`,
`IcomCivBackend` and `FlexBackend`; panes are made the way a backend
without Flex status makes them.

- **Hermes-Lite 2 capabilities, no command plane (12 checks):** the
renderer's law changes with gain (auto and manual black) and with the
manual black level for a dB row; none of the four raises
`commandDropped`; the same gain text sent raw is still reported, so the
alarm is live in the fixture.
- **Icom capabilities, no command plane (13 checks):** with auto black
gain raises nothing; with manual black no gain changes a dB row and gain
raises `commandDropped`, with the exact text; every Black Level position
draws a dB row black and the slider still raises `commandDropped`; Clone
reports the black level only with auto black, and both keys with manual
black; Reset reports the black level only; the connect-time replay
raises nothing.
- **A command plane (10 checks):** the wire text compared whole through
a command sink on an unopened `RadioConnection`, for each control, for
the target pan and not the active one, gain before black. With
absolute-dB rows and a command plane the black level is still sent.
- **Source-text pin (6 checks).** The claim: each of the four sites sets
the widget and then calls the model setter, and `MainWindow_Wiring.cpp`
builds no gain or black wire text of its own. No behavioural seam
reaches it: the sites are `MainWindow` lambdas and no test target
constructs a `MainWindow`. It shows how the sites are written, not that
they run.

**Mutations**, each as a scratch commit on `182bb99c` through the full
suite:

| mutation | failing checks of 41 | other tests |
|---|---|---|
| both gates removed, the four sites as on `main` | 15 |
`icom_family_test` |
| Black Level gated on `hasCommandPlane()` alone | 4 | none |
| colour gain ignores auto black (row unit only) | 4 | none |
| colour gain ignores the row unit (auto black only) | 4 | none |
| colour gain always withheld (the `1935624d` rule) | 2 | none |
| the connect-time replay reports its drop | 1 | `icom_family_test` |

The legs share one capture of dropped commands, so a wrong drop is also
counted by the checks after it in that leg.

Full ctest on macOS (Qt 6.12.0, Ninja, RelWithDebInfo) at `182bb99c`:
654 of 658 pass, at -j8 and on the serial rerun. The four failures are
known on clean `main` on this machine, each with only its known check
lines: `control_receive_test` ("RTL adjustable filter qualification
excludes legacy WFM", #6248), `vkamp_connection_test` ("commands after a
disconnect are dropped, not queued", #5254),
`peripheral_auth_dialog_test` (#6008, a serial-port name of this host)
and `unified_title_bar_test` (its two menu checks, #6224). This PR
changes `MainWindow_Wiring.cpp`, but only the four display sites; the
same two title-bar checks fail identically on two other branches at the
same base that do not touch it. Nothing else this PR touches is built by
those tests.

Every script step of the `Static checks` job was run locally at
`182bb99c` with stdin from `/dev/null` and is clean: colour ratchet +0,
`gen_touchpoint_manifest.py --check` up to date with no change,
`check_a11y.py` on the one changed GUI file with no findings. The
`Sanitizer option configures` job needs the CI image and was not run.

## Not verified

- Nothing in the running app; nothing on a Hermes-Lite 2, an Icom or a
Flex.
- Whether each of the four controls is enabled and reachable on a
Hermes-Lite 2 today.
- That the Icom Black Level slider is dead, and WtrFall Gain with it
under manual black, is the renderer's arithmetic plus `IcomCivBackend`
sending its scope rows through `toDbm`; I have no Icom.
- That the three sites pass the pan's own `wfAutoBlack()` is pinned by
source text only.
- ANAN and RTL-SDR, beyond their `binsAbsolute` declaration.
- Linux and Windows builds: CI will say.

73, ON8ST

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
rfoust added a commit that referenced this pull request Oct 12, 2026
…I. (#6139)

**Update after review, `ea2cf144` (2026-10-05):** ownership of the flag
is now declared, not inferred. Only the Hermes-Lite 2 declares the
client its owner (`RadioCapabilities::txWaterfallClientFlag`,
`clientPersistsFlag` true). The demo, ANAN, Icom and RTL-SDR declare
nothing and keep `main`'s behaviour: the toggle sends the wire text, the
model stays false, nothing is stored or restored. That is narrower than
this PR was. On the demo, `txwaterfall on` answers with the radio-echo
note again. Full suite at this head: 644 of 645; the one failure is
`peripheral_auth_dialog_test` (#6008), which fails on clean `main` too.
Source line numbers below are `main` at `85f2a124`.

Refs #5678 (HL2 parity; "Show TX in Waterfall", row P30 of our status
table). No issue exists for this.

## Limits, first

- **Nothing was keyed.** On an HL2 the toggle now sets the flag. What
the waterfall draws during an over on an HL2 has not been seen, and what
that radio's receive path reads while it transmits has not been
measured. A `true` model flag shows neither the colour law nor real
receive IQ while keyed; the demo cannot key at all.
- **The fix has not been seen in a running app.** The dead toggle was
(2026-10-01, bridge verb, 6 of 6 polls false). The fix is tested
socket-free on the real models and dialog.
- **It reaches the Hermes-Lite 2 only.** The request, the store and the
restore are gated on one accessor,
`RadioCapabilities::clientPersistsShowTxInWaterfall()`, which reads a
declared per-feature record. Flex, Icom, ANAN, RTL-SDR and the demo
leave the record absent. On Icom, ANAN, RTL-SDR and the demo the toggle
is as dead as on `main`. Engaging another family is one declaration, for
that family's owner or the maintainer to make.
- **Yours to move:** the keyed rows take the colour law a Flex's keyed
rows use (`dbmToWaterfallLevel`). On an HL2 they are absolute dB (dBFS
under a dBm label); whether the law suits them is unknown until someone
keys one.
- **Persistence is in the radio's settings document**, not a flat key:
one optional bool, `showTxInWaterfall`, in the existing per-radio
`ClientDisplay` feature document. No schema bump. Written and read only
where the backend declares the client its owner.

## What was wrong on `main`

On a Flex the flag is the radio's: `transmit set show_tx_in_waterfall=`
tells it, and its transmit status echoes it (`FlexBackend.cpp:1193`).
That echo is the only writer of `TransmitModel::showTxInWaterfall()`.

- The Radio Setup button sends the wire text unconditionally
(`RadioSetupDialog.cpp:2717`), and so does the bridge verb
(`AutomationServer.cpp:3599`).
- A radio with no command plane drops it (`RadioModel.cpp:9764`) and
nothing echoes, so the flag can never become true. The demo has a
command plane and no handler for the flag, so nothing echoes there
either.
- While keyed, the pane holding the TX passband draws rows only when the
flag is true (`SpectrumWidget.cpp:8488`). With it false that pane's
waterfall stands still for the whole over.

There is something to draw: the HL2 mutes its demodulator while keyed
and the spectrum keeps running on real IQ (`Hl2RxDsp::setAudioMuted`).

## The change

- `RadioModel::requestLocalShowTxInWaterfall(bool)`, the shape of the
existing `requestLocalPanWeightedAverage`: where the backend declares
the client the owner of the flag it applies the flag and returns true;
everywhere else it returns false and the caller sends the wire text,
which only a Flex answers. No raw command is added above the seam
(`check_command_plane`: 400, baseline 400).
- It writes the model through `TransmitModel::applyChanges`, the path an
echo takes.
- It stores the value per radio, on the same declaration. A disconnect
clears the transmit model (`TransmitModel.cpp:43`) and no status puts
the flag back, so `RadioModel::onConnected()` restores it, on the same
declaration.
- The Radio Setup button and the `txwaterfall` verb ask the model first.
- `RadioCapabilities::txWaterfallClientFlag`: a new optional per-feature
record with one field, `clientPersistsFlag` (no default). Engaged by the
HL2 only. No top-level capability boolean is added
(`check_capability_records`: 70 of 70).

`SpectrumWidget` is not changed. Default unchanged: off.

## Tests

`show_tx_in_waterfall_client_flag_test` (new, socket-free, real
backends): on the real `Hl2Backend`, request, store per radio, restore
on connect, and the bridge verb; on the real Icom, ANAN, RTL-SDR and
demo backends, each with a `ClientDisplay` document that already holds
the flag, nothing is restored, the request declines and the document is
untouched; the same on a real `FlexBackend` (the echo still works) and
on a backend that engages the record with the field false; the bridge
verb's radio-echo note on Flex and on the demo; the store's refusals.

`radio_setup_show_tx_waterfall_test` (new, production dialog, real
backends): on an HL2 a click sets the model and sends no wire text; on
an Icom, which declares no owner, and on a Flex the click sends the wire
text and the model is not set. No source-text pin.

Fail-before, one build and one run per row, failing checks in the two
tests:

| mutation | client flag test | dialog test |
|---|---|---|
| this branch | 0 | 0 |
| `main`'s behaviour (request declines, connect restores nothing) | 11 |
1 slot |
| the request does not write the model | 5 | 1 slot |
| the connect edge does not restore | 1 | 0 |
| the request is taken on a Flex too | 4 | 1 slot (Flex) |
| the request does not store | 3 | 0 |
| the button sends the wire text unguarded | 0 | 1 slot |
| the bridge verb skips the local route | 2 | 0 |
| the store ignores who owns the flag on read | 1 | 0 |
| the ownership accessor is true for every backend | 40 | 2 slots |
| the ownership accessor is false for every backend | 15 | 1 slot |

The two accessor rows were measured at `ea2cf144`, one build and one
full run each. The other rows were measured at earlier heads, before the
opt-out checks existed, and were not re-run.

Full ctest, macOS, Qt 6.12.0, at `ea2cf144`: 644 of 645 pass. The one
failure is not ours; see below.

Static checks (`static-checks.yml`, stdin from `/dev/null`): clean.
`check_a11y` on `RadioSetupDialog.cpp`: 9 warnings, the same 9 as
`main`, none on lines this PR touches. `check_capability_records`: 70
booleans, frozen at 70. The steps were run from this branch's
`static-checks.yml`; `main`'s newer `check_build_options` steps are not
on this branch and were not run.

## With the open PRs

- #5924, #6117 and #6115 are merged and in this branch. #5919 is merged
into `main` and is not in this branch.
- #6140 (open, `7a5ca748`) adds its own records to
`RadioCapabilities.h`; a trial merge with this head is clean and the
merged header still counts 70 booleans. A trial merge with `main`
`85f2a124` is clean. Neither merge result was built.

## Not verified

- Anything keyed; see Limits.
- Linux and Windows at this head. ANAN, Icom and RTL-SDR on a radio:
they keep `main`'s behaviour by declaration, checked on their real
backends, never connected.
- A running app at this head, the demo included.
- That the main window hands a restored flag to the spectrum widget.

**Known red on `main`:** `peripheral_auth_dialog_test` (#6008) fails on
this Mac at `ea2cf144` with the diagnostic it prints on clean `main` at
`22507580` (pasted on #6134): "Clear code or Status is clipped at the
default dialog size", `dialog 960x680, viewport 658, page minimum 784,
horizontal scroll max 126` (a serial-port name on this host widens the
page). Clean `main` was not re-run at `85f2a124`.
`hl2_pan_create_async_test` and `vkamp_connection_test` passed here.

73, ON8ST

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Robbie Foust <rfoust@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants