Skip to content

fix(gui): manual Black Level works on a dBm row. Principle VIII. - #6115

Merged
ten9876 merged 10 commits into
aethersdr:mainfrom
on8st:hl2/waterfall-manual-black
Oct 4, 2026
Merged

ten9876 merged 10 commits into
aethersdr:mainfrom
on8st:hl2/waterfall-manual-black

Conversation

@on8st

@on8st on8st commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Not covered: Icom and the Demo. Both send dB rows down the same path (IcomCivBackend scope rows through toDbm, SimSignalSource at a -120 floor) but declare no binsAbsolute, so on either of them Off still draws every row black. The maintainer ruled on 2026-10-03 to merge this as it stands, correct for HL2, ANAN and RTL-SDR. A row-unit signal emitted where onBackendSpectrumFrame sends the row on comes as a separate follow-up PR. radio-capabilities-map.md states the same gap.

Second update, 700c2d7e: current main is merged in; the docs now call binsAbsolute a proxy for the row unit and list the Demo as not covered (Icom added in a3758582). The merged tree is 614 of 616 on macOS: the two failing tests fail identically on clean main and are not from this PR.

Summary

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

Update after review, a54dff9c. Two more things to read first, and one rename:

  • The shipped default (Black Level 15) is still dark on these radios. It is a black point of −75; the HL2 floors first light at slider 52 to 98. Retest with the slider above that. A Flex at 15 is in the same position (threshold 145 against a 96..120 tile).
  • The rows are dBFS under a dBm label on all three families. No RTL-SDR bin goes below −120 dBFS (RtlSdrDdc.cpp:179), so slider 0 (−60) cannot black an RTL signal stronger than −60 dBFS. The end points are unchanged; the options are in the review reply.
  • The API now says rowsAreAbsoluteDb. Full ctest at a54dff9c: 611 of 611 (macOS, Qt 6.12.0, Ninja).

Read first.

  • The fix has not been seen in the running app. The defect was observed there. The fix is tested as arithmetic only.
  • Two constants are chosen, not derived, and they are yours to move: the manual black point on a dBm row runs from -60 dBm (slider 0) to -160 dBm (slider 100).
  • It reaches three families. The switch is RadioCapabilities::panBinsAbsolute(), which HL2, ANAN and RTL-SDR declare. Only the HL2 was observed.
  • The test ends in a source-text pin on SpectrumWidget.cpp. It names its claim; if you would rather not carry it, it is four checks at the end of one file.

With the Display panel's Black Level button on Off (manual), a Hermes-Lite 2 draws every new waterfall row black, wherever the slider sits.

Observed (the defect, before the change)

Hermes-Lite 2, gateware 74, receive only, through the automation bridge, 2026-10-01, on a local integration build over 25da98de (Qt 6.12.0). None of the PRs in that build touched this function.

Black Level button mean luma of the newest 130 rows
SW, WtrFall Gain 0 / 50 / 100 5.6 to 5.8 / 12.7 to 13.8 / 43.5 to 43.6
SW, offset 20 / 50 / 80 2.6 / 14.8 / 61.4
Off, Black Level 0, 25, 50, 75, 100, WtrFall Gain 0, 50, 100 0.00 in 12 of 12 frames, max 0.0, 653 856 px each

get display read back each wfBlackLevel and wfAutoBlack: false, so the slider reached the widget.

Cause

The manual black point in SpectrumWidget::intensityToWaterfallLevel was 160 - level: 160 down to 60, in Flex tile-intensity units (a tile is int16(raw) / 128, about 96..120 on HF).

A radio whose spectrum is computed on this host has no waterfall plane. RadioModel::onBackendSpectrumFrame hands the pan frame on as the waterfall row, so the row is dBm: negative. Every sample is below a threshold of +60..+160. SW is unaffected because its black point is measured from the row, so it is already in the row's unit.

The change

  • The value-to-level law moves from SpectrumWidget into gui/WaterfallLevelMap.h as pure functions, because SpectrumWidget links into no test target. The widget now gathers its state and calls it.
  • The manual branch takes the row's unit from m_panBinsAbsolute, the flag the widget already holds for the auto-floor gate:
    • false (Flex): 160 - level. Unchanged.
    • true (HL2, ANAN, RTL-SDR): -60 dBm - level, one dB a step.
  • SW and HW do not read the flag. No capability is added and no family is named.
  • docs/HERMES.md and docs/architecture/radio-capabilities-map.md record the flag's second consumer.

Design choice, yours to move: the two end points. The span has to reach below the lowest floor this path produces and leave only strong signals lit at slider 0, as on a Flex. On this HL2 on a dummy load the displayed floor ran from -111 dBm (LNA -12 dB) to -148 dBm (LNA +40 dB) at the 384 kHz span; the 48 kHz span has bins eight times narrower, 9 dB lower. -160 dBm clears that. These figures come from one radio whose dBm axis is uncalibrated; I have none for an ANAN or an RTL-SDR. Both constants are in WaterfallLevelMap.h (kManualBlackAtZeroAbsoluteDb, and the 0..100 slider at one dB a step).

Direction is the Flex one in both units: a higher slider value is a lower black point ("Decrease to darken the noise floor", the slider's tooltip). WtrFall Gain keeps its range law (120 - gain * 0.91, in the row's unit), so Off and SW at the same black point draw the same picture.

Tests

waterfall_manual_black_test (new, no Qt, 16 checks):

  • A Flex is unchanged. A frozen copy of the law as it stood is compared with the extracted one bit for bit over 8 604 792 samples: Off, SW and HW, every 5th value of gain and of the slider, three radio black levels, values from -200 to +600, NaN and both infinities. Zero mismatches with the flag false; zero for SW and HW with it true. (The header keeps qBound's comparison order, which sends a NaN sample to black; std::clamp would not.)
  • The tile law blanks a dBm row at every slider position, also for a signal 60 dB over the floor.
  • On a dBm row, for seven measured floors and gain 0 / 50 / 100: slider 100 lights the floor, slider 0 blacks it, some position blacks the floor and keeps a signal 30 dB above it, and raising the slider never darkens.
  • Off at a black point equals SW at the same black point.
  • Source-text pin (four checks). The claim: SpectrumWidget.cpp passes m_panBinsAbsolute as rowsAreAbsoluteDb, calls the shared law, and keeps no second copy of 160 - level. No behavioural seam reaches it: SpectrumWidget is compiled only into the application, and no test target links it. It shows how the call is written, not that the widget runs. That HL2 declares panBinsAbsolute() and a Flex does not is pinned on real backends by noise_floor_auto_adjust_gate_test.

Red before green, re-run at this head. On main the law is a private member of a widget no test links, so the test cannot be built against main; M1 is main's law inside the new header.

mutation failing checks of 16
M1: the manual branch ignores the unit (main's law) 4
M2: the widget passes false 1 (the source-text pin)
M3: the tile constant 160 becomes 150 2
M4: the dBm slider direction reversed 5
M5: the unit leaks into SW 2

Mutations were run with the test compiled on its own with Apple clang.

Full ctest on macOS (Qt 6.12.0, Ninja) at 67999c5c, which contains main at c7513e12: 100% tests passed out of 611.

Every script step of the Static checks job in static-checks.yml was run locally with stdin from /dev/null and is clean, the colour ratchet at +0. check_a11y on the two changed GUI files: no findings.

Not verified

  • Not observed in the running app. The observation above is the defect, on a build without this change. No build carrying the fix has been launched against a radio.
  • ANAN and RTL-SDR take the same path; nothing was run on them.
  • The source-text pin does not see MainWindow pushing the capability into the widget.
  • macOS only. The sanitizer-configure job was not run locally.

Beside this on main

Nothing on main overlaps: the branch merges c7513e12 cleanly. The NB Blank fix for the same unit problem (branch hl2/waterfall-nb-blank-dbm) reads the same flag and merges cleanly with this one. The keyed (TX) waterfall rows go through dbmToWaterfallLevel, which has its own black-level law and is untouched.

73, ON8ST

🤖 Generated with Claude Code

on8st and others added 4 commits October 1, 2026 12:14
With the Black Level button on Off, a Hermes-Lite 2 drew every new
waterfall row black at every slider position. The manual black point was
`160 - level`, a threshold in Flex tile-intensity units (about 96..120).
A radio whose spectrum is computed on this host has no waterfall plane:
RadioModel::onBackendSpectrumFrame reuses the pan frame as the row, so
the row is dBm and sits below +60..+160 always. SW worked because its
black point is measured from the row.

The row's unit is now taken from the capability the widget already holds
for the auto-floor gate, RadioCapabilities::panBinsAbsolute(). The law
moves to gui/WaterfallLevelMap.h so a test can reach it:

- tile row (flag false; Flex): `160 - level`, unchanged.
- dBm row (HL2, ANAN, RTL-SDR): `-60 dBm - level`, -60 down to -160 dBm.

Same direction in both units: a higher value draws more of the floor.
SW and HW do not read the flag. The two dBm end points are chosen, not
derived; the reasoning is on the constant.

waterfall_manual_black_test compares the extracted law with a frozen
copy of the old one, bit for bit, over 8.6 million samples in all three
modes, and pins the dBm behaviour against the floors seen on one HL2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts. The branch's long comments are cut to the five-line rule;
the defect narrative stays in the original commit message.

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

Brings the comment blocks this branch adds under the AGENTS.md rule: a
comment says what is true now, in five lines or fewer. The observation
(Hermes-Lite 2, 2026-10-01, Off drew 12 of 12 frames black) and the list
of what the test pins are in the PR body. The source-text block now
names the claim it pins and why no behavioural seam reaches it
(SpectrumWidget links into no test target), as docs/agents/tests-ci.md
asks. Comments only; no check changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st
on8st requested a review from a team as a code owner October 3, 2026 17:04

@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

Yes, for the radio it was measured on. There is no linked issue (the body says so, and Refs #5678); GOVERNANCE.md does not require one for a bug fix with a clear root cause, and this one has a root cause I was able to re-derive independently rather than take on trust: RadioModel::onBackendSpectrumFrame emits the same bins array as both the pan frame and the waterfall row (src/models/RadioModel.cpp:6752, "the backend has no separate waterfall plane, so the pan row is the waterfall row"), that row lands on the native-tile path, and the manual branch there compared it against a tile-intensity threshold of +60..+160. Every sample is below that. The diagnosis is correct and the fix is aimed at the right line.

Two things the fix does not reach, both arithmetic rather than opinion, both detailed below: the shipped default slider position still draws an HL2 waterfall black, and the two chosen end points do not fit RTL-SDR's scale, which is hard-bounded to roughly −120…−6 dBFS by its own FFT code. Neither is a correctness defect in the law; both are the constants question the body explicitly hands to the maintainer, and I have numbers for it now.

2. Scope

File / group What it changes Claimed? Verdict
src/gui/WaterfallLevelMap.h (new) the value→level law as pure functions, plus the two end-point constants yes In scope
src/gui/SpectrumWidget.cpp intensityToWaterfallLevel gathers state and delegates; wfHighThresholdRaw removed yes In scope — wfHighThresholdRaw had exactly one caller, so the move leaves nothing behind, and the bit-identity test covers the move
tests/waterfall_manual_black_test.cpp + tests/tests.cmake registered target, add_test, AETHER_SOURCE_DIR yes In scope. Not a socket test — no QTcp*/QUdp*/QLocal*/bind/listen/fake peer; the only I/O is one std::ifstream over a source file. AETHER_SOURCE_DIR has ~8 precedents in tests.cmake
docs/HERMES.md, docs/architecture/radio-capabilities-map.md record the flag's second consumer yes In scope, and the right place

No CHANGELOG.md entry — correct. No capability added, no family named, no settings key added or migrated, no new protocol or public surface, no dialog or a11y surface, no formatting churn, no stray commit. Everything in the diff is explained by the stated fix. The move of the law out of the widget is not scope creep: SpectrumWidget links into no test target, so extraction is the only way the arithmetic becomes testable at all, and the 8.6 M-sample bit-identity check is exactly the right price for it.

3. Blockers

None.

4. Nits (all non-blocking)

  • The shipped default still draws black on an HL2 — the practical headline. Default DisplayWfBlackLevel is 15 (SpectrumWidget.cpp:2501) → black point −75 dBm. Against your own seven measured floors the level is 0.000 at every colour gain, and so is slider 50. The first position that lights the floor runs 52 (LNA −12 dB) → 89 (LNA +40 dB) → 98 (48 kHz span). So an operator who flips Black Level to Off without also dragging the slider past halfway still sees the reported symptom. This is consistent with the Flex, where the manual default of 15 → threshold 145 also sits above a 96..120 tile row, so it is not a regression — but it is worth a line in the body so nobody retests at the default and concludes the fix did not take.
  • dBm vs dBFS naming. RadioModel.cpp:6661 calls the HL2 payload "DC-centred dBFS bins", and RtlSdrBackend.cpp:105 is explicit that "the axis is dBFS relative to the converter's own full scale." The new rowsAreDbm / kManualBlackAtZeroDbm and both doc paragraphs say dBm. The arithmetic is unaffected, but one of the two comments is stale and this is the name on the new API.
  • Pre-existing, same bug class, not yours to fix here: m_autoBlackThresh{145.0f} (SpectrumWidget.h:1706) is also a tile-unit seed. The estimator at SpectrumWidget.cpp:8538 smooths at 0.05, so on a dBm row it needs ~110 rows to walk from +145 down to ~−140 — i.e. SW mode also starts black for several seconds after connect on these three families. Your "SW measures its black point from the row" claim is correct in steady state (I read the estimator at 8515-8538; it genuinely reads binsIntensity), just not at the seed. Worth adding to the follow-up list beside the NB Blank branch.
  • Two dBm black-point laws now coexist on one radio. If HL2 native rows stall past waterfallFallbackTimeoutMs(), rows switch to pushRxWaterfallFallbackIfDue → dbmToWaterfallLevel (SpectrumWidget.cpp:12370), whose manual black point is m_wfMinDbm + (125 − level) * 0.4. Same radio, same unit, same slider, a different black point. Pre-existing divergence, but this PR is the first to make both live laws dBm laws, so it becomes visible. (FWIW m_wfMinDbm is never assigned anywhere in src/ — frozen at its −130 default — which is why I am not suggesting it as an anchor below.)
  • The source-text pin is, strictly, asserting the implementation back to itself. I would keep it: it is disclosed, it is precedented, and its one real blind spot — MainWindow actually pushing the capability — I checked by hand and it is covered (see below).

5. What I tried to break

Findings below are reasoned from code plus my own local compile-and-run of the new test; nothing was observed in the running app, as you also state.

  • I built and ran the test. macOS, Apple clang, -std=c++20, header-only: 16/16 checks, 0 failed, 8 604 792 samples compared — exactly the figures in your body.
  • I re-ran your mutation table independently and it reproduces precisely: M1 (manual branch ignores the unit) → 4 failures, M3 (160→150) → 2, M4 (direction reversed) → 5, M5 (unit leaks into SW) → 2. I added two of my own: swapping bound() for std::clamp → 2 failures, so the NaN-ordering argument in the header comment is real and load-bearing, not decorative; and moving kManualBlackAtZeroDbm −60 → −100 → only 2 failures, both of them the constant-echo and SW-equality checks. So the behavioural checks (lit end, dark end, +30 dB separation) are permissive over a wide band of end points — which is honest given the body calls the constants chosen, but it means the test will not object if the maintainer moves them.
  • Both capability push sites check out. MainWindow.cpp:7558-7566 pushes to every SpectrumWidget child on connect and disconnect (connected && caps.panBinsAbsolute(), so it resets to false on disconnect), and MainWindow_Wiring.cpp:3912 covers the case I expected to be the hole — a pane added after connect via Add Panadapter or a layout change. It is already there with a comment explaining why. Hl2Backend.cpp:1767, AnanBackend.cpp:386 and RtlSdrBackend.cpp:117 all set binsAbsolute = true, so the three-family claim is accurate.
  • I looked for a second copy of the law, including a GPU/QRhi shader path, since render-path parity is a recurring bug class here. The only other level laws are dbmToWaterfallLevel and kiwiSdrWaterfallLevel; both are untouched and reached from different call sites, and waterfallLevelToRgb/encodeWaterfallLevel are unit-agnostic. Nothing left behind.
  • Head freshness and CI. The task checkout was 67999c5c; I fetched pull/6115/head and confirmed it is the same commit and that f5f2b14 is an ancestor, so I reviewed the real head. All five checks are green on it. Linux (ci.yml:282) and macOS (:1147) build full-tree, so the new test compiles under GCC too — but Windows builds only a named target subset, so MSVC never sees this file, and no PR-gating ctest runs this test (every ctest step in ci.yml is -R-filtered to a handful of names). Green CI here does not mean these 16 checks ran; your local full-suite run and mine are the evidence.
  • Could not check: anything requiring the running app, an ANAN, or an RTL dongle — same boundary you declared.

6. Recommendation

Approve with nits, plus one maintainer decision you already asked for. The law itself is right and unusually well pinned: a Flex is bit-identical over 8.6 M samples across all three black-point sources including NaN and both infinities, every mutation I could invent is caught, the extraction is necessary rather than cosmetic, and the scope is clean. I tried to break the arithmetic, the capability plumbing, and the sibling render paths, and it held.

The one thing worth the maintainer's attention before merge is the question the body raises: the two end points are tuned to one HL2 and the test only validates them against that radio's floors, while the same switch serves a family whose scale cannot reach them. That is not an argument against merging — it is an argument for either narrowing the switch to the HL2/ANAN path for now, or anchoring the black point to the pan's live display range (m_refLevel − m_dynamicRange) so it self-calibrates and no constant has to be guessed per family. The HL2's auto-floor loop is already driven by this very same panBinsAbsolute flag, so that range is maintained and sits on the noise floor. Concrete next step: pick one of those two, and add the one-line note about the default slider so the fix is retested off the default.

Nice work — the mutation table is accurate to the check, which is rarer than it should be, and the "Not verified" section is exactly the right shape.


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

Comment thread src/gui/WaterfallLevelMap.h Outdated
Comment thread src/gui/WaterfallLevelMap.h Outdated
Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment thread tests/waterfall_manual_black_test.cpp
The rows HL2, ANAN and RTL-SDR hand the waterfall are dBFS under a dBm
label (Hl2Backend, AnanBackend and RtlSdrBackend each say so), so the
new API says rowsAreAbsoluteDb / kManualBlackAtZeroAbsoluteDb, matching
PanAmplitudeModel::binsAbsolute. No arithmetic changes.

The test header and the floors array now say that every absolute-row
check is scoped to one HL2's floors and pins direction and monotonicity,
not the end points; ANAN and RTL-SDR take the path unmeasured.

Fail-before, test compiled alone: widget passes false -> 1 of 16 fails
(the source-text pin); manual branch ignores the unit -> 4 of 16.

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

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first: the default is still dark, and the end points are not changed.

Default. Slider 15 is a black point of −75. The seven HL2 floors read 0.000 at every gain; first lit at 52 … 98. A Flex at 15 (threshold 145, tile 96..120) is also 0.000; first lit at 41 … 65. The default follows the end-point choice, so it is yours.

Two corrections.

  • The pan range tracks the floor only with the auto floor on, and that defaults off (SpectrumWidget.cpp:2527).
  • Your own table puts both bands inside one span: RTL floors light at 11 … 61, HL2's at 52 … 98. The RTL gap is the other end: nothing above −60 dBFS can be blacked.
option cost
keep −60 … −160 RTL signals above −60 always lit
narrow to HL2/ANAN new capability field; RTL stays black in Off; ANAN is unmeasured too (AnanBackend.cpp:378)
anchor to the pan range a scale move recolours new rows only; fixed while auto floor is off
end points in PanAmplitudeModel a record field, three backends; no ANAN figure
anchor to the row's measured floor Off becomes SW with a second slider

Done in a54dff9c: the API says rowsAreAbsoluteDb (dBFS under a dBm label), and the test states its floors are one HL2's.

The SW seed and the fallback law are follow-ups. Agreed: green CI did not run this test.

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.

Second adversarial pass at a54dff9c

This follows the bot review on 67999c5c and the author's replies, and doesn't repeat them. It covers four things that pass didn't: the fix running in the app, a fourth producer of dB rows, the sibling #6117, and settings meaning.

Issue fit

Yes, for HL2, ANAN and RTL-SDR. I have now seen the fix work in the running app, which the body lists as its biggest unverified claim. The diagnosis is right: RadioModel::onBackendSpectrumFrame (src/models/RadioModel.cpp:6659-6750) sends the pan frame on as the waterfall row, so the row is in dB and the tile threshold 160 - level blanks it. But the predicate the PR uses to detect a dB row, panBinsAbsolute(), misses a fourth producer that reaches the same function: the built-in Demo. On the Demo, Off is still black at every slider position on this head (evidence below).

Scope

File / group What it changes Claimed? Verdict
src/gui/WaterfallLevelMap.h (new) value-to-level law as pure functions, two end-point constants yes In scope
src/gui/SpectrumWidget.cpp intensityToWaterfallLevel delegates; wfHighThresholdRaw moved yes In scope
tests/waterfall_manual_black_test.cpp, tests/tests.cmake new registered no-Qt test yes In scope. Not a socket test (only I/O is one std::ifstream over a source file), so preflight is clean
docs/HERMES.md, docs/architecture/radio-capabilities-map.md record the flag's second consumer yes In scope (see nit 3 on wording)

Everything in the diff is explained by the stated fix. No CHANGELOG entry, no new settings key, no capability field, no protocol surface.

Blockers

None.

Should-fix (non-blocking; maintainer call on the shape)

1. The Demo makes dB rows but doesn't declare binsAbsolute, so the fix can't reach it. The predicate is a proxy, not the row's unit.

  • src/core/backends/sim/SimSignalSource.cpp:136-151 emits host-computed float rows with kFloorDbm = -120.0.
  • SimBackend.cpp:47-51 relays them as spectrumFrameReady, so they go through the same onBackendSpectrumFrame → panFeedWaterfallRowReady path as the HL2.
  • SimBackend::capabilities() (SimBackend.cpp:248-379) declares no panAmplitude record, so panBinsAbsolute() is false and the manual branch keeps 160 - level on a dB row.

Bridge evidence, PR head, Demo DEMO-0001. The run was offscreen with isolated settings and a -DAETHER_GPU_SPECTRUM=OFF build, so the widget's pixels can be grabbed offscreen. Each value is the mean luma of the newest ~1.3 s of waterfall rows, cropped clear of the Display panel. get display read back wfAutoBlack:false and each wfBlackLevel:

state head as posted head + Demo declares binsAbsolute=true (scratch mutation, not PR code)
SW, offset 50 4.04 (lit) 4.04
Off, 0 0.00 0.10 (max 59: the carrier only)
Off, 15 (default) 0.00 0.41
Off, 50 0.00 12.4
Off, 75 0.00 145.3
Off, 100 0.00 203.7

floors read noiseFloorDbm: -103.97 on both runs. So the reported defect reproduces on the Demo at this head. With the flag declared, the PR's law behaves as designed on a dB row:

  • It rises monotonically with the slider.
  • The floor first lights near slider 44, from the threshold -60 - L against a -104 floor.
  • Slider 0 leaves only the carrier.

That second column is the first in-app sighting of this fix. Under the PR's own law it also matches what the HL2 should do.

PanAmplitudeModel::binsAbsolute is documented (RadioCapabilities.h:114-122) as "a bin does not move when the display reference level moves". That property is about the auto floor. The PR now also reads it as "the waterfall row is in dB". Those coincide on HL2, ANAN and RTL, but not on the Demo, and nothing keeps them coinciding for the next backend that routes through onBackendSpectrumFrame. The new paragraph in radio-capabilities-map.md:541 says "A backend with absolute bins has no waterfall plane of its own", but the inference the code needs runs the other way: no waterfall plane, so the row is the pan frame, so the row is in dB. Two options:

  • (a) Make the Demo declare binsAbsolute = true. That is truthful under the field's own definition, since its bins are host-computed and fixed against the reference level. It also turns on the Demo's auto-floor loop, which is a behaviour change, so it is better as its own small PR.
  • (b) Key the unit off where the row came from: rows that arrive via panFeedWaterfallRowReady from onBackendSpectrumFrame are pan frames by construction. That is closer to the real invariant, but it is a larger change.

Either way, the PR shouldn't read as complete while the one backend every reviewer and new user drives still shows the reported symptom. At minimum, the body and the capabilities-map paragraph should say the Demo is not covered.

Sibling #6117 (NB Blank on a dB row)

  • No conflict. git merge-tree of a54dff9c with 63a73a35 is clean, and so is a54dff9c with current main (579efd73f). The hunks don't overlap: updateWaterfallRow's blanker versus intensityToWaterfallLevel. The tests.cmake blocks land in different places.
  • Same root cause, treated consistently: both take the row's unit from m_panBinsAbsolute, never from the sign of the data, and both treat 1 dB as one tile unit of colour-range width. So both inherit the Demo gap in item 1. Whatever is decided there should land once and cover both.
  • Naming drift: this PR renamed to rowsAreAbsoluteDb / "dBFS under a dBm label" after review. #6117 still says RowKind::Dbm and "dBm row". Worth aligning before either merges, so the codebase doesn't carry two names for one concept.
  • Neither PR touches the third consumer of the same unit mismatch, the SW seed m_autoBlackThresh{145.0f} (SpectrumWidget.h:1706). I saw it in the Demo run: SW drew roughly the first 8 s of rows black before the estimator walked down from +145 (visible in the SW grab). The bot and the author already listed this as a follow-up, and I agree.

Settings meaning and migration

DisplayWfBlackLevel keeps its key, its 0..100 clamp (SpectrumWidget.cpp:4259) and its default of 15. On a Flex the law is bit-identical: I re-ran waterfall_manual_black_test at this head on Linux/GCC and got 16/16 with 8,604,792 samples compared. On a flag-true radio the old meaning of any saved value was "always black", so there is no meaning to preserve, and no migration is needed. The key is per-pan but not per-radio, so one saved number now means a tile threshold on a Flex and a dB threshold on an HL2. That is inherent to the design and not a defect, but worth knowing when someone switches radios.

Nits (non-blocking)

  1. CI is stale against main: runs were created at 18:34Z and main moved at 19:25Z (579efd73f). The trial merge is clean. No CI ctest -R filter runs waterfall_manual_black_test, so green CI does not mean these 16 checks ran.
  2. Pre-existing and not yours: SpectrumWidget.h:1642 still comments m_wfBlackLevel as "0-125" while the setter clamps to 0..100.
  3. docs/HERMES.md "Off needs the row's unit, and for a while it did not have it. … Off drew nothing at any slider position — 12 of 12 frames exactly black on the bench" narrates the bug's history. The capabilities-map paragraph's "Before that the tile threshold was applied…" does the same. These are design docs, so this is softer than the comments rule, but the current-invariant sentence would carry it alone and the bench story fits the PR body.

What I tried to break

  • Built the head on Linux (GCC, Qt 6.12) and ran the new test: 16/16. I didn't redo the bot's M1–M5 mutation table. Its numbers already match the body.
  • Drove the Demo through the bridge on my own socket, with isolated settings, offscreen, AETHER_AUTOMATION_NO_TX=1. I checked that get radio returned AetherSDR Demo / DEMO-0001. I toggled Black Level to Off via displayAutoBlackBtn, stepped displayBlackLevelSlider 0/15/50/75/100, read each back with get display, and grabbed pan-visible 0 each time. The table above is the result. The GPU build can't be grabbed offscreen ("Spectrum renderer unavailable"), so I rebuilt with AETHER_GPU_SPECTRUM=OFF. That changes the renderer, not the law, which both paths share via intensityToWaterfallLevel.
  • A/B without a base build: with the flag false, the head's manual branch runs main's exact law (proven bit-identical by the test). So head-as-posted on the Demo is the "before", and the scratch flag flip is the "after". The mutation was reverted and is not part of anything pushed.
  • Flex was not driven (no hardware in a review). Flex invariance rests on the bit-identity test, which I re-ran.
  • Not tested: ANAN and RTL hardware. The bot's RTL-saturation table is consistent with what I saw at slider 100 on the Demo: a -104 floor reads 204/255 mean luma, close to white.

Recommendation

Approve with nits, plus one maintainer decision. The law is correct, Flex is provably unchanged, scope is clean, and the fix now has in-app evidence. Before merge, decide item 1, the Demo/predicate gap that #6117 shares:

  • either a follow-up making SimBackend declare binsAbsolute,
  • or a statement in both PR bodies that the Demo is not covered.

The end-point constants are still the open maintainer question from the earlier round.

on8st and others added 2 commits October 3, 2026 22:51
Brings the branch onto main at d9de044 so CI runs against it. The
merge is textually clean; nothing on main since c7513e1 touches the
waterfall level law or its test.

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

The capabilities map now states the inference in the direction the code
needs it (no waterfall plane, so the row is the pan frame, so it is dB)
and that binsAbsolute stands in for it: true for HL2, ANAN and RTL-SDR,
not declared by SimBackend, whose dB rows therefore keep the tile law
and stay black on Off. HERMES.md and the map drop the bug's history and
keep the invariant.

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

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first, two items.

  1. You are right: the Demo is not covered, and this push only says so. Confirmed from the source, not run: SimSignalSource.cpp:137, SimBackend.cpp:47, and no panAmplitude record in SimBackend::capabilities().
  2. The merged tree is not green here: 614 of 616 (macOS, Qt 6.12.0). hl2_pan_create_async_test (fix(hl2): drop spectrum and meter output from a closed receiver's DSP. Principle VIII. #6113) and peripheral_auth_dialog_test (feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections #6008) fail, identically on clean main at d9de044c. a54dff9c was 611 of 611.

Pushed 700c2d7e, a merge of main (d9de044c) and one docs commit, no rebase:

  • radio-capabilities-map.md states the inference your way round, calls binsAbsolute a proxy, and lists the Demo as not covered.
  • Both doc paragraphs keep the invariant and drop the history (nit 3).
  • CI re-runs on current main (nit 1).

Yours to pick: (a) or (b). I would take (a), as its own PR, naming the Demo's auto-floor change. I will not open it unasked. (b) is not sized.

#6117. git merge-tree against its head is still clean. It takes this PR's name, RowKind::AbsoluteDb.

Left. SpectrumWidget.h:1642: touching it pulls that header into the a11y scan (two unrelated warnings). The SW seed stays a follow-up. Nothing new on the end points.

On those two failures: the HL2 one fails here on clean main every time (60 of 60), and its cause is in the test, not in the guard; I posted it on #6113. The other one I have not traced beyond a layout that needs 680 px in a 640 px viewport on this Mac.

73, ON8ST

@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Maintainer ruling on #6115 and #6117: merge both as they stand. They're correct for HL2, ANAN and RTL-SDR, and the bodies now say Icom and the demo aren't covered.

For the gap, I agree with you: option (a), a row-unit signal set where onBackendSpectrumFrame emits the row, as its own follow-up PR. If it changes the demo's auto-floor, name that in the PR. Please go ahead and build it.

73, Jeremy KK7GWY

ten9876 added a commit that referenced this pull request Oct 3, 2026
…le VIII. (#6134)

Refs #6113, #6008. on8st reported both failures on #6115 and #6113.

on8st sees 614 of 616 tests pass on macOS (Apple silicon, Qt 6.12.0,
clean `main` at `d9de044c`). The two failures are
`hl2_pan_create_async_test` and `peripheral_auth_dialog_test`. Linux CI
passes both.

**I have not verified this on macOS; there is no Mac here.** I
reproduced both failures on Linux by forcing the macOS conditions, as
described below.

## 1. `hl2_pan_create_async_test`: "the reuser has its own chain" (test
bug; the product guard is correct)

**Cause.** The check compared the reopened receiver's DSP with the
retired one by raw address: `newDsp != oldDsp`. In the "deleted" run,
the retired chain is freed before `createPanadapter()` builds the new
one. The macOS allocator then returns the same 536-byte block. on8st
printed the addresses and saw `old == new` in 30 of 30 runs. In the
"still alive" run the old chain is still allocated, so the addresses
differ.

**Linux reproduction.** I wrote an `LD_PRELOAD` shim (not committed)
that keeps the most recently freed block of a given size and returns it
to the next request of that size, whatever thread makes it. That is what
a size-class allocator does. With `REUSE_LO=REUSE_HI=536`
(`sizeof(Hl2RxDsp)`), unpatched `main` fails 3 of 3 runs with exactly
on8st's result: only `[FAIL] the reuser has its own chain`, in the
"deleted" run only.

**The product guard holds when an address is reused.** In those same
runs the stale frame and stale meter checks pass, as on8st saw in 60 of
60 runs. The forwards capture `producer = QPointer<Hl2RxDsp>(dsp)`. A
`QPointer` follows the QObject's shared reference-count block, not its
address. When the old DSP is destroyed, `producer` becomes null.
`producer.data()` then returns `nullptr`, not the old address, so a new
object at that address cannot pass `receiver->dsp != producer.data()`.

**Fix (test only).** Compare with `oldGuard.data()`, the test's existing
`QPointer`, instead of the raw address. The retired chain has been
deleted in both runs by this point; run 2 deletes it in the KeepAlive
block. The check therefore means: a chain exists behind UI 1, and the
retired one is gone. Its label now says that. Run 2 still exercises the
identity term through the stale frame and stale meter checks, which are
unchanged.

**Validation (Linux, GCC, Qt 6.12).** Plain build: 0 failures in 5 of 5
runs. With the reuse shim: 0 failures in 3 of 3 runs, where unpatched
`main` failed 3 of 3.

## 2. `peripheral_auth_dialog_test`: "Clear code or Status is clipped at
the default dialog size" (depends on the host's serial ports)

on8st saw a layout that needs about 680 px in a 640 px viewport. On
Linux the same page needs 502 px in a 644 px viewport.

**Likely cause.** The Peripherals page has three serial-port combos:
ACOM, SPE and LP-100A. Each is filled from
`QSerialPortInfo::availablePorts()` before it is first shown, with items
labelled `"<portName> — <description>"`. The combos keep Qt's default
`AdjustToContentsOnFirstShow`, so each one's minimum width is its
longest label. That puts the host's port names into the page's minimum
width.

CI runners only have short names like `ttyS0 — `. A Mac has `cu.*` and
`tty.*` ports such as `cu.Bluetooth-Incoming-Port` or
`cu.debug-console`, plus any USB-serial radio with a long description.

**Linux reproduction.** I injected one label into the serial combos and
measured the page (probe code, not committed). Production does not need
the stylesheet nudge my probe used: it fills the combos before the first
size hint is computed.

| widest label | combo minimum | page minimum | viewport 644 |
|---|---|---|---|
| `ttyACM0 — ESP32S3 DEV` (this machine) | 176 | 502 | fits |
| `cu.Bluetooth-Incoming-Port — ` | 202 | 528 | fits |
| `cu.usbserial-A10KC1QZ — FT232R USB UART` | 288 | 614 | fits |
| `cu.usbmodem1101 — IC-7300 USB Serial Port Interface` | 340 | 666 |
**fails**: horizontal scroll max 22, test exits on this check |

**What this PR changes (test only).** When the fit check fails, the test
now prints:
- the dialog size, the viewport width, the page minimum and the
horizontal scroll maximum;
- each serial combo's minimum width and its widest item.

The test still fails in that case. I have not seen on8st's port list, so
the next macOS run should confirm or rule out the cause.

**Not in this PR: a product fix, which needs a decision.** If the
diagnostic confirms the cause, the real app does the same on that Mac:
the Peripherals tab scrolls sideways at the default size, and Clear and
Status move off screen. The likely fix is three lines on those combos:
-
`setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon)`
- `setMinimumContentsLength(~18)`

The closed combo would elide long labels instead of widening the grid.
That is a UX choice, so it waits for the maintainer.

## Request

@on8st, could you run both tests on this PR's head?

```
ctest -R '^(hl2_pan_create_async_test|peripheral_auth_dialog_test)$' --output-on-failure
```

If `peripheral_auth_dialog_test` still fails, please paste the lines
under "clipped at the default dialog size".

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

https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 self-assigned this Oct 3, 2026
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

We're updating this branch against main and landing it (maintainer pass); please don't push to it meanwhile. New commits go on top, with no rebase or force-push.

ten9876 and others added 3 commits October 3, 2026 16:11
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Principle VIII.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Maintainer pass. Answers to the two reviews' points (Jeremy's 5402532542 and aethersdr-agent's 5401871688, both body-only). The head is a3758582: on8st's 700c2d7e, plus signed merges of main up to 52b6f7ad (which includes #6117, no conflicts), plus one docs commit.

Should-fix 1: Demo and Icom, the predicate as proxy. Ruled 2026-10-03: merge as it stands, correct for HL2, ANAN and RTL-SDR. Option (a), a row-unit signal set where onBackendSpectrumFrame emits the row, comes as on8st's follow-up PR. a3758582 adds Icom (IcomCivBackend, scope rows through toDbm) next to the Demo in radio-capabilities-map.md's not-covered paragraph, and the PR body now opens with the same statement.

Sibling #6117. It merged as 52b6f7ad. The two paragraphs in radio-capabilities-map.md are reconciled in a3758582:

The names agree: rowsAreAbsoluteDb here and RowKind::AbsoluteDb there.

End points and default. These are the maintainer's call, ruled "as they stand". No change.

Nits

  1. Stale CI: fixed. CI re-runs on a3758582, which contains current main.
  2. SpectrumWidget.h:1642 "0-125": not changed. It is pre-existing, and the ruling made nits optional. on8st's reason holds: touching that header pulls unrelated a11y warnings into this PR's scan.
  3. History in the docs: already dropped by on8st in 700c2d7e. Verified.

The SW seed (m_autoBlackThresh{145}) and the fallback dbmToWaterfallLevel law stay as listed follow-ups. They are outside this fix.

Verification on Linux/GCC (Qt 6, RelWithDebInfo) at a3758582

  • Full build is clean.
  • waterfall_manual_black_test: 16 checks, 0 failed. Mutation M1 gives 4 failed, as the body states.
  • waterfall_impulse_blanker_test, from fix(gui): NB Blank works on a dBm waterfall row. Principle VIII. #6117, passes in the merged tree.
  • ctest excluding socket-owning tests: 100% of 481 passed. 124 tests were left out because they are socket-owning by name or by a grep of their sources.
  • Every Static checks script exits 0. The colour ratchet is +0, and a11y reports 0 findings.
  • The fix was not observed in the app in this pass. The in-app Demo evidence is in Jeremy's review: the flag flip makes Off light the floor.

Maintainer pass by Claude for @ten9876.

@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.

Approving at a3758582 (maintainer pass, pr-land).

Maintainer ruling (2026-10-03): merge as it stands. It is correct for HL2, ANAN and RTL-SDR. Icom and the demo are not covered, and the body and radio-capabilities-map.md both say so. Option (a), a row-unit signal emitted from onBackendSpectrumFrame, follows as on8st's own PR. The end points (-60 to -160 dB) and the default of 15 stay as they are.

Review findings, all addressed:

  • Naming (rowsAreAbsoluteDb): on8st, a54dff9c.
  • Test-scope comments: on8st, a54dff9c.
  • Docs state the invariant, not the history: on8st, 700c2d7e.
  • Icom added to the not-covered paragraph, and the paragraphs reconciled with #6117's (now on main as 52b6f7ad): this pass, a3758582.
  • SpectrumWidget.h:1642 comment nit: left as it is, since it is pre-existing and the ruling made nits optional.

All 4 threads were answered with evidence and resolved. Both body-only reviews were answered in a top-level comment.

Verified empirically (Linux/GCC, RelWithDebInfo, at a3758582):

  • Full build is clean.
  • waterfall_manual_black_test: 16 checks, 0 failed, and mutation M1 gives 4 failed.
  • waterfall_impulse_blanker_test passes in the merged tree.
  • ctest excluding socket-owning tests: 481 of 481 passed. 124 were excluded by name or by a source grep.
  • All Static checks scripts exit 0. The colour ratchet is +0, and a11y reports 0 findings.

Reasoned, not observed: behaviour on HL2, ANAN and RTL hardware.

Freshness: the CI run on this head was created at 2026-10-03T23:53:29Z. main's tip 52b6f7ad dates from 23:46:21Z and is in the merged tree, so there is no drift.

@ten9876
ten9876 enabled auto-merge (squash) October 3, 2026 23:54
@ten9876
ten9876 merged commit e969d21 into aethersdr:main Oct 4, 2026
5 checks passed
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 4, 2026
main gained aethersdr#6138, aethersdr#6115 and aethersdr#6027 since this branch was cut. No
textual conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 4, 2026
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>
ten9876 pushed a commit that referenced this pull request Oct 6, 2026
…ite 2. Principle III. (#6140)

**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.
- **#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 #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 #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` (#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. #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; #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 #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

- **#5924** (merged, and merged in here at `295bff6e`): it conflicted in
`ClientDisplaySettings.h`, `MainWindow.h` and `MainWindow_Wiring.cpp`,
all resolved by keeping both sides. The tables do not overlap and its
pending-write key has its own prefix.
- **#5919** (merged) is in this branch through `main`.
- #6117 and #6115 (both merged) are in this branch.
- **#6129** (open, head `04762258`): see the update block at the top.
- **#6139** (open, ours): both PRs add to `RadioCapabilities.h` and to
each backend's `capabilities()`, in different places. A trial merge of
`7a5ca748` with its head `98be49a1` is clean; the merged tree was not
compiled.

## 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](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <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.

2 participants