Repository navigation
fix(gui): manual Black Level works on a dBm row. Principle VIII. - #6115
Conversation
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>
There was a problem hiding this comment.
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
DisplayWfBlackLevelis15(SpectrumWidget.cpp:2501) → black point −75 dBm. Against your own seven measured floors the level is0.000at 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:6661calls the HL2 payload "DC-centred dBFS bins", andRtlSdrBackend.cpp:105is explicit that "the axis is dBFS relative to the converter's own full scale." The newrowsAreDbm/kManualBlackAtZeroDbmand 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 atSpectrumWidget.cpp:8538smooths 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 at8515-8538; it genuinely readsbinsIntensity), 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 topushRxWaterfallFallbackIfDue→dbmToWaterfallLevel(SpectrumWidget.cpp:12370), whose manual black point ism_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. (FWIWm_wfMinDbmis never assigned anywhere insrc/— 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 —
MainWindowactually 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()forstd::clamp→ 2 failures, so the NaN-ordering argument in the header comment is real and load-bearing, not decorative; and movingkManualBlackAtZeroDbm−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-7566pushes to everySpectrumWidgetchild on connect and disconnect (connected && caps.panBinsAbsolute(), so it resets to false on disconnect), andMainWindow_Wiring.cpp:3912covers 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:386andRtlSdrBackend.cpp:117all setbinsAbsolute = 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
dbmToWaterfallLevelandkiwiSdrWaterfallLevel; both are untouched and reached from different call sites, andwaterfallLevelToRgb/encodeWaterfallLevelare unit-agnostic. Nothing left behind. - Head freshness and CI. The task checkout was
67999c5c; I fetchedpull/6115/headand confirmed it is the same commit and thatf5f2b14is 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-gatingctestruns this test (everycteststep inci.ymlis-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
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>
|
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.
Done in The SW seed and the fallback law are follow-ups. Agreed: green CI did not run this test. 73, ON8ST |
ten9876
left a comment
There was a problem hiding this comment.
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-151emits host-computed float rows withkFloorDbm = -120.0.SimBackend.cpp:47-51relays them asspectrumFrameReady, so they go through the sameonBackendSpectrumFrame→panFeedWaterfallRowReadypath as the HL2.SimBackend::capabilities()(SimBackend.cpp:248-379) declares nopanAmplituderecord, sopanBinsAbsolute()is false and the manual branch keeps160 - levelon 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 - Lagainst 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
panFeedWaterfallRowReadyfromonBackendSpectrumFrameare 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-treeofa54dff9cwith63a73a35is clean, and so isa54dff9cwith currentmain(579efd73f). The hunks don't overlap:updateWaterfallRow's blanker versusintensityToWaterfallLevel. Thetests.cmakeblocks 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 saysRowKind::Dbmand "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)
- CI is stale against
main: runs were created at 18:34Z andmainmoved at 19:25Z (579efd73f). The trial merge is clean. No CIctest-Rfilter runswaterfall_manual_black_test, so green CI does not mean these 16 checks ran. - Pre-existing and not yours:
SpectrumWidget.h:1642still commentsm_wfBlackLevelas "0-125" while the setter clamps to 0..100. 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 thatget radioreturnedAetherSDR Demo/DEMO-0001. I toggled Black Level to Off viadisplayAutoBlackBtn, steppeddisplayBlackLevelSlider0/15/50/75/100, read each back withget display, and grabbedpan-visible 0each time. The table above is the result. The GPU build can't be grabbed offscreen ("Spectrum renderer unavailable"), so I rebuilt withAETHER_GPU_SPECTRUM=OFF. That changes the renderer, not the law, which both paths share viaintensityToWaterfallLevel. - 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
SimBackenddeclarebinsAbsolute, - 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.
…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>
|
Bad news first, two items.
Pushed
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. Left. 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 |
|
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 73, Jeremy KK7GWY |
…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>
|
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. |
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>
|
Maintainer pass. Answers to the two reviews' points (Jeremy's 5402532542 and aethersdr-agent's 5401871688, both body-only). The head is 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 Sibling #6117. It merged as
The names agree: End points and default. These are the maintainer's call, ruled "as they stand". No change. Nits
The SW seed ( Verification on Linux/GCC (Qt 6, RelWithDebInfo) at
Maintainer pass by Claude for @ten9876. |
ten9876
left a comment
There was a problem hiding this comment.
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:1642comment 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_testpasses 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 checksscripts 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.
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>
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>
…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>
#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>
…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>
Not covered: Icom and the Demo. Both send dB rows down the same path (
IcomCivBackendscope rows throughtoDbm,SimSignalSourceat a -120 floor) but declare nobinsAbsolute, 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 whereonBackendSpectrumFramesends the row on comes as a separate follow-up PR.radio-capabilities-map.mdstates the same gap.Second update,
700c2d7e: current main is merged in; the docs now callbinsAbsolutea proxy for the row unit and list the Demo as not covered (Icom added ina3758582). 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
Fixesline: I found no issue for this.Update after review,
a54dff9c. Two more things to read first, and one rename: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.rowsAreAbsoluteDb. Full ctest ata54dff9c: 611 of 611 (macOS, Qt 6.12.0, Ninja).Read first.
RadioCapabilities::panBinsAbsolute(), which HL2, ANAN and RTL-SDR declare. Only the HL2 was observed.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.get displayread back eachwfBlackLevelandwfAutoBlack: false, so the slider reached the widget.Cause
The manual black point in
SpectrumWidget::intensityToWaterfallLevelwas160 - level: 160 down to 60, in Flex tile-intensity units (a tile isint16(raw) / 128, about 96..120 on HF).A radio whose spectrum is computed on this host has no waterfall plane.
RadioModel::onBackendSpectrumFramehands 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
SpectrumWidgetintogui/WaterfallLevelMap.has pure functions, becauseSpectrumWidgetlinks into no test target. The widget now gathers its state and calls it.m_panBinsAbsolute, the flag the widget already holds for the auto-floor gate:160 - level. Unchanged.-60 dBm - level, one dB a step.docs/HERMES.mdanddocs/architecture/radio-capabilities-map.mdrecord 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):qBound's comparison order, which sends a NaN sample to black;std::clampwould not.)SpectrumWidget.cpppassesm_panBinsAbsoluteasrowsAreAbsoluteDb, calls the shared law, and keeps no second copy of160 - level. No behavioural seam reaches it:SpectrumWidgetis 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 declarespanBinsAbsolute()and a Flex does not is pinned on real backends bynoise_floor_auto_adjust_gate_test.Red before green, re-run at this head. On
mainthe law is a private member of a widget no test links, so the test cannot be built againstmain; M1 ismain's law inside the new header.main's law)falseMutations were run with the test compiled on its own with Apple clang.
Full ctest on macOS (Qt 6.12.0, Ninja) at
67999c5c, which containsmainatc7513e12: 100% tests passed out of 611.Every script step of the
Static checksjob instatic-checks.ymlwas run locally with stdin from/dev/nulland is clean, the colour ratchet at +0.check_a11yon the two changed GUI files: no findings.Not verified
MainWindowpushing the capability into the widget.Beside this on main
Nothing on
mainoverlaps: the branch mergesc7513e12cleanly. The NB Blank fix for the same unit problem (branchhl2/waterfall-nb-blank-dbm) reads the same flag and merges cleanly with this one. The keyed (TX) waterfall rows go throughdbmToWaterfallLevel, which has its own black-level law and is untouched.73, ON8ST
🤖 Generated with Claude Code