Repository navigation
Fix Flex FFT headroom and range reply convergence - #6241
Conversation
There was a problem hiding this comment.
Reviewed at head 7725ebe2 against the PR checkout. All five checks are green (build, check-windows, check-macos, Static checks, Sanitizer option configures). Findings below are reasoned from code — I have no build, no test run, and no way to drive the GUI here, so nothing in this review was reproduced at runtime.
1. Issue fit
Partially — and the PR says so, which I checked and agree with. This is Refs #6238, not Fixes, and the body is explicit that the band/Go-To/preamp bounce (R5/R6) is not claimed. Mapping #6238's acceptance list to the diff:
| #6238 case | Addressed? |
|---|---|
| R1 — bounded upper encoder headroom, sparse peak detection, explicitly not reusing the floor detector's occupancy threshold | Yes — fftPeakTouchesEncoderCeiling (src/gui/SpectrumPreviewLogic.h:421) is a per-bin test with no occupancy term, exactly as the issue directed |
| R2 — add lower headroom without sacrificing upper room, within min/max/span limits, terminate at the bound | Yes — clippedFloorRecoveryRange now pins the ceiling (src/gui/DbmRangeTransition.h:200-213), bounded at −180 and a 180 dB aperture; materiallyDifferent gives the no-op termination |
| R3 — sync the applied range on ordinary status; reply-only completion; supersession/rejection | Yes — setStreamDbmRange moved out of the encoderRecoveryPending branch (src/gui/MainWindow_Wiring.cpp:4097), plus Handshake::completeReply |
| R4 — validate the whole candidate before mutating handshake/cache/decoder/pending | Yes — dispatchValidatedRange orders validate → dispatch → commit |
| R5/R6 — settled floor acquisition | Partial, as disclosed: NoiseFloorReacquisition is added and the PR's own R5/R6 rows report the transients persist |
The core of R3 is a real pre-existing defect and the fix is the right shape: on main, an ordinary levelChanged reached sw->setDbmRange() but never PanadapterStream::setDbmRange(), so a foreign-driven range change left the VITA decoder on the old aperture. That's worth having.
2. Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/gui/DbmRangeTransition.h |
dispatchValidatedRange, completeReply, endpoint-preserving recovery helpers |
Yes (R1–R4) | In scope |
src/gui/SpectrumWidget.{h,cpp} |
encoder-range cache, peak-clip detector, reacquisition gate, applyDbmRangeImmediate ref policy, rendered-trace diagnostics |
Yes | In scope |
src/gui/SpectrumPreviewLogic.h |
NoiseFloorReacquisition, fftPeakTouchesEncoderCeiling |
Yes | In scope |
src/gui/MainWindow_Wiring.cpp |
range dispatch rewrite, rewire epoch, status cache refresh | Yes | In scope — but see Blocker 1 for a non-Flex side effect |
src/gui/MainWindow_Session.cpp |
one setEncoderYPixels call |
Yes | In scope |
src/models/RadioModel.{h,cpp} |
rejectsForeignPanWrite extraction; sendCmdPublic void→bool + the pan gate |
Yes | In scope. I verified the extraction is equivalence-preserving: panadapter() is m_panadapters.value(panId, nullptr) (RadioModel.cpp:5789) and ourClientHandle() is literally return clientHandle(); (RadioModel.cpp:10235) |
docs/automation-bridge.md |
adds encoderMinDbm/encoderMaxDbm/encoderRangeGeneration, noiseFloorReacquiring, nativeInputBins, renderedTrace to the trace snapshot |
Yes | Needs maintainer decision — no new verb, but new keys in an existing bridge reply are surface third parties bind to. Caveats are honestly written ("local diagnostics, not VITA wire tags", "does not certify scanout"), so this is a "is this the right reply to grow" call, not a defect |
docs/architecture/vita49-format.md |
new paragraphs; also deletes the file's trailing blank line | Partly | Nit — unrelated whitespace, harmless |
docs/architecture/aetherd-touchpoints.md |
DbmRangePlausibility.h count 1→2 |
Yes | In scope; the new #include "core/DbmRangePlausibility.h" in DbmRangeTransition.h explains it |
tests/*, tests/tests.cmake |
new assertions; panadapter_dbm_range_test joins AETHER_SETTINGS_CONSUMERS |
Yes | In scope. The CMake contract is satisfied: the target now instantiates RadioModel and uses TestSettingsProfile.h, and it is registered in AETHER_SETTINGS_CONSUMERS (tests/tests.cmake:7765) |
No CHANGELOG.md entry (correct). No new capability field, no new protocol verb, no settings key, no raw command-plane site. No removed guard that I could find — I read the - lines and the #3977 ownership comment block that was deleted from sendDbmRangeCommand is replaced by a stronger check in the same function (pan && isConnected() && ownedByClient, MainWindow_Wiring.cpp:3800-3804), so the protection is not lost.
3. Blockers
1. src/gui/MainWindow_Wiring.cpp:4306 — a Flex-scoped early return silently drops dBm-range persistence on the Hermes-Lite 2.
if (!sendDbmRangeCommand(minDbm, maxDbm)) {
return;
}sendDbmRangeCommand returns false at its very first gate when the backend does not own the dBm scale (MainWindow_Wiring.cpp:3790):
if (!m_radioModel.backendCapabilities().radioOwnsDbmScale) {
return false;
}On main the same code path read armDbmRangeHandshake(...); setStreamDbmRange(...); sendDbmRangeCommand(...); … adoptClientOwnedDbmRange(...) — sendDbmRangeCommand was void and simply no-op'd on a fixed-scale backend, so the other three still ran. After this PR the early return skips both setStreamDbmRange and adoptClientOwnedDbmRange.
That matters on exactly one family today: Hl2Backend.cpp:1799 sets amplitude.clientPersistsDbmRange = true (ANAN and RTL explicitly set it false, Icom and Sim declare no panAmplitude record at all), and clientPersistsDbmRange() additionally requires binsAbsolute, which HL2 has. So on an HL2:
- Operator clicks the dBm strip's ±10 dB arrow.
SpectrumWidget.cpp:9996mutatesm_refLevel/m_dynamicRangedirectly and emitsdbmRangeChangeRequested, so the display does move. autoFloorChangeis false (this is an operator gesture, not auto floor),profileLoadHeldis false, solocalOnlyis false and control reaches 4306.sendDbmRangeCommandreturns false →adoptClientOwnedDbmRange()never runs →pan->setRange()is never called andClientDisplaySettings::saveDbmRange()is never scheduled. The operator's new range is lost at the next profile restore / restart.
The sibling handler shows the intended shape — dbmRangeDragFinished has its own explicit branch for this case at MainWindow_Wiring.cpp:4375-4381:
if (!m_radioModel.backendCapabilities().radioOwnsDbmScale) {
sw->setDbmRange(minDbm, maxDbm);
setStreamDbmRange(minDbm, maxDbm, true);
adoptClientOwnedDbmRange(applet->panId(), sw->panIndex(),
minDbm, maxDbm);
return;
}so the drag gesture still persists correctly; only the click/arrow gesture regressed. Suggested fix — add the same branch immediately before line 4306, keeping the existing emittingDssZoomFloorDbmRange() guard on the adopt:
if (!m_radioModel.backendCapabilities().radioOwnsDbmScale) {
setStreamDbmRange(minDbm, maxDbm, true);
if (!sw->emittingDssZoomFloorDbmRange()) {
adoptClientOwnedDbmRange(applet->panId(), sw->panIndex(),
minDbm, maxDbm);
}
return;
}
if (!sendDbmRangeCommand(minDbm, maxDbm)) {
return;
}Two notes so this is scored fairly. First, the handshake half of the same change is an improvement: main armed a 2 s handshake on a radio that can never echo, which is the hazard the 4375 comment names, and this PR stops doing that. Second, nothing in the suite would have caught the persistence loss — tests/client_display_pan_settings_test.cpp is the only test touching dbmRangeChangeRequested, and it does so via a source-text followsWithin() grep and direct ClientDisplaySettings calls, never through the handler. A socket-free check that an HL2-shaped capability set still reaches saveDbmRange on this gesture would be the natural guard, and it's a deterministic seam.
4. Needs maintainer decision
src/gui/SpectrumWidget.cpp:3690-3717 + SpectrumPreviewLogic.h:421 — the ceiling half of recovery can ratchet the operator's radio-owned max_dbm to the +20 dBm cap and never give it back.
fftPeakTouchesEncoderCeiling fires when any single bin is within 1.5 × rowStepDb of m_encoderMaxDbm. On a −130/−40 pan at y_pixels=700 that threshold is ≈ 0.19 dB, i.e. VITA pixel row 0 or 1 — genuinely saturated, so detection is correct. The response is what I'd want a ruling on:
candidate = DbmRangeTransition::clippedPeakRecoveryRange(
candidate.minDbm, candidate.maxDbm, peakClipped ? kRecoveryHeadroomDb : 0.0f);kRecoveryHeadroomDb is 24.0, rate-limited to one request per kRecoveryRequestIntervalMs (1000 ms), and the post-write settle gate is kFrequencyRangeSettleMs = 300 ms. The call site at SpectrumWidget.cpp:8545 is unconditional in updateSpectrum — pre-existing on main, and its comment says so ("even when client-side automatic floor placement is disabled"). So the scenario: operator deliberately sets −130/−40 to work a weak signal next to a local S9+40 broadcaster. The strong peak saturates row 0 every frame, and the client issues max_dbm=−16, then +8, then +20 over about three seconds, where clippedPeakRecoveryRange's std::min({… , 20.0f, …}) terminates it. Because the request is sent with encoderOnly = true, the presentation axis is deliberately preserved, so the operator sees no range change in the UI while the radio-owned max_dbm has moved 60 dB. Pulling it back by hand re-triggers the ratchet on the next frame.
I'm not calling this a Constitution violation, because #6238's own R1 acceptance text explicitly directs "do not reuse the floor detector's broad occupancy threshold as the only peak detector" — the absence of an occupancy term is the requested design, not an oversight. But an automatic, repeated, invisible-to-the-operator write to radio-owned state (Principle II/III territory) with no persistence requirement and an irreversible terminal state is a policy call, and the floor half has always had a run-length gate (dssFrameFloorLooksClipped) that bounds its false-positive rate while this one has nothing. Options that stay inside the issue's guidance: require the ceiling-touching peak to persist across N consecutive frames (persistence, not occupancy); stop expanding at the operator's stored/adopted ceiling; or gate the ceiling half on m_noiseFloorEnable so it cannot fire in the Auto-Floor-OFF regime the PR declares out of scope (tests/spectrum_confirmed_geometry_test.cpp asserts the opposite today — "a sparse clipped peak requests upper headroom even with auto floor disabled").
A second-order consequence worth stating in the body either way: both halves now move one endpoint, so every recovery event permanently widens the aperture and nothing ever narrows it. On main floor recovery was a translation, so the span — and therefore the ~0.13 dB/row amplitude LSB — was conserved. The new behaviour trades vertical resolution for headroom monotonically. That is the correct fix for R1/R2 as specified; it is not a free trade and the PR body doesn't name the cost.
5. Nits (non-blocking)
src/gui/SpectrumWidget.cpp:3978-3982— the newif (m_radioOwnsDbmScale && flexInputFloorLooksClipped()) return false;inupdateNoiseFloorBaselineis unconditional on whether recovery can actually succeed. On a MultiFlex-shared pan owned by another client whosemin_dbmsits above the real noise floor,sendDbmRangeCommandrefuses (:3800) and this early return then blocks baseline acquisition for the whole session, so auto floor never converges.mainacquired a (wrong) baseline from the clipped frame. Pairing this with "…and a recovery request is eligible" would keep the intent without the deadlock.src/gui/SpectrumWidget.cpp:3707/:3717—requestFlexRadioHeadroomreturnsfloorClippedeven on the saturated no-op path, so oncemin_dbmis pinned at −180 with a still-clipped floor,applyNoiseFloorAutoAdjustreturns early every frame forever. Same shape onmain, so not a regression, but the new:3978return makes it a two-sided freeze instead of one.src/gui/MainWindow_Wiring.cpp:3677-3680— the rewire guard rides a dynamic"_aetherDbmRangeWireEpoch"QObject property. It works, but a stringly-typed property onSpectrumWidgetis invisible to the compiler and to anyone grepping the widget's header; aquint64 m_dbmRangeWireEpochwith an accessor would be greppable and is the same amount of code.src/gui/MainWindow_Wiring.cpp:4097—setStreamDbmRange()on every ordinarylevelChangednow reachessetEncoderDbmRange() → prepareForFftScaleChange(), which arms a fresh 750 ms settle plus reacquisition. While a foreign client drags its own range, our auto floor stays in perpetual reacquisition.setEncoderDbmRange's equality guard bounds the no-change case, so this only bites during sustained foreign motion.src/gui/DbmRangeTransition.h:206-213—clippedPeakRecoveryRange'sstd::min({currentMaxDbm + step, 20.0f, …})lowers the ceiling if the radio ever reportedmax_dbm > +20, whichdbmRangeLooksPlausiblepermits up to +80. FlexLib caps at +20, so this is unreachable today; astd::max(currentMaxDbm, …)floor on the result would make it unreachable by construction.src/gui/SpectrumWidget.h:288-296—setEncoderYPixelsbumpsm_encoderRangeGenerationandm_encoderRangeChangedMsbut, unlikesetEncoderDbmRange, does not callprepareForFftScaleChange(). The asymmetry is probably deliberate (a pixel-count change has its own settle path viaprepareForFftPixelScaleChange), but it isn't commented and the two setters sit adjacent.m_encoderYPixelsdefaults to 700. IfsetEncoderYPixelsnever runs (pan absent at wire time) the derivedrowStepDbis wrong by the ratio of the realy_pixelsto 700. Only a 1.5-row tolerance rides on it, so the practical effect is small.- Coverage placement:
spectrum_confirmed_geometry_test— which carries all of the widget-level proof (reacquisition integration, the ceiling detector's wiring, the newapplyDbmRangeImmediateref policy) — is behindif(AETHER_BUILD_SPECTRUM_GESTURE_TEST)attests/tests.cmake:2213, so CI never builds or runs it. The PR discloses this. The pure helpers inspectrum_preview_logic_testandpanadapter_dbm_range_testare default-graph targets, which is the right home for them. Also note the geometry test's settled-sequence assertions depend on wall clock (wait(60)×2 against the gate'snowMs - m_firstMs < 100), a ~20 ms margin; waits only lengthen under load so it should hold, but it is timing-coupled.
6. What I tried to break and could not
- Lambda lifetime in
dispatchValidatedRange. The[&]captures looked alarming for a function that registers an async callback. They're safe:dispatchValidatedRange(DbmRangeTransition.h:21-29) invokes bothdispatchandcommitsynchronously before returning, and the reply callback handed tosendCmdPubliccaptures by value (this, guard, pan, pendingDbm, generation, previousRange, …). No dangling reference. *generationread before it's written. The reply callback reads*generation, which the commit lambda sets after dispatch. IfsendCmdever invoked the callback inline on a drop,completeReply(0, …)would run against an inactive handshake and returnIgnore— and in factsendCmdPublicreturnssendCmd(...) != 0, so a drop returns false andcommitnever runs at all. Both orderings are safe.- Teardown / rewire / foreign-pan races on the reply path. The callback guard at
MainWindow_Wiring.cpp:3815-3821checksQPointer<SpectrumWidget>,QPointer<PanadapterModel>,isConnected(), the wire epoch, pan-identity re-lookup, and re-confirms ownership. I tried widget deletion, pan recreation under the same id, disconnect mid-flight, and a doublewirePanadapter()— each is covered by one of those five. - Stuck
m_pendingDbmRangeEchoafter the early returns. I expected the droppedarmDbmRangeHandshaketo orphan the widget's pending guard. It self-expires:setDbmRangeatSpectrumWidget.cpp:7917-7921clears it oncenowMs - m_pendingDbmRangeEchoStartMs > kDbmRangeHandshakeTimeoutMs, andupdateNoiseFloorBaseline:3952-3958does the same. Bounded at 2 s, same as the handshake it replaced. - A drag snapping back when the reply lands. The new
applyDbmRangeImmediatebranch recomputesreffromm_noiseFloorBaselineDbm + m_noiseFloorTargetFrac * dyn, which looked like it would discard the operator's drag oncepan->setRange()→levelChanged→setDbmRange()came back. Both emitters callrefreshNoiseFloorTarget(true, true)synchronously before emitting (SpectrumWidget.cpp:11404and:11449, and:9994on the click path), som_noiseFloorTargetFracalready describes the operator's new placement and the formula reproduces theirref. - Div-by-zero / empty-bin crashes in the new math.
rowStepDbdivides bym_encoderYPixels - 1— the setter guardsyPixels > 1and the default is 700.*std::min_element(m_bins...)is behindm_bins.isEmpty()in bothflexInputFloorLooksClippedandrequestFlexRadioHeadroom.fftPeakTouchesEncoderCeilingrefuses non-finite inputs androwStepDb <= 0. - An aperture-shrinking
clippedFloorRecoveryRange.max{min - step, −180, max − 180}raises the floor when the span already exceeds 180 dB. Unreachable:dbmRangeLooksPlausiblecapsrangeDbat 180 (src/core/DbmRangePlausibility.h:27) and both recovery helpers use the same bound. - Widening
sendCmdPublic's pan gate onto unrelated callers. 26 call sites across rigctl, TCI, firmware/waveform/DVK upload, memories, XVTR, profiles. None emits adisplay pan setcommand, so the new refusal can only fire on the two raw pass-throughs (RigctlProtocol.cpp:2107,TciProtocol.cpp:1652), where it strictly adds the #3977 protection. Thevoid→boolchange is source-compatible for all of them. - Per-frame cost of the new diagnostics.
recordRenderedTraceaddsestimateNoiseFloorDbmto both render paths. It's stride-sampled to ~512 reads, branch-only, zero allocation (src/gui/NoiseFloorEstimator.h:15-40), so this is noise on a 30 Hz pan. Withdrawn as a finding. - Tests that would pass unfixed. The recovery-range assertions genuinely discriminate:
CHECK(std::abs(clippedRecoveryRange.maxDbm - -15.5f) < 0.01f)demands the preserved ceiling wheremainproduced −21.5. ThecompleteReplyandNoiseFloorReacquisitionsuites exercise API that doesn't exist onmain, so they can't be self-confirming.fftPeakTouchesEncoderCeiling's negative cases (visible axis, zero row step, NaN) are real.
Could not check: any runtime behaviour; the FlexLib claim in docs/architecture/vita49-format.md ("FlexLib 4.2.18's LowDbm/HighDbm reply handlers also accept this") — this is a clean-room repo and FlexLib is not in the checkout, so that Principle I citation is untestable from here and only a maintainer with the reference can settle it; and KN7K's Windows/FLEX-8600/firmware-4.1.5 environment, which the PR already lists as unverified.
One behaviour I'd flag for the maintainer rather than the author: Handshake::completeReply (src/gui/DbmRangeTransition.h:115-128) returns m_requestedRange on an accepted reply even when an intervening radio status reported a different range. The PR's own test documents it — arm −128/−10, observeRadioRange(-130, -20), then an accepted reply yields −128/−10. That is the client's own write winning over a radio status it had already received, which is the Principle II seam. It may well be the right call for a firmware that doesn't echo its own accepted range (which is R3's whole premise), but it is a judgment the maintainer owns, not something I'd want settled implicitly by a test assertion.
7. Recommendation
Request changes, for Blocker 1 only — a one-branch fix, and the HL2 persistence loss is a user-visible regression in a family this Flex-scoped PR shouldn't be touching. Everything else here is good work: the R3 decoder-staleness fix is a genuine pre-existing defect, the validate-before-commit ordering is the right seam, the callback guard is unusually thorough, and the limits section is honest about what the proof does and doesn't cover. Next step: add the !radioOwnsDbmScale branch before MainWindow_Wiring.cpp:4306 (ideally with a socket-free check that an HL2-shaped capability set still reaches saveDbmRange on the strip-click gesture), then get a maintainer ruling on the ceiling-ratchet policy in §4 and on the new renderedTrace/encoder keys in the bridge reply before merge.
Thanks for the detailed body — the per-regression table and the explicit "not verified" list made this much faster to review against #6238's acceptance criteria.
🤖 aethersdr-agent · cost: $15.1824 · model: claude-opus-5
NF0T
left a comment
There was a problem hiding this comment.
Independent review at head 7725ebe2. I formed this before comparing against the @aethersdr-agent review. I agree with its outcome on the one blocker, but its mechanism is wrong, which changes the fix. Details are below.
What's right
- R3 is a real pre-existing defect, and the fix has the right shape. On
mainan ordinarylevelChangedreached the widget but neverPanadapterStream::setDbmRange(), so the GUI'sencoderDbmRangecache went stale. MovingsetStreamDbmRange()out of theencoderRecoveryPendingbranch (MainWindow_Wiring.cpp:4096) fixes exactly what #6238's liveC317capture shows: a translation of the stale-130/-40instead of the applied-80/-10. - R4: validate, then dispatch, then commit, is the right seam.
dispatchValidatedRangeruns both lambdas synchronously, so the[&]captures are safe. The reply callback captures by value. A drop that invokes the callback inline (sequence 0) reachescompleteReply(0, …)against an inactive handshake and returnsIgnore.PanadapterModel::setRange()is a pure setter, so reply-driven convergence cannot loop back to the radio. - The endpoint bounds hold up against FlexLib 4.2.20.41343.
Panadapter.LowDbmclamps at-180.0andHighDbmat20.0(Panadapter.cs:448,:480), matchingclippedFloorRecoveryRangeandclippedPeakRecoveryRange. - The foreign-pan gate on
sendCmdPublicis equivalence-preserving. I agree with the bot. It strictly adds #3977 protection to the two raw pass-through callers. - The partial-repair framing is honest.
Refs #6238, notFixes, plus the explicit R5/R6 "transients persist" rows, matches what the code does.
Blocker: Hermes-Lite 2 dBm-range persistence (#6140) is lost on both operator gestures
MainWindow_Wiring.cpp:4306 (scale-strip arrows) and :4383 (drag finished) now return before adoptClientOwnedDbmRange() whenever sendDbmRangeCommand() returns false.
The mechanism differs from the earlier review, and that matters for the fix. The HL2 does not fail the radioOwnsDbmScale gate. It leaves that capability at its default true (RadioCapabilities.h:439; Hl2Backend.cpp:1802 says so explicitly). It passes plausibility and pan ownership: ownedByClient() fails open on an unknown owner, and clientHandle() is 0 without a RadioConnection. The failure comes one step later. RadioModel::sendCmd() hits !hasCommandPlane() and returns 0, so sendCmdPublic() returns false, dispatchValidatedRange() returns false, and the handler returns before the adopt.
Two consequences follow:
- The drag path is broken too, not only the click path. The
!radioOwnsDbmScalebranch at:4375that the earlier review cited as "still persists correctly" never runs on an HL2, because the flag istruethere. Drag falls through to the same early return at:4383. - That review's suggested fix would not help the HL2, for the same reason: a new
!radioOwnsDbmScalebranch before:4306is unreachable on the one family it targets.
Only the HL2 declares clientPersistsDbmRange (ANAN and RTL set it false; Icom and Sim declare no panAmplitude), so this is the only affected family. It is the persistence #6140 shipped two commits before this PR's base (2e3d7ae7f, "the dBm range survive[s] a restart on the Hermes-Lite 2").
The existing guard catches it, but CI doesn't run that guard per PR. client_display_pan_settings_test pins precedes(wiring, "sendDbmRangeCommand(minDbm, maxDbm);\n", "if (!sw->emittingDssZoomFloorDbmRange()) {") ("the send comes before the mark is read"). I evaluated every dBm-range pin in that test with its exact precedes/followsWithin/count semantics against the git blobs. At the base (30882701) all 9 pass. At this head, that one fails: the statement literal no longer exists, because both sites are now if (!sendDbmRangeCommand(...)). The test is in the default graph but not on the frozen per-PR allow-list, so it would first go red in full-suite.yml on main after merge. The PR's "6/6 selected tests" didn't include it.
Suggested fix (inline below). In both handlers, call sendDbmRangeCommand() as a statement and let the adopt run unconditionally:
adoptClientOwnedDbmRange()already returns early unlessclientOwnsPanDbmRange(), and it re-checksdbmRangeLooksPlausible(). On a Flex the adopt is a no-op, so the early return does no work there.- On the HL2 this restores
main's behaviour exactly. It also keeps this PR's improvement: no 2 s handshake is armed on a radio that can never echo, because the arm now lives inside the commit lambda. - The #6140 pin passes again unchanged.
Questions (non-blocking for me)
1. Should automatic, radio-persisted ceiling writes reach every Flex user? The ceiling half of recovery runs every frame from updateSpectrum (SpectrumWidget.cpp:8545), including with Auto Floor OFF, and there is no opt-out. It sends display pan set … max_dbm=, which is radio state the Flex saves per band: KN7K's -88 came back from the band stack (#5685). So this is a default-behaviour change for the whole Flex population, and part of it leaves the machine. An Auto-Floor-OFF operator's manual range can come back widened on the next band recall, and nothing on screen shows that it changed. The aperture also only ever widens, so vertical resolution drops monotonically. I read this as following #6238's R1 design, not as a defect. Is that reach intended, though? Would you be open to gating the ceiling half on Auto Floor, or on a persistence/run-length condition, so an Auto-Floor-OFF operator's stored range is left alone?
2. Is requested-wins on an accepted reply deliberate against Principle II? completeReply(accepted) lets the client's request supersede a radio status received after the command (DbmRangeTransition.h:123, pinned by the -128/-10 over -130/-20 assertion in panadapter_dbm_range_test). Principle II: "Where a command has no status echo the client may update optimistically, but the radio's subsequent status is the truth and supersedes the optimistic value if they differ."
- FlexLib's
SetLowDbmReply/SetHighDbmReplyreturn immediately onresp_val == 0(Panadapter.cs:460,:492). In FlexLib, a status that arrives before the reply therefore stands. finish()in the same class already prefersm_authoritativeRange, so the timeout and reply paths now disagree about the same intervening status.- I see the counter-case: a status in flight from before the radio processed the command lands in the same bracket, and last-received-wins would then keep a stale value. FlexLib has that mirror flaw. Either answer is defensible. Whichever you choose, it should be stated in the code against Principle II rather than settled only by a test assertion. If you keep requested-wins, a one-line comment naming the in-flight-status case would do it. If not,
m_authoritativeRange.value_or(m_requestedRange)is the change.
3. Can we get Windows evidence before this closes KN7K's report? All of the live proof is one 8400M on macOS/Metal. The reporter is on Windows 11, a FLEX-8600 and firmware 4.1.5, and Windows/D3D and Linux have no evidence at all. The decoder and recovery logic is platform-neutral, but recordRenderedTrace sits on both the QRhi and software paint paths. Could the R1/R2 recipe (MW, -178/-88 → recovery) be run on a Windows host before #5685/#6219 are treated as addressed?
Non-blocking / forward guidance
- I verified and agree with these earlier-review nits: the stringly-typed
_aetherDbmRangeWireEpochproperty (aquint64member would be greppable), the trailing blank line removed fromvita49-format.md, andspectrum_confirmed_geometry_testsitting behindAETHER_BUILD_SPECTRUM_GESTURE_TESTso CI never builds the widget-level proof. The PR discloses that last one. - When a PR touches the dBm handlers, add
client_display_pan_settings_testto the local selection. It is the only guard on the client-owned path.
Scope
| File / group | Claimed | Verdict |
|---|---|---|
DbmRangeTransition.h, SpectrumPreviewLogic.h, SpectrumWidget.{h,cpp} |
R1–R4 + reacquisition | In scope |
MainWindow_Wiring.cpp, MainWindow_Session.cpp |
dispatch/reply/cache | In scope; HL2 side-effect above |
RadioModel.{h,cpp} |
foreign-pan gate on both entry points | In scope. #5554 gate: no family branch, no new wire text (Static checks: 369 raw sites unchanged) |
docs/automation-bridge.md |
new traceDebugSnapshot keys |
In scope; diagnostic-only and honestly caveated |
docs/architecture/* |
touchpoint count, VITA notes | In scope (+ whitespace nit) |
tests/* |
new assertions | In scope; no socket/peer constructs |
Preference vs fix: every behavioural change traces to #6238's R1–R4 acceptance text. No preference changes are bundled. No CHANGELOG.md entry, which is correct.
What I tried to break
- HL2 gestures. I traced click and drag through
sendDbmRangeCommand→dispatchValidatedRange→sendCmdPublic→sendCmd's!hasCommandPlane()drop. Both now skip the adopt (blocker above). - #6140's source pins. All 9 dBm pins evaluated at the base and at the head, with exact helper semantics on the git blobs: 9/9 at the base, 8/9 at the head. Running the local MinGW binary directly was not a valid control: this checkout has
core.autocrlf=true, so its\nliterals fail onmaintoo. I didn't use those results. - Inline-drop reply ordering. The callback fires with generation 0 before commit and is ignored. On a dispatch failure, commit never runs.
- Accepted-reply equality. If
pan->setRange()returns false (no change), it falls back toapplyAuthoritativeDbmRange, so the decoder still converges. - Rejected reply with no status. The previous encoder range is restored through the same path.
- Reply-loop risk. None:
PanadapterModel::setRange()emitslevelChangedonly. - FlexLib bounds and reply semantics. Checked against the 4.2.20.41343 C# sources.
- Not driven. The automation bridge was unavailable in this session. Neither finding is reachable in demo mode anyway: Sim declares no client-owned range and has no Flex FFT encoder. Both findings are reasoned from code, plus the deterministic pin evaluation, not reproduced at runtime. No build was run here.
CI: all five checks green (build, check-macos, check-windows, Static checks, Sanitizer option configures). Commit is signed and GitHub-verified.
Verdict: request changes, for the HL2 persistence regression only. It's a two-site, two-line fix, and it restores the #6140 pin. The rest is careful work. The callback guard covers every teardown/rewire/foreign-pan race I tried, and the R3 cache fix is worth landing on its own. I'm happy to re-review promptly once the two call sites are changed, since #6242 is stacked on this.
73,
Ryan NF0T
👨🏼💻 Co-authored by Claude Opus 5.5
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
Partially — request changes. Reviewed 7725ebe251b7119a4e87236230423e6474a89ee2 against #6238 and the primary clipping reports #5685/#6219. R1/R2 map to endpoint-aware detection and bounded expansion; R3 maps to the ordinary-status cache update and reply completion; R4 maps to validate/dispatch/commit ordering. The partial-repair framing is appropriate: R5/R6 bounce and the separately tracked R7 repair are not claimed solved. However, the dispatch-success requirement now prevents both HL2 operator gestures from adopting and persisting their client-owned dBm range, regressing #6140 outside the intended Flex repair.
Scope
| File/group | Change | Claimed? | Verdict |
|---|---|---|---|
src/gui/DbmRangeTransition.h |
Validate-before-commit, reply completion, bounded endpoint expansion | Yes | In scope |
src/gui/SpectrumPreviewLogic.h, SpectrumWidget.{cpp,h} |
Sparse ceiling detection, settled reacquisition, separate encoder metadata, draw diagnostics | Yes | In scope |
src/gui/MainWindow_Wiring.cpp, MainWindow_Session.cpp |
Per-pan cache/dimensions, guarded replies and gesture dispatch | Yes | In scope; HL2 regression below |
src/models/RadioModel.{cpp,h} |
Dispatch result and shared confirmed foreign-pan refusal | Yes | In scope |
Three changed tests, tests/tests.cmake |
Range/recovery/reacquisition tests and settings isolation registration | Yes | In scope |
docs/architecture/vita49-format.md, docs/automation-bridge.md |
Range contract and additional diagnostic fields | Yes | In scope |
docs/architecture/aetherd-touchpoints.md |
Existing validator includers 1→2 | Yes | Disclosed and reproducible |
All 15 files are explained by the stated repair. There is one signed, GitHub-verified commit, authored October 7 at 00:40:25 UTC, with a matching subject. No unrelated dependency, default, theme, settings schema, CHANGELOG entry, or new bridge verb is bundled. The added diagnostic fields are an additive bridge response surface and are documented. The automatic ceiling recovery, including Auto Floor OFF, follows #6238's proposed endpoint repair; I do not classify it as an unrelated preference. Its radio-persisted effect deserves the maintainer attention noted below.
Blocker
-
src/gui/MainWindow_Wiring.cpp:4306and:4383— both HL2 operator gestures now return before adoption/persistence. These two new branches makeadoptClientOwnedDbmRange()conditional on successfully dispatching a Flex command. HL2 declares client-owned range persistence but leavesradioOwnsDbmScaleat its permissive default, so neither the send helper's initial gate nor the drag's fixed-scale branch handles it.RadioModel::sendCmd()drops the command because HL2 has no command plane; the new false result causes both handlers to skip the model update and deferred save. The changed scale can therefore be lost when the model reseeds it or the app restarts.A socket-free cold probe installed the real HL2 backend through
RadioModel::setBackendForTest()without connecting hardware and called the real dispatch method:HL2 cold declarations: radioOwnsDbmScale=1 clientPersistsDbmRange=1 hasCommandPlane=0 dispatched=0The existing
client_display_pan_settings_testfails on the head:FAIL: the send comes before the mark is read 1 check(s) failedThe same test passes on merge base
30882701a. The source pin's missing literal alone would be only a refactor-sensitive assertion. Here the actual backend declaration/drop plus both early-return paths independently establish the behavioral regression. This test is registered by default but outside the frozen per-PR test gate, explaining why hosted green does not settle it. I did not connect an HL2 or claim a full-app HL2 restart reproduction; the evidence combines the real backend/transport probe, the actual handler control flow, and the head/base test comparison.Restore adoption for a declared client owner independently of Flex dispatch. The minimal change is to replace each new three-line
if (!sendDbmRangeCommand(...)) { return; }block with the existing statement:sendDbmRangeCommand(minDbm, maxDbm);adoptClientOwnedDbmRange()already refuses non-owners and invalid ranges, so this is a no-op on Flex; the new send helper still commits no decoder/handshake state on refusal. Alternatively use an explicit capability-shaped local-owner branch, with the source pin updated to the intended ordering. Adding only a!radioOwnsDbmScalebranch would not fix HL2. Run the client-display guard and retain refusal/foreign-pan tests. This finding agrees with @NF0T's mechanism; the earlier bot's explanation that HL2 fails the radio-scale gate is incorrect.
Nits and maintainer questions — non-blocking
src/gui/DbmRangeTransition.h:123;tests/panadapter_dbm_range_test.cpp:159— make the accepted-reply/status ordering decision explicit. The real helper probe reportsstatus -130/-20 then accepted reply => -128/-10.finish()instead preserves the intervening authoritative range. Constitution II says, “Whenever the client's model and the radio's reported state disagree about what is live ... the radio wins and the client reconciles to it.” A status already in flight before this write is a plausible reason to prefer the accepted request, but arrival alone cannot distinguish that from a newer radio change. Jeremy: settle/document the intended contract and its evidence rather than treating a zero-code reply as a range-generation tag. I have reproduced the helper decision, not a live status-ordering failure, so I am not asserting an additional confirmed hardware defect.src/gui/SpectrumWidget.cpp:8545;tests/spectrum_confirmed_geometry_test.cpp:165— explicitly describe Auto Floor OFF reach in the Change section. The new ceiling half runs on every native frame, even when auto floor is disabled; the widget test intentionally pins that. This can widen a manually selected encoder range that Flex persists. The PR already lists #5525 as unproven, but that is a different limit from saying this new behavior is active with Auto Floor OFF. This is consistent with the linked repair proposal, not a request to invent an opt-out.docs/architecture/vita49-format.md:36— the exact FlexLib 4.2.18 reply-handler authority remains unverified here. There is no localreference/FlexLib_*source. @NF0T cites a 4.2.20 source check; that is useful corroboration but not my independent verification of 4.2.18. Keep the evidence version clear. Windows/8600/4.1.5, second owned pan, resize/y_pixels stress and post-TX remain honest disclosed limits; demo results cannot close those reports.tests/tests.cmake:2212— keep the opt-in real-widget test invocation reproducible. It requires-DAETHER_BUILD_SPECTRUM_GESTURE_TEST=ONand runs in neither default full-suite nor default sanitizer configurations. The body discloses opt-in, but giving this configure flag beside the test command makes the advertised six-test run directly repeatable. Do not expand the frozen per-PR gate to fix this.
Verification and attacks
-
Build/tests: built named targets only in isolated head and base worktrees, using Linux, Qt 6.12,
AETHER_SHARED_CORE=ON,-O2 -g1, and the explicit opt-in widget flag. HeadAetherSDRand all seven selected test targets build. The author's six named tests pass, includingspectrum_confirmed_geometry_test; the additionalclient_display_pan_settings_testfails as above and passes in a full merge-base build. No broad suite or local Windows/macOS run was attempted. The shared-core review link mode differs from the shipped static configuration. -
Mutation checks: separate
/tmpinclude overlays restored the bad moving ceiling (five failing assertions), replaced accepted completion with the old range (two), bypassed validation (five), and removed the settle deadline (one). Each mutant exits 1 while the original focused tests pass. These mutations validate the two helper suites; I did not independently repeat the author's production-widget mutation. PR sources remained unchanged. -
Lifecycle/refusal attacks: inspected invalid/NaN and dispatch-refused ordering, synchronous drop callbacks before generation commit, superseded replies, rejection with/without intervening status, matching status, timeout, disconnect, widget/pan destruction, same-ID replacement and wiring epoch changes. The synchronous dispatch lambdas do not escape; asynchronous reply captures are by value and guarded by object lifetime, connection, epoch, identity and ownership. The relevant socket-free range tests pass. This is code/test evidence, not a sanitizer churn run or a live multi-pan proof.
-
Demo bridge: drove only the scratch binaries offscreen with fresh settings, explicit per-instance sockets under
/tmp/pr6241-demo, identitiespr6241-review-headandpr6241-review-base*, andAETHER_AUTOMATION_NO_TX=1.connect local serial DEMO-0001,connect wait,get radio,get pans,get tracedebug,dumpTree, persistent-connectiongesture begin/move/end,tune,disconnectand reconnect exercised the real GUI. Both builds retain one slice at 1.050 MHz and one trace pane after reconnect; sampled serial/model areDEMO-0001/AetherSDR Demo,transmitting=false.The same settled 30-pixel scale drag demonstrates R3's model behavior on the demo's acknowledgement-without-range-status path:
{"baseWidgetRefBefore":-40,"baseWidgetRefAfter":-29.772727966308594,"baseWidgetSpan":90,"baseModelMin":-130,"baseModelMax":-40} {"headWidgetRef":-29.772727966308594,"headWidgetSpan":90,"headModelMin":-119.7727279663086,"headModelMax":-29.772727966308594,"headEncoderMin":-119.7727279663086,"headEncoderMax":-29.772727966308594}Head diagnostics also show 1024 finite input bins and increasing FFT sequence, with the added encoder metadata absent from the base response. All owned instances were disconnected and closed via
close MainWindow; none of the operator's instances was driven. No live radio was connected or TX keyed. The simulator acknowledges otherwise unmodeled commands, so this proves GUI reply convergence, not physical Flex encoder headroom or entitlement to infer radio wire generations. Offscreen GPU rendering producedrenderedTrace.valid=false; no pixel/scanout claim is made. Demo cannot represent HL2's declared client-owned range or the exact Windows/8600 clipping report. -
Governance/gates: engine, command-plane and capability findings are byte-identical on base/head: 102 tracked boundary warnings, 369 raw sites, 70 capability booleans. Both generated manifests reproduce; the validator includer growth is disclosed. Registration, frozen CI test gate and
git diff --checkpass. No new family-string branch, concrete backend cast, raw command template, capability, dependency or keying path was added. A separately model-invocable code-review skill was unavailable; existing bot/human findings were checked rather than treated as evidence by themselves.
CI and recommendation
Request changes for the HL2 regression. All four required checks plus the sanitizer configure check passed, and the commit is signed. Their pull_request runs were created at 2026-10-07 00:47:53/54 UTC; current main 121340c08 is dated 16:14:44 UTC. With strict: false, that green does not establish compatibility with current main. Restore both client-owned gesture paths, run the focused tests including client_display_pan_settings_test, and refresh CI. The bounded headroom and validation work is useful; it should land without undoing #6140.
… and headroom recovery writes only for Auto Floor on an owned pan. Principle II. - Both dBm-range gestures adopt the operator's range again on a backend with no command plane (HL2, aethersdr#6140): a dropped dispatch no longer returns before adoptClientOwnedDbmRange(), which is a no-op on a radio-owned range. - An accepted range reply keeps a radio status received after the write (Principle II; FlexLib's handlers ignore an accepted reply). A rejection falls back to the range the radio last confirmed, not an earlier request still in flight. - Headroom recovery, floor and ceiling, rewrites min_dbm/max_dbm only with Auto Floor on and only on a pan this session owns (maintainer ruling). A clipped floor holds baseline acquisition only while a recovery can still come, so a foreign pan or a floor at -180 dBm no longer stalls auto floor. Recovery helpers never move an endpoint inward. - The rewire epoch is a SpectrumWidget member, not a dynamic property, and one ownership predicate serves the widget and the dispatch path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Your blocker is fixed in 793ab77. Both HL2 gestures (MainWindow_Wiring.cpp arrow and drag-finished handlers) call sendDbmRangeCommand() as a statement followed by the unconditional adopt, as you suggested. client_display_pan_settings_test passes ('the send comes before the mark is read'), and reverting the fix makes it fail again. Your questions were settled by maintainer rulings, also implemented in 793ab77. Q1: both halves of headroom recovery rewrite min/max_dbm only with Auto Floor on and only on an owned pan. Q2: a radio status received after the write wins over an accepted reply (Principle II, matching FlexLib); this is documented in DbmRangeTransition.h. Q3: the PR stays 'Refs', and #5685/#6219 stay open until Windows evidence exists. Your nits are also in 793ab77: the rewire epoch is now a SpectrumWidget member, vita49-format.md's trailing blank line is restored, and the PR body gives the opt-in -DAETHER_BUILD_SPECTRUM_GESTURE_TEST=ON configure flag beside the test run.
|
Landing pass at Maintainer rulings, implemented here:
Earlier bot review
Automated review of this pass (code-review, medium)
Not verified:
|
ten9876
left a comment
There was a problem hiding this comment.
Approving at 793ab77cd.
Fixed in this pass (793ab77cd):
- HL2 gestures (NF0T, ten9876 blocker). Both handlers adopt the operator's range whether or not dispatch succeeds.
client_display_pan_settings_testpasses, and fails when the fix is reverted. - Accepted-reply ordering (maintainer ruling: radio status wins). A rejection falls back to the radio-confirmed range. Pinned in
panadapter_dbm_range_test, which fails when reverted. - Headroom recovery (maintainer ruling). Floor and ceiling recovery run only with Auto Floor on and only on an owned pan. A clipped floor holds baseline acquisition only while a recovery can still come (foreign pan, -180 dBm limit). Pinned by four
spectrum_confirmed_geometry_testcases, each red under its mutation. - Recovery helpers never move an endpoint inward. The rewire epoch is a member, and one ownership predicate is shared. The docs cite the in-repo FlexLib 4.1.5.39794 reply handlers.
Verified empirically:
- 7/7 focused tests on Linux/Qt 6.12, including
client_display_pan_settings_testand the opt-in widget test. - Engine-boundary and command-plane findings identical to the merge base; capability records at the frozen 70; manifest clean.
- Demo bridge: dBm arrow clicks converged the model and encoder (-130/-50, then -130/-60) before and after a reconnect, with
transmitting: false. - Required CI green on this head.
Reasoned, not run:
- the HL2 runtime path (the demo has no client-owned range);
- the rejection-fallback path (there's no reply-injection seam);
- KN7K's Windows/FLEX-8600 environment, so #5685 and #6219 stay open.
tci_server_review_test was not run (socket-owning).
Carry each observation's decoder aperture and generation through the existing queued spectrum feed. Reproject in-flight wire apertures before smoothing, including partial endpoint updates and rapid range reversals. Retain a bounded candidate history after target encoding first appears; command acceptance does not identify the aperture of an FFT already in flight. Discard comparisons when RF gain or accepted frequency geometry changes so real reception changes retain their levels. Matching replies preserve frame context. Refs aethersdr#6238 (R7). Follow-up dependent on aethersdr#6241. Validation: full macOS build, eight selected offscreen tests, mutation checks, recorded-frame C++ replay, and RX-only FLEX-8400M bridge/native capture. Twenty held releases stayed within 1.53 dB at the native rendered floor; ten rapid reversal pairs stayed within 1.94 dB at the smoothed-bin median. These are separate evidence layers. Raw VITA and logs remain local.
Follow-up to #6241 (merged as `ac2f0ce3b`), rebased onto main so the diff holds only this repair. It addresses **R7 in #6238: the noise floor popping immediately after release of the right dBm scale**. With FFT Floor Auto OFF, MW AM, normal 2D and default averaging/FPS, shifting the entire right-side range by about 37.55 dB could produce a roughly 27 dB false floor excursion after mouse-up. A frame decoded before release could arrive afterward and prematurely complete the correction guard; subsequent old-wire frames then entered averaging under the new decoder scale. Command acceptance did not identify the aperture of an FFT already in flight. The repair carries immutable decode aperture/generation with each observation through the existing queued/deferred spectrum feed, then reprojects plausible in-flight apertures **before smoothing, floor tracking, waterfall fallback and 3D history**. It retains at most 32 candidate ranges for the existing two-second window, including rapid reversals and intermediate min/max combinations. Recorded samples matched a temporary old-min/new-max aperture across all 2,696 bins; mixed UDP fragments did not explain that affine error. The endpoint timing is inferred from those samples, not a new firmware guarantee. Matching replies preserve frame context. Older decode generations cannot complete an A→B→A request. Cancellation, authoritative reconciliation, RF-gain changes and accepted frequency/span changes discard the comparison history so genuine reception changes retain their levels. Absolute-level producers keep an empty context. The new engine/UI payload contains three scalars, no vendor types or commands, and follows the existing route without another thread. ## Before / after results | Case and evidence layer | Before | After / result | Coverage and limit | | --- | --- | --- | --- | | Held right-scale release, live native Cocoa/Metal | Two of ten captured separated releases on #6241 showed approximately ±27 dB floor departures, about 116 ms after release. | **20 held releases**, both directions: maximum rendered-floor departure **1.53 dB** in the first second after release. | FLEX-8400M 4.2.18.41174; MW AM; Auto Floor OFF; 2D; average 50, weighted OFF, 25 FPS; midpoint −100 dBm; ±37.55 dB shifts. Measured relative to the last pre-release draw. | | Same captured failing frames, production C++ replay | Smoothed-bin median departures **26.89 / 27.13 dB**; guard completed on the first queued old decode. | **1.16 / 0.31 dB**; completion waits for the current decode generation and target interpretation. | Same vectors, ordering and smoothing inputs. Deterministic replay is separate from hardware/render proof. | | Intermediate endpoint aperture, captured-frame C++ replay | Omitting partial endpoint candidates left median errors **14.11 / 14.32 dB** and falsely completed the transition. | Both recorded frames reprojected; residual median errors **0.72 / 0.62 dB** and transition stayed pending. | Both endpoint combinations covered by socket-free helper and real-widget regressions. | | Rapid A→B→A, live decoder/widget pipeline | Two-aperture assumptions discard older in-flight candidates; queued older A could retire a newer A request. | **10 rapid reversal pairs (20 releases)**: maximum smoothed-bin median departure **1.94 dB** in the first second after each pair. | Per-frame CPU/widget measurements. Native draw records paused during this portion, so it is not rendered-trace timing proof. | | Late old-wire frame after target appears; matching/rounded reply | Correction could be discarded by completion/reconciliation. | Target completion retains bounded candidates; old frames still reproject; matching replies preserve the guard. | Deterministic production-widget tests; explicit cancellation and expiry also covered. | | Genuine RF-gain / frequency change during candidate window | Retaining old-scene comparisons could incorrectly reinterpret a real level change. | History invalidates; injected −104→−72 dBm RF scene remains −72 dBm. Production bridge also observed gain 0→32→0 and center 1.000→1.050→1.000 MHz. | Unit test proves preservation of true levels; live readback proves control convergence. Broader band/preamp bounce remains separate. | | Raw VITA decode cross-check, live capture | Aperture assumptions were previously ambiguous. | **5,446 complete decoded FFTs** independently reconstructed from raw pixel samples: **zero parity discrepancies** above 20 µdB. Five pre-release decodes arrived after release; 50 inputs with >10 dB median error were corrected before averaging. | Raw data, identifiers and logs remain local; only aggregate results and images are published. | | Regression protection | Removing the metadata-aware correction reproduces the ordering failure. | **8/8 selected offscreen tests passed**; disabling the widget metadata branch caused 21 assertions to fail; removing endpoint candidates restored the recorded errors above. | The real-widget test is opt-in, enabled locally. No new socket peer or CI gate additions. Hosted CI is pending. | The raw/native capture used temporary record-only probes around the same repair. Those probes are absent from this PR and the production app. The later RF-scene invalidation hooks have direct production-widget tests and a separate uninstrumented RX bridge run. The complete final production source at `c7c94dcebad13b1026eb680baba163ca3378ebaa` built successfully with `cmake --build build -j22`; its eight selected tests passed. A read-only merge-tree check against refreshed main `25de97c3` was clean; integration tests on that merged tree have not been run. ## Proof Per-frame native rendered floor, two captured failing releases → 20 held releases with the repair:  Native after screenshot from our RX-only FLEX-8400M run (static appearance; timing proof is the chart above):  ## Validation and remaining scope Selected tests: `panadapter_dbm_range_test`, `spectrum_preview_logic_test`, `dbm_range_plausibility_test`, `spectrum_confirmed_geometry_test`, `noise_floor_auto_adjust_gate_test`, `pan_frame_guard_test`, `backend_family_switch_test`, `rtl_model_acceptance_test`. Run with `QT_QPA_PLATFORM=offscreen ctest --test-dir build -j22 --no-tests=error` and the explicit eight-test selection. The opt-in real-widget cases are not part of the default CI graph. A stream whose range was never written (its status matches the -130/-40 default) mints a decode generation on its first FFT, so the session's first release is guarded as well. `panadapter_dbm_range_test` pins this: register a stream, decode, assert the scale is `valid()`, then check that a real write supersedes it. The test fails without the seed. On the rebased head (`c983a788`, Linux/Qt 6.12, with `-DAETHER_BUILD_SPECTRUM_GESTURE_TEST=ON`), 7/7 tests passed: `panadapter_dbm_range_test`, `spectrum_preview_logic_test`, `dbm_range_plausibility_test`, `noise_floor_auto_adjust_gate_test`, `pan_frame_guard_test`, `client_display_pan_settings_test` and `spectrum_confirmed_geometry_test`. On the demo radio, arrow clicks converged the model and encoder to -130/-50 and then -130/-60, with encoder generations 2 and 3, 1024 finite bins and `transmitting: false`. Engine-boundary findings match the merge-base baseline per file (102 tracked findings, no additions); capability count stays 70; raw command-plane count stays 369 across 32 files. Generated touchpoints, frozen CI gate, bridge documentation and whitespace checks passed. No new vendor include, raw command, capability, thread, setting, bridge verb or transmit behavior was added. The agent automation bridge drove phaseful native gestures, captured snapshots/screenshots and confirmed positive RX control convergence. All sampled MOX/tune/transmitting states were false; touched slice/pan/filter/step and global line-out state were restored, the owned app stopped, and the shared reservation released. No TX testing was performed. Refs #6238, #6241 and #5525. The six KN7K display reports remain linked in #6241: #5685, #6219, #5204, #4221, #3970, #2371. This follow-up does not claim to resolve every symptom in those reports. R5/R6 band/Go-to/band-stack and preamp bounce remain open investigation cases. Windows, KN7K's radio/firmware, Auto Floor ON timing and TX recovery are not live-proven here. Future RadioCert gap: existing gestures can reach this path, but sparse `tracedebug` polling cannot certify an approximately 100 ms non-event. Bounded per-frame VITA/decode-generation/transition/render correlation, recorded-frame replay and capture-gap handling remain future work in #6238; this PR adds deterministic ordering regressions without introducing that testing feature. Generated with OpenAI Codex (GPT-6 Astra) --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#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>
Flex FFT headroom recovery can lower the peak ceiling while trying to unclip the noise floor. Ordinary radio status also leaves the GUI encoder cache stale, and accepted reply-only range writes do not converge the canonical pan model. This repair preserves the opposite encoder endpoint, validates before decoder mutation, and completes range writes on the current radio reply.
Refs #6238. This is a partial repair: complete band/Go To/preamp trace-bounce removal is not claimed, and the issue remains open.
Linked display reports
The six open KN7K panadapter display reports are linked below, together with the related Auto Floor OFF report #5525. These are reference links, not closing claims; the PR's implementation and validation scope remain the partial range/controller repair described below.
Earlier KN7K reports #3483 — band-to-band floor positioning, #3731 — FFT artifacts, and #3804 — post-TX floor recovery remain historical context. Those closed reports are not reopened or reclassified as fixed by this PR.
Change
min_dbm/max_dbmonly while Auto Floor is on, and only on a pan this session owns. Flex stores the range per band, so with Auto Floor off the client never changes the operator's range. This was a maintainer ruling. It also changes the floor recoverymainran with Auto Floor off.SetLowDbmReply/SetHighDbmReplyignore an accepted reply). With no such status, an accepted reply-only write converges the model, and a rejection falls back to the range the radio last confirmed.The generated touchpoint change records reuse of the existing core dBm validator by the GUI range helper. No new engine dependency, raw command-plane site, capability or bridge verb is added; the frozen raw-site count remains 369. Host absolute-bin and Kiwi paths retain their existing acquisition behavior.
Proof
Agent automation bridge, native Cocoa/Qt 6.12/Metal, RX-only FLEX-8400M, firmware 4.2.18.41174. MW broadcast signals, native band buttons, keyboard Go To, local band-stack bookmarks, and RF-gain controls were exercised. These are maintainer 8400M screenshots, not KN7K's. They are separate ambient RX captures, not a controlled RF amplitude comparison.
-178/-88, automatic upper recovery reached-178/-40, keeping the minimum fixed. The after image shows intact AM peaks around-42 dBminstead of the old-88plateau.-100/-16recovered to-124/-16; the maximum stayed-16. Presentation span stayed 84 dB while the encoder aperture expanded.-80/-10, recovery requested-104/-10, then-128/-10, with successful replies and no matching self-status range echo in the captured bracket. The model and decoder metadata both converged to-128/-10.-202/-112range, NaN, dispatch refusal and a confirmed foreign pan before state commit. Invalid ranges were not sent to hardware.-124/-16, preventing the intermediate candidate's false expansion to-148/-16. The rendered trace still moved (24.77–36.30%, ending 25.49% at an 84 dB presentation span). Reverse and 86% target cases also retained transients.Before: earlier unfixed maintainer 8400M reproduction,
abc5b26, with encoder ceiling-88 dBm.After: current production changes, automatic ceiling recovery and intact MW signal peaks.
Native captures were made before committing as
30882701-dirty; only a source comment and stronger test assertions changed before signed commit7725ebe2was rebuilt and tested. Rendered telemetry describes draw submission, not scanout; the after image was inspected separately. Raw logs and the full evidence archive remain local at the operator's request.Validation and limits
cmake --build <build-dir> -j22passed at signed commit7725ebe2.QT_QPA_PLATFORM=offscreen ctest --test-dir <build-dir> --no-tests=error: 7/7 selected tests passed at793ab77con Linux/Qt 6.12:panadapter_dbm_range_test,spectrum_preview_logic_test,dbm_range_plausibility_test,noise_floor_auto_adjust_gate_test,pan_frame_guard_test,client_display_pan_settings_test(the fix(gui): FFT FPS and the dBm range survive a restart. Principle VIII. #6140 HL2 pin), and opt-inspectrum_confirmed_geometry_test. The last one needs-DAETHER_BUILD_SPECTRUM_GESTURE_TEST=ONat configure. Offscreen widget checks are logic/gesture proof, not GPU rendering proof.client_display_pan_settings_test), radio-status-wins (panadapter_dbm_range_test), the Auto Floor gate, the ownership gate, and the -180 dBm limit (spectrum_confirmed_geometry_test).DEMO-0001,transmitting: false): clicking the dBm strip's up arrow moved the pan model and encoder to -130/-50 and then -130/-60, with the display axis following. This held before and after a disconnect and reconnect, which exercises the rewire epoch.The remaining bounce needs raw VITA frame/pixel correlation with decoding range and TCP status timing. The agent automation bridge has no raw VITA capture/replay surface; the noninteractive packet-capture privilege check required a password, and no pcap was captured. A trace hold/blanking policy or automatic display-span change would require maintainer UX direction. This PR introduces neither and should not close the complete bounce investigation.
Generated with OpenAI Codex (GPT-6 Astra)