Skip to content

fix(gui): NB Blank works on a dBm waterfall row. Principle VIII. - #6117

Merged
ten9876 merged 7 commits into
aethersdr:mainfrom
on8st:hl2/waterfall-nb-blank-dbm
Oct 3, 2026
Merged

ten9876 merged 7 commits into
aethersdr:mainfrom
on8st:hl2/waterfall-nb-blank-dbm

Conversation

@on8st

@on8st on8st commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Update after review, efcbb06b and b75b8356 (full suite 611 of 611):

  • Icom and the demo are not fixed. Their rows are dB too (IcomCivBackend.cpp:1730, SimSignalSource.cpp:137) but they declare no binsAbsolute, so NB Blank stays dead there. The predicate is the maintainer's to choose; the options are in the review reply.
  • The rows are dBFS under a dBm label: the code now says RowKind::AbsoluteDb. Read "dBm row" below as "absolute dB row".
  • The ring empties when the row kind changes, and its size is one constant shared by the widget and the test.

Summary

Refs #5678 (the HL2 parity rows). Refs #277 (the feature).

Read first.

  • The fix has not been seen in the running app. The defect was observed there. The fix is covered by a unit test on the extracted decision.
  • One design choice, yours to move: what the operator's threshold means on a dBm row. This PR reads it as one dB per slider step (kDbPerThresholdUnit = 100).
  • It reaches three families. The switch is RadioCapabilities::panBinsAbsolute(), which HL2, ANAN and RTL-SDR declare. Only the HL2 was observed.
  • The test ends in a source-text pin on SpectrumWidget.cpp. It names its claim; if you would rather not carry it, it is four checks at the end of one file.

On a Hermes-Lite 2 the waterfall's NB Blank is enabled, toggles, persists and does nothing.

Observed (the defect, before the change)

HL2 on a dummy load, receive only, 2026-10-01, on a local integration build over 25da98de (Qt 6.12.0). The floor was stepped up 35.0 to 36.8 dB with the LNA gain and the waterfall grabbed 2.00 s later; three cycles each. get display confirmed wfBlankerEnabled and the threshold on every leg.

NB Blank n floor step, dB burst depth, px
Off 3 35.6 to 36.1 50, 50, 50
On, 1.05 3 36.0 to 36.8 50, 50, 50
On, 1.95 3 35.0 to 35.2 50, 50, 50

At 25 rows/s, 2.00 s is 50 rows, one px a row: the step is in the waterfall with no delay on 9 of 9 legs. On equals Off. There was no positive control: no working blanker was seen on any radio in that run.

Cause

SpectrumWidget::updateWaterfallRow replaced a row when

m_wfBlankerRingCount >= 8 && baseline > 0.0f && rowMean > baseline * m_wfBlankerThreshold

A Flex tile is a positive intensity (about 96..120), so that is a ratio test. A radio whose spectrum is computed on this host has no waterfall plane: RadioModel::onBackendSpectrumFrame hands the pan frame on as the row. That frame is dBm, every bin negative, so baseline > 0.0f is false on every row.

Dropping the guard is not the fix. With a baseline of -147 and the default 1.15, baseline * threshold is -169; every ordinary row is above that, and the waterfall would stand still.

The change

  • src/gui/WaterfallImpulseBlanker.h (new, no Qt): decide(kind, historyRows, baseline, rowMean, threshold) returns whether the row is an impulse and what it adds to the ring.
  • SpectrumWidget.cpp: the inline test and the two ring writes become one call. The row kind is m_panBinsAbsolute, the declared capability the widget already holds for the auto-floor gate. It is not guessed from the sign of the data.
  • Tile rows are unchanged: same test, same 1.05 cap on what a rejected row adds to the ring.
  • dBm rows: impulse when historyRows >= 8 and rowMean - baseline > (threshold - 1) * 100 dB. A rejected row enters the ring at most 5 dB over the baseline (the tile cap through the same mapping). A baseline that is not finite is never an impulse: one row mean of -inf in the ring would otherwise make every later row an impulse and write -inf back.

The design choice: what the threshold means in dB

The control is a slider 5..95 that becomes 1 + v/100, default 1.15. This PR reads it as one dB per slider step: 5 dB at the lowest setting, 15 dB at the default, 95 dB at the top.

On a tile, mean > baseline * t means a margin of (t - 1) times the tile's own level, about 96..120, so one slider step is about one intensity unit. A dBm row cannot use its own level, because the zero of a dBm scale is arbitrary. So the margin uses a fixed 100. One dB is taken as one intensity unit because the widget already colours both row kinds with one range width in the row's own unit (120 - 0.91 * gain). What one tile unit is worth in dB is not measured here.

law at 1.05 at 1.15 at 1.95 why not
(t - 1) * 100 (this PR) 5 dB 15 dB 95 dB
10 * log10(t) 0.2 dB 0.6 dB 2.9 dB Below the movement of an ordinary row's mean: a station 40 dB over the floor on 5 % of the bins lifts it by 2 dB, and the waterfall would hold each time it keys up
(t - 1) * abs(baseline) 5.6 to 7.4 dB 17 to 22 dB 105 to 141 dB Moves with LNA gain (floor -111 to -148 dBm on this HL2)

Another law is one constant or one line in dbMargin.

Tests

waterfall_impulse_blanker_test (new, no Qt, 47 checks):

  • dBm rows. A row 20 dB over a -147 dBm floor is replaced at the default; a row 0.5 dB over, at, or below the baseline is not. 7 rows of history is not enough, 8 is. The margin is 5, 15 and 95 dB. The same excess gives the same answer on baselines from -148 to +10 dBm.
  • dBm rows through the widget's ring. A steady band at -60 dBm is never blanked in 400 rows. A single impulse row is replaced and moves the baseline by 5/32 dB. A 36 dB step that stays is held for 108 rows at 1.05 and 75 at 1.15, then released; at 1.95 it passes. A falling level is never blanked. A -inf row does not freeze the waterfall.
  • Tile rows. Nine hand-computed cases, and 121 176 cases (history 0..33, baselines of both signs, six thresholds) that decide bit for bit as the original expression, 27 040 of them impulses.
  • Source-text pin (four checks). The claim: SpectrumWidget.cpp takes the row kind from m_panBinsAbsolute, writes the helper's value to the ring, and keeps no inline ratio test. No behavioural seam reaches it: SpectrumWidget is compiled only into the application, and no test target links it. It shows how the call is written, not that the widget runs.

Red before green, re-run at this head. The helper is new, so the test cannot build on main; "the dBm branch inert" is main's behaviour.

mutation failing checks of 47
the dBm branch inert (main) 11
the ratio test on dBm rows, guards dropped 14
10 * log10(t) as the margin 13
the rejected row uncapped 4
the finite guard dropped 3
the two kinds swapped at the call site 1 (the source-text pin)

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

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

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

Not verified

  • Not observed in the running app. The observation above is the defect, on a build without this change. No build carrying the fix has been launched against a radio.
  • The observed stimulus is a sustained step, not an impulse. No real impulse on an HL2 waterfall has been looked at.
  • ANAN and RTL-SDR take the same path; nothing was run on them. Icom and the demo backend leave the flag false and keep the tile test.
  • The blanker firing on a Flex is read from the source and from Noise Blanker–Gated Waterfall Impulse Suppression #277, not observed here.
  • macOS only. The sanitizer-configure job was not run locally.

Beside this on main

Nothing on main overlaps: the branch merges c7513e12 cleanly. The manual Black Level fix for the same unit problem (branch hl2/waterfall-manual-black) reads the same flag and merges cleanly with this one. No doc change here; that branch documents the row unit.

73, ON8ST

🤖 Generated with Claude Code

on8st and others added 4 commits October 1, 2026 12:43
The waterfall impulse blanker (aethersdr#277) replaced a row when

    ringCount >= 8 && baseline > 0 && rowMean > baseline * threshold

A Flex waterfall tile is a positive intensity, so that is a ratio test.
A radio whose spectrum is computed on this host (HL2, ANAN, RTL-SDR) has
no waterfall plane: RadioModel::onBackendSpectrumFrame reuses the pan
frame as the row, so the row is negative dBm, `baseline > 0` is never
true and the control did nothing. Dropping the guard would be worse:
with a baseline of -147 and 1.15 the product is -169, every ordinary row
is above it and the waterfall stands still.

The decision moves to a pure helper, WaterfallImpulseBlanker::decide,
which takes the row's unit. The unit is the declared capability
RadioCapabilities::panBinsAbsolute() (m_panBinsAbsolute in the widget),
not the sign of the data.

- Tile rows: the ratio test and the 1.05 cap, unchanged.
- dBm rows: rowMean - baseline > (threshold - 1) * 100 dB, which is one
  dB per step of the 5..95 control: 5 dB at the lowest setting, 15 dB at
  the default. A rejected row enters the ring at most 5 dB over the
  baseline. A baseline that is not finite fails open.

The reference of 100 stands in for the tile's own level (about 96..120),
which is what the ratio multiplies on a Flex. The widget colours both
row kinds with one range width in the row's unit, so the same setting
asks for the same step in colour on either radio. 10*log10(threshold)
was not used: 0.2 to 2.9 dB is inside what one station keying up does to
the mean of a narrow span.

Test: waterfall_impulse_blanker_test, 47 checks. 121176 tile cases
decide bit for bit as the original expression. Twelve mutations of the
helper and the call site were each caught.

Not observed in the app yet: the HL2 observation this answers is from
before the change.

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 margin derivation stays in the original commit message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the comment blocks this branch adds under the AGENTS.md rule: a
comment says what is true now, in five lines or fewer. How the defect
was found and the lab run it was seen in 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. One check's name loses a lab run number;
no check's condition changed.

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

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1. Issue fit

Yes. No Fixes line — Refs #5678 / Refs #277 — and GOVERNANCE.md does not want an RFC for a bug fix with a clear root cause, which this is.

I verified the root cause rather than taking it from the body. RadioModel::onBackendSpectrumFrame (src/models/RadioModel.cpp:6659) re-emits the host-computed pan frame as the waterfall row ("the backend has no separate waterfall plane, so the pan row is the waterfall row", line 6706), and that lands in SpectrumWidget::updateWaterfallRow. The old baseline > 0.0f term therefore is false on every row of a negative-valued frame, so NB Blank is an inert control on HL2/ANAN/RTL-SDR. The author's "dropping the guard is not the fix" argument also holds: -147 * 1.15 = -169, so a bare ratio test blanks everything.

I also confirmed the load-bearing justification for the source-text pin: tests/tests.cmake names SpectrumWidget.cpp exactly once (in this PR's own comment), no test target compiles it, and the pattern has precedent in the file at tests.cmake:6622.

2. Scope

Everything in the diff is explained by the stated fix.

File What it changes Claimed? Verdict
src/gui/WaterfallImpulseBlanker.h New header-only decide(); tile law verbatim, dBm law added ✅ In scope
src/gui/SpectrumWidget.cpp +1 include; inline test → one call; two identical ring writes → one ✅ In scope. Behaviour-preserving for tiles — see below
tests/tests.cmake Registers waterfall_impulse_blanker_test ✅ In scope
tests/waterfall_impulse_blanker_test.cpp 47 checks ✅ In scope

Four files, nothing else. No CHANGELOG.md entry (correct — it must not have one). No new public/protocol/settings surface: RowKind and decide() are internal, no capability field is added, no default changes. No sibling call site left behind — m_wfBlankerThreshold has exactly one consumer and it is this one.

Deleted behaviour: the only - lines of substance are the two per-branch ring writes. They collapse correctly — decide() returns min(rowMean, baseline*1.05f) on a tile impulse and rowMean otherwise, which is byte-for-byte what the two deleted lines wrote, and the unconditional write sits in the same place the old pair did (no early return between).

Not a preference change. The old behaviour is a dead control on three families, with hardware evidence in the body. The direction (higher slider = less blanking) matches the tile law. Nothing user-visible changes on a Flex.

Socket tests: none added, removed, or modified. The new test is Qt-free and opens nothing — grep -E "QTcp|QUdp|QLocal|bind\(|listen\(|connectToHost" on it returns 0 hits.

3. Blockers

None.

4. Nits (non-blocking)

  • docs/architecture/radio-capabilities-map.md:529 goes stale. It states: "panBinsAbsolute() is consumed too, and it is the second term of ONE gate." After this merges there are two consumers, and #6115's body says it "records the flag's second consumer" — so whichever lands second leaves the doc wrong either way. One line here is cheaper than coordinating the order. I looked for a canon sentence requiring a consumer to be documented and did not find one in AGENTS.md, so this is a convention, not a rule.
  • The source-text pin is whitespace-collapsed exact-match. A future clang-format rewrap of those five lines turns it red with no behaviour change. You flag it yourself and offer to drop it; I would keep it — there is genuinely no behavioural seam, and the precedent exists — but it is worth a comment in the test saying it is reformat-sensitive.
  • The test's Ring hardcodes kN = 32 with nothing pinning it to SpectrumWidget::WF_BLANKER_N (currently 32, SpectrumWidget.h:1992). If that constant ever moves, the ring-level tests silently stop modelling the widget.
  • RowKind::Dbm slightly overclaims the unit. RadioModel.cpp:6662 calls the HL2 payload "DC-centred dBFS bins", and RTL bins are dBFS too. The law is a pure difference so it is unit-agnostic and correct either way — but the enum name asserts something the producers do not.
  • Pre-existing, not yours: SpectrumOverlayMenu.cpp:2397 tooltips the slider "Higher values blank more aggressively." That is backwards for both laws. Worth naming because this PR is the first thing to state the slider's meaning in dB. Also m_wfBlankerMode ("0=Fill, 1=Interpolate") is persisted and settable but read nowhere — a dead settings key from #277.

5. What I tried to break

The new test is Qt-free, so unusually I actually ran this one rather than only reasoning about it: c++ -std=c++20 -I src on the standalone test, plus my own probe harness against the shipped header.

  • Reproduced the red-before-green table independently. I applied three of your mutations myself and got your exact counts: dBm branch inert → 11 failed, finite guard dropped → 3, rejected row uncapped → 4. The clean run is 47 checks, 0 failed, with 121176 tile cases / 27040 impulses and "75 rows held" — every number in the body reproduces. The red-before-green claim is real, not decorative.
  • Attacked the threshold range for a divide-by-the-margin blow-up. If threshold could reach 1.0, dbMargin is 0 and the dBm cap becomes min(rowMean, baseline) — a baseline that can only fall, i.e. a permanent freeze. It cannot: setWfBlankerThreshold clamps to [1.05, 2.0] (SpectrumWidget.cpp:4800) and the settings load clamps identically (:2511), and the slider is 5..95. Both doors are shut. No finding.
  • Periodic impulses, to see whether the cap lets the baseline creep until blanking dies. At 1/2, 1/3, 1/5 and 1/10 duty with a 37 dB burst over 1000 rows: 0 missed on either row kind, baseline settling at −142.0 (dBm) and 113.7 (tile). The cap does its job.
  • A slow ramp, the classic false-positive for a margin test. +0.5 dB/row for 200 rows at slider 5: dBm blanks 188/200, tile blanks 187/200. That is #277's existing slow-ramp behaviour inherited exactly, not something the dBm law introduces. At 1.15 and above, 0/200 on both. Parity, no finding.
  • Hold length on a sustained step — the one stimulus you actually measured on hardware. A 36 dB step that stays is held 108 rows (4.3 s) at slider 5 and 75 rows (3.0 s) at the default, released thereafter; the tile equivalent is 86 and 55 rows. Same regime, dBm slightly longer. Maintainer-visible: the LNA step in your own evidence table will now produce a ~3 s frozen stripe where it previously passed straight through. I think that is correct — it is what a Flex already does — but it is the first thing a user will notice and it deserves to be a conscious call.
  • The transition no test covers: connect/disconnect across row kinds without a restart. MainWindow.cpp:7561 sets the flag as connected && caps.panBinsAbsolute(), and the ring is never reset. Flex→HL2 leaves ~108 residue read under the dBm law: rowMean − baseline is hugely negative, never fires. HL2→Flex leaves ~−147 residue read under the tile law: baseline > 0.0f is false, never fires. Both fail open and self-heal within 32 rows (~1.3 s of under-blanking). Benign in both directions.
  • Non-finite handling both ways. -inf and NaN baselines fail open on the dBm path by the explicit guard, and on the tile path incidentally (NaN > 0 is false, rowMean > +inf is false). A -inf row mean still enters the ring and disables the blanker for 32 rows — your test pins exactly that and the recovery, and I could not turn it into a permanent freeze.
  • Equivalence of the tile path, beyond your grid: the collapsed ring write is the same value in the same position as the two deleted lines, with no early return between the branch and the write. I read the whole function (SpectrumWidget.cpp:8450-8690) rather than the hunk.
  • Merge with #6115, which touches the same function: git merge-tree produces 0 conflict markers. Your "merges cleanly" claim holds. Worth the maintainer noticing that the two PRs split one unit bug across two new headers (WaterfallImpulseBlanker.h and WaterfallLevelMap.h) with the same extraction-plus-source-pin shape — reviewable independently, but it is one problem in two parts.
  • CI is green on 63a73a35 across all five checks, including check-windows (so no MSVC surprise from the bare near() helper).
  • Could not check: anything at runtime in the GUI — no build, no radio. You state this yourself for ANAN and RTL-SDR and for the fix-in-app, and I cannot close any of those gaps. The call-site wiring is reasoned from code, since nothing links SpectrumWidget.

6. Recommendation

Approve with nits. This is the most falsifiable PR body I have reviewed here — every number in it reproduced when I ran the test and the mutations myself, the tile path is provably unchanged, and the four attacks I thought would land (threshold underflow, baseline creep under periodic impulses, slow-ramp false positives, cross-radio ring residue) all failed against the code.

The one genuine judgment call is kDbPerThresholdUnit = 100, which you correctly hand to the maintainer. I would not escalate it to "needs a decision": your comparison table rules out 10*log10(t) and (t-1)*|baseline| for stated reasons, and my hold-length numbers independently show the resulting dBm margin lands in the same regime as the tile ratio rather than being arbitrary. It is one constant in one header if anyone wants it moved.

Next step: add the one-line radio-capabilities-map.md:529 correction for the second consumer, and let the maintainer rule on the sustained-step hold being the intended new behaviour on HL2. Nice work — 73.


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

Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment thread src/gui/SpectrumWidget.cpp
Comment thread src/gui/WaterfallImpulseBlanker.h Outdated
Comment thread src/gui/WaterfallImpulseBlanker.h Outdated
Comment thread tests/waterfall_impulse_blanker_test.cpp
Comment thread tests/waterfall_impulse_blanker_test.cpp
Review follow-up on aethersdr#6117, no arithmetic change.

RowKind::Dbm becomes RowKind::AbsoluteDb: the rows HL2, ANAN and RTL-SDR
hand the waterfall are dBFS under a dBm label, and the law only takes a
difference. Same vocabulary as WaterfallLevelMap (rowsAreAbsoluteDb).

The ring size is in the header as kRingRows. The test's ring takes it,
and SpectrumWidget::updateWaterfallRow holds WF_BLANKER_N to it with a
static_assert, which a source-text check pins. A static_assert in the
.cpp rather than an include in SpectrumWidget.h keeps the widely
included header untouched. The source-text block now says it is
sensitive to a reformat. radio-capabilities-map.md names the flag's use
as the row unit.

The header states that the ring holds one unit at a time: the row kind
changes only across a disconnect, where clearDisplay ->
clearWaterfallRows -> clearCurrentWaterfallRows -> resetWfBlankerState
empties it. Without that, absolute-dB residue read under the tile law
would hold a tile waterfall for about 140 rows at the default (probe
against this header; not a test, the widget has no seam).

Fail-before, test compiled alone (48 checks): the static_assert against
a literal 32 -> 1 fails (ring size); the call site with the old enum
name -> 1 fails (call site).

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

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Issue fit

Yes, on the three families it targets, with one gap. There is no Fixes line (Refs #5678, Refs #277); a bug fix with a clear root cause needs no RFC (GOVERNANCE.md). The root cause holds. RadioModel::onBackendSpectrumFrame (src/models/RadioModel.cpp:6659-6706) reuses the backend's dB pan frame as the waterfall row. baseline > 0.0f is therefore false on every row, and NB Blank does nothing on HL2, ANAN and RTL-SDR. The fix keys the row unit on m_panBinsAbsolute. That flag picks out exactly those three families, but it does not pick out every backend whose rows are dB. See maintainer flag 1.

This review builds on @aethersdr-agent's review. I did not repeat what it reproduced. I did the three things it could not: built the branch, drove it through the bridge, and modelled cross-radio switches of the blanker ring.

Scope

File What it changes Claimed? Verdict
src/gui/WaterfallImpulseBlanker.h (new) Pure decide(). The tile law is unchanged; a dB-margin law is added Yes In scope
src/gui/SpectrumWidget.cpp:8640-8678 +1 include. The inline test becomes one call, and the two ring writes become one Yes In scope. The tile path is unchanged (see below)
tests/tests.cmake:3370-3381 Registers waterfall_impulse_blanker_test Yes In scope
tests/waterfall_impulse_blanker_test.cpp (new) 47 checks, no Qt, no sockets Yes In scope

Everything in the diff is explained by the stated fix. There is no new public, protocol or settings surface, no default change and no CHANGELOG.md entry. Commits 2891b138 (10-01) and 83c367b8 (10-03) are the fix and a comment trim, and the other two are merges from main. The test opens no socket (I read it: no QTcp/QUdp/QLocal/bind(/listen(/connectToHost).

Blockers

None.

Needs a maintainer decision

  1. The predicate misses two of the five backends whose rows are dB. On an Icom (for example an IC-7300), the title's claim "NB Blank works on a dBm waterfall row" is still false. #6115 has the same gap, so between them the bug is fixed on the same three families twice. WaterfallImpulseBlanker.h:7-10 and SpectrumWidget.cpp:8646 treat panBinsAbsolute() as "this row is dB". That capability answers a different question: "do the bins hold still while the reference level moves" (RadioCapabilities.h:240-247, and the table at docs/architecture/radio-capabilities-map.md:523-527). Every backend that feeds onBackendSpectrumFrame produces dB rows:

    • Icom: IcomCivBackend.cpp:1730, spectrumFrameReady(0, floatBytes(toDbm(...)))
    • the demo: SimSignalSource.cpp:137, kFloorDbm = -120.0
    • HL2, ANAN and RTL-SDR

    Only the last three declare binsAbsolute (Hl2Backend.cpp:1767, AnanBackend.cpp:386, RtlSdrBackend.cpp:117). Icom and the demo therefore still take the tile branch with a negative baseline, and NB Blank stays dead on both. The body says so ("Icom and the demo backend leave the flag false and keep the tile test") but presents it as intended. It is the same defect. I ran the shipped header against demo-shaped rows: a ring of -120 dB rows followed by a +40 dB step, through RowKind::TileIntensity, gives 0 blanked rows in 100 at 1.05, 1.15, 1.50 and 1.95.

    #6115 (WaterfallLevelMap, manual Black Level) uses the same predicate and writes the premise into canon: "A backend with absolute bins has no waterfall plane of its own". That is true, but the converse is not, and the converse is what the code relies on. After both PRs, the Black Level slider and NB Blank on Icom and the demo stay broken in the same way.

    The partition that matches the defect is "this row came through the backend seam, not a Flex tile". Options:

    • (a) A separate declared row-unit capability, or a flag set at the place where onBackendSpectrumFrame emits the row, used by both PRs.
    • (b) Declare binsAbsolute on Icom and the demo, if their bins are in fact absolute. That has to be decided per radio, because it also opens the auto-floor gate.
    • (c) Accept the three families now and open a follow-up that names Icom and the demo.

    Whichever is chosen, #6115 and #6117 should make the same choice. I am not treating this as a blocker: nothing regresses, and the author disclosed the omission.

  2. The user-visible change on HL2, ANAN and RTL-SDR is a held stripe on any sustained level step. The body's own stimulus is an LNA step. That step passed straight through before; with this PR it holds for 75 rows (about 3 s) at the default and 108 rows at 1.05. The existing review measured the same thing. A Flex already behaves this way, so this is probably correct. It is still the first thing an operator will notice, so it should be ruled on deliberately, together with kDbPerThresholdUnit = 100 (WaterfallImpulseBlanker.h:33).

Overlap with #6115

  • No textual conflict. git merge-tree --write-tree 63a73a35 a54dff9c is clean (exit 0), and so is a merge of this head with current main (579efd73).
  • The same root cause is fixed in two halves with matching shapes. Each PR adds a header extracted from SpectrumWidget, keys on the same m_panBinsAbsolute and adds a whitespace-collapsed source-text pin. The code is consistent: both branch on the same member, so they cannot disagree about a given radio. Point 1 above applies equally to both.
  • Naming has drifted between the two. After review, #6115 renamed its parameter to rowsAreAbsoluteDb, because its body says the rows are "dBFS under a dBm label on all three families". This PR still names the same fact RowKind::Dbm (WaterfallImpulseBlanker.h:16), and its comments say "dBm row" throughout. One name for one fact across both headers would help, for example RowKind::AbsoluteDb.
  • Docs. #6115 adds "It has a second consumer: the unit of a waterfall row" to radio-capabilities-map.md, and that paragraph names only the Black Level. Once both PRs merge, NB Blank is a third consumer and the doc does not mention it. The fix is one sentence in whichever PR lands second, or one PR carries the paragraph for both. The existing review's radio-capabilities-map.md:529 nit becomes this point once #6115 is in.

Nits (non-blocking)

  • Correction to the existing review: "self-heals within 32 rows" is wrong for HL2→Flex, though in practice the case cannot happen. I ran the shipped decide() on a ring holding 32 rows at -147 followed by Flex tiles at 108. The baseline crosses zero at row 19, and from then on every tile row is an "impulse" capped at baseline * 1.05. The result is 169 rows blanked at 1.05 and 138 at 1.15 (about 5.5 s). Flex→HL2 blanks 0. The same arithmetic applies to main today, because main writes HL2 row means into the ring too, so this PR does not introduce it. In a real radio swap, reprojectWaterfall (SpectrumWidget.cpp:7354) resets the ring on any change of centre or bandwidth, which is why I say it cannot happen in practice. Now that the ring's unit depends on m_panBinsAbsolute, setPanBinsAbsolute (SpectrumWidget.h:362) is the natural place to make that reset unconditional:
        void setPanBinsAbsolute(bool on)
        {
            if (m_panBinsAbsolute != on)
                resetWfBlankerState();  // the ring's unit follows this flag
            m_panBinsAbsolute = on;
        }
  • The Ring in the test hardcodes kN = 32 (tests/waterfall_impulse_blanker_test.cpp:37), and nothing ties it to SpectrumWidget::WF_BLANKER_N. This was already raised; I agree with it.
  • The tooltip is backwards, and was before this PR. SpectrumOverlayMenu.cpp:2397 says "Higher values blank more aggressively". Under both laws a higher threshold blanks less. This PR is the first place the slider's meaning is written down, so it is a cheap moment to fix the sentence.

What I verified and what I tried to break

  • Built the head (63a73a35, Linux, Qt 6 / GCC, RelWithDebInfo) and ran waterfall_impulse_blanker_test: 47 checks, 0 failed. The existing review already reproduced the mutation table, so I did not run it again.
  • Bridge session on the demo (DEMO-0001). I confirmed get radio returned "model":"AetherSDR Demo","family":"sim". The session used isolated settings, offscreen, NO_TX, and my own absolute socket. I opened the Display panel and set displayWfBlankerThreshSlider=5 and displayWfBlankerBtn on, and get display read back wfBlankerEnabled:true, wfBlankerThreshold:1.05. Both values were still there after a relaunch.
  • I could not grab the waterfall. The GPU renderer cannot start offscreen: get rhi returned "rendererFailed":true and grab SpectrumWidget returned "grab produced an empty image". AETHER_NO_GPU=1 failed the same way offscreen. A waterfall drawn on the CPU needs -DAETHER_GPU_SPECTRUM=OFF, which is a full rebuild I did not do. So "Flex and demo unchanged" is proven from the code, not from pixels. The demo leaves the flag false, so it runs the byte-identical tile branch, where baseline > 0.0f is false on its -120 dB rows, exactly as on main. The PR's 121 176-case equivalence grid covers the Flex tile path, and I re-read the full ring-write path (SpectrumWidget.cpp:8625-8681): the value is the same and so is the position. The demo goes through the backend seam, so this is not a Flex-wire proof either way.
  • Cross-radio ring residue (nit 1), modelled on the shipped header in both directions and at four thresholds.
  • Automated pass (/code-review, medium) found exactly one issue, the same Icom/demo predicate gap as maintainer flag 1. It arrived there independently. It also confirmed that HL2 bins cannot be -inf or NaN, because Hl2Spectrum.cpp adds a 1e-24 floor before the log, and that the ring-unit mixing on a capability flip already happens on main.
  • Threshold floor. setWfBlankerThreshold clamps to [1.05, 2.0], so the dBm margin can never reach 0 and freeze the ring. This holds.
  • CI is green on 63a73a35, but the earliest run was created at 17:04Z, and main has since moved to 579efd73 (19:25Z, 6 commits). None of those commits touch SpectrumWidget.cpp or tests.cmake, and the merge with current main is clean. So the green is very probably still accurate, but it was not produced against current main.

Recommendation: approve with nits. No blockers, so this review is a COMMENT. It is correct for the three families it claims, and the author disclosed what it leaves out. Two calls are for the maintainer: (1) the scope of the predicate, meaning fix Icom and the demo here or in a follow-up, with #6115 following the same decision; (2) the new held stripe on a sustained step, together with kDbPerThresholdUnit.

Maintainer review follow-up on aethersdr#6117.

setPanBinsAbsolute empties the blanker ring when the flag changes, as
the review proposes: the ring's unit follows the flag. Absolute-dB
residue read under the tile law turns the mean small and positive, so
every tile row is an impulse: 138 rows held at the default, now a
check. Tile residue under the dB law holds none. A disconnect already
empties the ring through clearDisplay; this makes it hold for the flag
itself. A source-text check pins the setter.

The NB Blank threshold tooltip said higher values blank more. Under
both laws a higher threshold blanks less; the tooltip now says so.

Fail-before, test compiled alone (52 checks): the setter without the
reset -> 1 fails (kind change: setPanBinsAbsolute empties the ring).

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

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first: the Icom and demo gap is real and is not fixed here.

Predicate (flag 1), yours. Confirmed: IcomCivBackend.cpp:1730 and SimSignalSource.cpp:137 hand over dB rows and declare no binsAbsolute. #6115 will follow the same choice.

option cost
(a) a row-unit capability, or a flag set where onBackendSpectrumFrame emits the row one field or flag, both headers switch to it; reaches all five
(b) binsAbsolute on Icom and demo also opens their auto-floor gate; unmeasured here
(c) three families now, follow-up Icom and demo stay dead until then

(a) is the one that matches the defect; we build it on your word.

Done in efcbb06b and b75b8356, no arithmetic change:

Yours: the ~3 s hold on a sustained step, with kDbPerThresholdUnit.

The a11y scan now covers two more files: six existing warnings, none on a changed line.

73, ON8ST

@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

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

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

73, Jeremy KK7GWY

@ten9876 ten9876 self-assigned this Oct 3, 2026
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

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

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

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Maintainer pass. Answers to the two reviews' points (Jeremy's review 5402519886 and aethersdr-agent's 5402028463, both body-only). The head is eeb52ce89: on8st's efcbb06b and b75b8356, plus a signed merge of current main (5fae2f863) with no conflicts.

Needs a maintainer decision

  1. Predicate scope (Icom and the demo). Ruled 2026-10-03: merge as it stands, correct for HL2, ANAN and RTL-SDR. Option (a), a row-unit signal set where onBackendSpectrumFrame emits the row, comes as on8st's own follow-up PR. The PR body states that Icom (IcomCivBackend.cpp:1730) and the demo (SimSignalSource.cpp:137) are not covered. Nothing in this PR changes for that.
  2. The ~3 s hold on a sustained step, and kDbPerThresholdUnit = 100. Covered by the same ruling. Unchanged.

Overlap, docs and nits: all done by on8st, verified at eeb52ce89

  • Naming: RowKind::AbsoluteDb (efcbb06b), which agrees with fix(gui): manual Black Level works on a dBm row. Principle VIII. #6115's rowsAreAbsoluteDb.
  • Docs: radio-capabilities-map.md now names NB Blank's use of panBinsAbsolute(). fix(gui): manual Black Level works on a dBm row. Principle VIII. #6115's paragraph is reconciled when it is updated after this one lands.
  • Ring residue on a row-kind change: setPanBinsAbsolute empties the ring (b75b8356). The test checks it, and a mutation proves the check: with the reset call removed, 52 checks give 1 failure.
  • kN = 32: the ring size is now kRingRows, and the widget static_asserts it (efcbb06b).
  • The tooltip is now "Higher values blank less." (SpectrumOverlayMenu.cpp:2397).
  • The source-text pin is kept, and its comment now says a reformat of those lines turns it red.
  • Not changed, and pre-existing: m_wfBlankerMode (from Noise Blanker–Gated Waterfall Impulse Suppression #277) is persisted but never read. That is outside this fix and was not part of the ruling.

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

  • Full build is clean, including the new static_assert.
  • waterfall_impulse_blanker_test: 52 checks, 0 failed.
  • ctest excluding socket-owning tests: 100% of 480 passed. 124 tests were left out because they are socket-owning by name or by a grep of their sources.
  • Static gates all exit 0: engine boundary, touchpoint manifest, capability records, command plane, test registration, CI test gate, network timeouts, qsettings, theme seed, shader dialects, qt pin and seam-probe table. The colour ratchet is +0. a11y: 6 findings, all on lines this PR doesn't touch.
  • Nothing was observed in the running app (the GPU waterfall can't be grabbed offscreen).

Maintainer pass by Claude for @ten9876.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at eeb52ce89 (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 says so. Option (a), a row-unit signal emitted from onBackendSpectrumFrame, follows as on8st's own PR. kDbPerThresholdUnit = 100 and the sustained-step hold stay as they are.

Review findings, all fixed by on8st and verified here:

  • RowKind::AbsoluteDb naming: efcbb06b.
  • kRingRows plus the widget static_assert: efcbb06b. The full app build compiles with it.
  • Ring emptied on a row-kind change: b75b8356. A mutation proves the test catches its removal: 52 checks, 1 failed.
  • Tooltip "Higher values blank less": efcbb06b.
  • radio-capabilities-map.md names NB Blank's use of the flag.
  • Comment on the source-text pin.

This pass: a signed merge of main 5fae2f863 (no conflicts) at eeb52ce89. All 6 threads were answered with evidence and resolved. Both body-only reviews were answered in a top-level comment.

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

  • Full build is clean.
  • waterfall_impulse_blanker_test: 52 checks, 0 failed.
  • ctest excluding socket-owning tests: 480 of 480 passed. 124 were excluded by name or by a source grep.
  • All Static checks scripts exit 0, and the colour ratchet is +0.

Reasoned, not observed: the in-app waterfall on HL2, ANAN and RTL-SDR. There was no radio, and the GPU waterfall can't be grabbed offscreen.

Freshness: the CI run on this head was created at 2026-10-03T23:08:54Z. main's tip 5fae2f863 dates from 22:49:15Z and is in the merged tree, so there is no drift.

@ten9876
ten9876 enabled auto-merge (squash) October 3, 2026 23:10
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Freshness note added after the approval. main moved to f24ac481c (#6124, 23:18:54Z) after this head's CI run was created (23:08:54Z). That commit touches MainWindow.cpp/.h, MainWindow_Session.cpp, RadioModel.cpp, tests/radiomodel_audio_mute_test.cpp and tools/check_command_plane.py, none of which this PR changes, and git merge-tree of eeb52ce89 against it is clean. On the maintainer's decision this is accepted as drift, so no further update.

@ten9876
ten9876 merged commit 52b6f7a into aethersdr:main Oct 3, 2026
5 checks passed
ten9876 pushed a commit that referenced this pull request Oct 6, 2026
…ite 2. Principle III. (#6140)

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

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

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

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

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

## Limits, first

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

## What was wrong on `main`

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

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

## The change

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

## Tests

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

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

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

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

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

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

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

## With the open PRs

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

## Not verified

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

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

73, ON8ST

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

---------

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

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

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

## Limits, first

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

## What was wrong on `main`

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

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

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

## The change

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

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

## Tests

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

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

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

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

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

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

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

## With the open PRs

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

## Not verified

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

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

73, ON8ST

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

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants