Skip to content

fix(tgxl): prevent STANDBY/BYPASS display flicker and stale hold regression - #1

Open
WA8PAM wants to merge 236 commits into
mainfrom
fix/tgxl-standby-bypass-flicker
Open

WA8PAM wants to merge 236 commits into
mainfrom
fix/tgxl-standby-bypass-flicker

Conversation

@WA8PAM

@WA8PAM WA8PAM commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Fix two TGXL applet display bugs introduced in v26.9.4

Bug 1 — STANDBY/BYPASS mode label doesn't track hardware

Switching between STANDBY and BYPASS requires two FlexRadio commands (mode= then bypass=). The radio echoes full TGXL status after each command, so an intermediate echo temporarily reverts the field still in flight — producing a visible flicker (e.g. STANDBY→BYPASS flashes OPERATE between the two echoes).

Fix: setOperateAndBypass() added to TunerModel to issue both commands atomically with hold guards that block intermediate echoes until the hardware confirms the commanded values.

Regression fix: If the confirmation echo for a held field never arrived (FlexRadio sometimes sends only changed fields), the hold stayed armed indefinitely. The next single-command transition (setOperate / setBypass) did not clear it, leaving the UI stuck. Fixed by clearing all holds at the start of any single-command transition.

Tested on hardware: cycled STANDBY↔BYPASS >20 times, then toggled STANDBY repeatedly — display tracks hardware correctly throughout with no flicker and no stuck labels.

Bug 2 — Source label always shows "RF SENSE" regardless of trigger mode

The TGXL protocol (both relay and direct port-9010 paths) does not expose per-port trigger mode. The source label was permanently hardcoded to "RF SENSE" — an assumption that is wrong on any port configured for PTT.

Fix: The source label in AccessoryPortRow is now hidden by default and only appears when the port has confirmed live source data. On a PTT-mode port the label is absent from startup and stays absent permanently once PTT is ever sensed — matching the behaviour of the actual TGXL front panel exactly. setSourceVisible() added to AccessoryPortRow following the setFrequencyVisible() pattern.

Commits
cc16bc5 — hold guards to prevent STANDBY/BYPASS display flicker
49fb09f — clear stale holds on single-command transitions
4e23fd1 — source label hidden until port trigger mode confirmed
CI notes

The accessibility bot reports 49 warnings across 16 src/gui/ files. These are pre-existing findings in files touched by this PR, not regressions introduced here. Build is not blocked.

rfoust and others added 30 commits September 7, 2026 19:51
Add optional NOAA radar and original-observation playback to both PSK Reporter map views, including bounded background loading, retained coverage, and the accepted globe navigation improvements.

Fix readiness after small pans and disclose unknown live observation age with persistent load-failure notices. The revised combined scope is explicitly accepted; opacity and dBZ legend follow-ups remain under aethersdr#3574.

Validation: macOS app build, 8/8 focused headless tests, regression counterchecks, and isolated automation-bridge label verification.

Squashed-from: aethersdr#5477
Co-authored-by: Robbie Foust <2991296+rfoust@users.noreply.github.com>
Co-authored-by: jensenpat <84298137+jensenpat@users.noreply.github.com>
…. Principle VIII.

Place the attached diversity pair's ordered master flag on the right and partner on the left, matching the contributor-reported SmartSDR reference. Preserve locked edge behavior and slice-ID fallback ordering. Add socket-free mapping and ordering coverage.

Squashed-from: aethersdr#5470
Co-authored-by: CJ Johnson <396488+chibondking@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## Summary

Follow-up to aethersdr#5402, which merged with one disclosed nit unfixed: in
`Hl2Backend::setPanRfGain` the equality early return

```cpp
const int clamped = qBound(kLnaGainMinDb, gainDb, kLnaGainMaxDb);
if (clamped == m_lnaGainDb)
    return;
```

sits **above** both `m_lnaSessionPin = false;` and the
`m_lnaDbByBand.insert(...)`
that follows it. So an operator who sets the gain to exactly the value
already
live neither ends a session pin nor records their band choice — and the
one
value guaranteed to be already live is the one a connect param pinned.

Concretely: 20 m stored at −12, connect with `lnaGainDb=20`, the
operator still
on 20 m deliberately sets 20. The write is a no-op, the pin survives,
and
`currentOperatingState()` keeps persisting −12 for a band the operator
has just
told the radio they want at +20.

This is the follow-up I committed to in

aethersdr#5402 (comment).
Nothing else in the merged fix changes.

**The window is narrow and worth saying plainly.** It is the **start
band only**:
after the first band change `applyPerBandStateFor` clears the pin, and
without a
pin `bandMemoryWriteback` returns the live value anyway. Reachability is
also
narrow — `lnaGainDb` is a connect param the shipped UI does not
populate, so a
pin needs an automation or embedder connect. And the failure direction
is
conservative: an old calibration survives rather than dying. That is
exactly why
@Ozy311 filed it as a non-blocking nit and why it should not have held
aethersdr#5402's
merge. It is still wrong, and it was uncovered as well as unfixed.

## What changed, and why this shape

The early return was protecting one thing that is genuinely redundant on
an
unchanged value: `applyLnaGainDb()`, which writes the AD9866 LNA
register through
`Metis`, moves `m_dbRef` and echoes `panRfGainChanged` to every pan.
That stays
guarded — an unchanged value is still not re-sent to the radio and still
does not
re-echo.

Everything **below** it was never redundant. The pin clear and the
band-map write
are consequences of *the operator having chosen this value for this
band*, and an
operator who deliberately confirms the pinned value has chosen it just
as much as
one who moved the slider. So the guard now wraps only the register
write, and the
persistence half runs unconditionally.

`notifyOperatingStateChanged()` is guarded on `moved || endedPin ||
recordedBand`
so that a write which moves nothing, ends no pin and changes no stored
entry does
not schedule a debounced store for a no-op. Any of the three actually
changing
notifies exactly as before.

## Constitution principle honored

**Principle VIII — Evidence Over Assertion.** The new assertions were
run red
against the unmodified merge base before the production change and green
after;
the exact commands and output are below. Not Principle XI: XI is the
squash-merge CI re-run and the maintainer's reproduction, which is not
mine to
claim.

## Test plan

Both directions were actually executed, in this worktree, in this order.

**1. Test added first, built and run against unmodified `origin/main`
(`10a566e3`) — RED:**

```
$ ninja -j 6 hl2_gain_restore_test      # test file only, production untouched
$ ./hl2_gain_restore_test
...
[ OK ] connect seeding creates a usable pan identity
[ OK ] same-value case starts pinned at +20 with 20m still stored as -12
[FAIL] an operator write of the pinned value itself records the band
[FAIL] the confirmed value survives a band round trip instead of reverting to -12
...
exit status 1 — 26 checks pass, 2 fail
```

**2. Production change applied, incremental rebuild, same binary re-run
— GREEN:**

```
$ ninja -j 6 hl2_gain_restore_test      # rebuilds Hl2Backend.cpp.o and relinks
$ ./hl2_gain_restore_test
...
[ OK ] connect seeding creates a usable pan identity
[ OK ] same-value case starts pinned at +20 with 20m still stored as -12
[ OK ] an operator write of the pinned value itself records the band
[ OK ] the confirmed value survives a band round trip instead of reverting to -12
...
exit status 0 — 28 checks pass, 0 fail
```

The mutation is the one @Ozy311's aethersdr#5402 review taught: the new assertion
must
fail on the merged code, not merely agree with a re-typed copy of the
rule.
That is also why the case is in `tests/hl2_gain_restore_test.cpp`, which
drives
the real `Hl2Backend`, rather than in the policy-header test.

- [x] Local build passes — for the `hl2_gain_restore_test` target and
its
`aethercore` dependency (`ninja hl2_gain_restore_test`, exit 0). **A
full
`cmake --build build` was NOT run here** and the rest of the suite was
      NOT run here; CI covers both.
- [ ] Behavior verified on a real radio — **not by me, not yet.** See
below.
- [x] Existing tests pass — the other 15 checks in
`hl2_gain_restore_test` pass
alongside the 3 new ones. Other test binaries were not built or run
here.
- [x] Reproduction steps documented — above.

### What still wants hardware, and who has it

Separately from this diff: the half of aethersdr#5402 that closes aethersdr#5400 — the GUI
startup
replay — has landed on socket-free tests and a demo backend. @Ozy311
wrote that
their fresh execution was limited to the standalone policy test and that
they did
not rerun the expanded suite or the GUI startup path; @jensenpat wrote
that the
bridge session was app-startup evidence, not HL2 hardware evidence, with
no
physical radio connected. I filed aethersdr#5400 from a real Hermes-Lite 2 and
still have
it, so the operator here will confirm the merged behaviour on hardware
across a
genuine restart and report it on aethersdr#5402 whether or not it agrees with the
tests.
That confirmation is not a precondition for this diff, which is a pure
persistence-ordering fix with a test that fails without it.

## Checklist

- [x] Commits are signed (SSH)
- [x] No new flat-key `AppSettings` calls — no settings keys added
- [x] Code is clean-room
- [x] All meter UI uses `MeterSmoother` — no UI touched
- [x] Documentation updated if user-visible behavior changed — none
needed; this
restores the behaviour the existing comment at the call site already
      describes ("This is also what ends a session pin")
- [x] Security-sensitive changes reference a GHSA — n/a

## Deliberately not widened

- `connectLna()`'s unclamped explicit-parameter branch (aethersdr#5402 bot nit 4)
is left
alone — it was documented rather than deferred, and
`Hl2BandMemoryPolicy.h`
  says so in its own comment.
- `MainWindow::restoreBandState` is untouched. It calls
  `m_radioModel.setPanRfGain(snap.rfGain)` without going through
`restoreLegacyRfGain`, so wiring it up would reintroduce aethersdr#5400 exactly —
but
it has no callers anywhere in `src` or `tests` (only its declaration in
`MainWindow.h:969` and its definition in `MainWindow.cpp:10614`). Dead,
not
  broken.
- No re-opening of the merged design, and no neighbouring refactor.

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

https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document the existing get dsp backend payload, chain levels, absence semantics, and property narrowing. Clarify that HL2 DSP runs on the host, distinguish get hostnb from model snapshots, and identify the fields queried directly from WDSP.

Validation: bridge documentation checker, JSON example and source field comparison, whitespace check, and PR CI. Documentation only.

Squashed-from: aethersdr#5465
Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: jensenpat <patjensen@gmail.com>
## Summary

Closes aethersdr#5478.

Adds an opt-in NASA/GSFC 2016 VIIRS city-light overlay to the PSK
Reporter flat map and globe. Saved controls provide brightness (70%
default), faint-light enhancement (50%), and warmth (25%; zero retains
NASA's original grayscale appearance). Day/night fades lights through
civil twilight using current UTC; the imagery is historical and
independent of radar playback.

Radar support from aethersdr#5477 has merged. This branch is rebased onto main at
`8e7a670b9f91d735171fca0aa9821bfc07ff18b7`; the diff contains only
city-light changes.

City-light-specific changes:
- Shared bounded NASA WMS loader with HTTP disk caching,
response/dimension validation, cancellation, and retry status; fixed
2016 dataset with visible attribution.
- Flat-map world wrapping and globe geographic texture placement, below
radar and report markers/paths.
- GPU brightness, faint-light, warmth, and twilight processing on the
globe; slider changes and unrelated basemap uploads retain city-light
textures and shaders.
- One compact, click-through map notice stacks radar and city-light
loading messages. Each clears independently, with delayed display to
avoid flashes on quick city-light loads.
- Documentation: `docs/psk-reporter-city-lights.md`.

The proposed control layout and appearance defaults need maintainer
design review. Full-world/date-line exports still have less detail than
regional views; this is not a tiled city-light renderer. No new
dependency or radio/protocol behavior is introduced by the city-light
commits.

## Constitution principle honored

Principles V and VII: preferences remain under the existing PskReporter
object; external image responses and dimensions are bounded and
validated. Principle IV: implemented independently from public NASA
documentation and imagery. OpenHamClock was used only as a public visual
reference; no implementation code was inspected or copied. Principle XI:
a native GPU regression probe failed when basemap uploads discarded
city-light resources and passed after removing those resets.

## Test plan

- Native macOS ARM64 RelWithDebInfo build with RADE enabled; final
executable and build processor verified ARM64, with no RNNoise x86
sources.
- Focused CTest: city_lights_source_test, weather_radar_source_test,
weather_radar_placement_test, weather_radar_wrap_render_test,
weather_radar_loading_test, map_image_cache_test, globe_navigation_test,
psk_reporter_map_behavior_test.
- Opt-in native OpenGL test:
cityLightsGlobeDrawsAboveDetailAndBelowRadar covers actual rendered
output, slider changes without texture replacement, basemap-upload
residency, warmth, and radar ordering.
- Isolated DEMO-0001 app validation with transmission disabled:
controls/layout inspected, both projections exercised during
development, preferences checked in the isolated settings database. No
live radio used.
- Engine boundary, test registration, frozen CI test gate, and diff
whitespace checks.
- New city-light source tests inject QNetworkReply objects and do not
bind sockets or emulate external firmware. GPU cases use the existing
explicit environment opt-in; they skip in ordinary runs.
- Windows/Linux execution and hosted CI results are not claimed. New
registered tests follow the repository's full-suite policy; the per-PR
frozen test gate is unchanged.

## Checklist

- [x] Commits are signed and signatures verified
- [x] No new flat-key AppSettings calls
- [x] Clean-room implementation
- [x] User-facing documentation updated; CHANGELOG unchanged
- [x] Local ARM64 build and focused checks
- [ ] Hosted CI reviewed
- [ ] Maintainer design review
- [x] Radar dependency merged and city-light branch rebased onto main

---------

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…current log sites (aethersdr#5481)

Closes aethersdr#5480.

`redactPii()` grew one hand-written regex at a time as log sites were
added, and the patterns drifted apart: the name and coordinate rules
learned to match quoted and JSON-shaped values while the token rule
never did, so the same value was scrubbed in one spelling and passed
through in another. This regenerates every keyword rule from a single
table over one shared value grammar, so a spelling that works for one
field works for all of them.

New `src/core/LogRedactionPolicy.h` holds the tables, mirroring how
`SettingsCredentialPolicy` backs `SettingsSanitizer`. Adding a field is
one row plus one test case.

## Coverage added

Each with a case in `async_log_writer_test`, driven through the real
writer (`enqueue` → `formatLine` → `redactPii` → disk) rather than
through `redactPii()` alone:

- **Home-directory prefix → `~`** for `/home/<user>`, `/Users/<user>`
and `C:\Users\<user>`, both slash styles and Qt-doubled backslashes, and
for a root the running process does not itself have (Flatpak, elevated
runs, a log copied off another machine). User names may contain spaces,
so the segment runs to the next separator.
- **IPv6 literals** — compressed, bracketed-with-port, IPv4-mapped and
`%scope`.
- **Email addresses**, in prose and as field values. `SmartLinkClient`'s
call-site truncation is now the redactor's job.
- **`user` / `username`**, in both `user <name>` and `username=` forms.
- **Peer hostnames** after `connecting to` / `connected to` /
`reconnecting to` / `disconnected from` / `connect to` / `pin for` /
`resolving`, with `:port` and trailing prose preserved.
- **Maidenhead grid** as a keyword field and as a bare word after
`grid`, plus quoted coordinate pairs like `location="47.6,-122.3"`.
- **URL authority**, including userinfo.
- **Quoted, JSON and Qt-escaped (`\"value\"`) spellings** for every
field rule, not only bare `key=value` protocol text. The JSON fixtures
are generated through `QDebug`'s own formatting rather than
hand-written, so they prove the rules survive Qt's quoting.

## Two bugs found while regenerating the rules

- The IPv6 rule now keys on *contains `::`* or *exactly eight groups*. A
looser "two or more hex groups" pattern also matches a MAC address and
an elapsed-time value like `12:34:56`, and destroyed both — `mac
00:1C:2D:05:37:2A` became `[v6-redacted]`. MAC redaction now runs ahead
of it too. Pinned by `testIpv6DoesNotEatMacOrClock`.
- A retained value prefix is emitted only when the value is **strictly
longer** than the prefix. A value no longer than the prefix was
previously "redacted" to itself.

## Support bundle

`SupportBundle` now streams historical logs through `redactPii()` into
the archive instead of `QFile::copy`. Lines are redacted at capture, so
a log written by this build is already clean — but an older log on disk
was written by whatever redactor shipped at the time, and a bundle
generated after an upgrade carried those lines out unchanged. Redaction
is idempotent, so re-running it over a clean line is a no-op. The source
log on disk is deliberately left alone: it is the operator's own
diagnostic record, and rewriting it would destroy detail they may still
need.

## Docs

`docs/log-redaction.md` records what the redactor does and does not
scrub, so a new log site can be checked against it. The **limitations**
are the point of the page: a keyword matcher cannot see a value that
carries no keyword, and several writers never reach `AsyncLogWriter` at
all — the SpotHub feed logs (`DxClusterClient`, `N1MMSpotClient`,
`SpotCollectorClient`, `WsjtxClient`, `FreeDvClient`) write directly and
are also not collected into a support bundle. Documented rather than
papered over, so a passing redactor unit test is not read as "all
project logs are sanitised".

Also documented: the fields deliberately **not** redacted (callsign,
model, firmware, version, ports, stream ids), with a test asserting they
stay readable, since removing them has broken triage before.

## Verification

- **349/349 CTests pass** and the app links.
- All seven static checks pass: engine boundary `--strict`, touchpoint
manifest `--check`, test registration, frozen CI gate, network timeouts,
theme seed, shader dialects.
- **Four mutations confirm the new tests bite** — removing the home-path
rules, removing the auth-scheme skip, and removing the short-value guard
each fail their case. The short-value assertion was *tightened after the
first version of it passed against the mutant*: it had asserted the
absence of `token=ab ` with a trailing space, which is absent either
way.

⚠️ **Reviewers on Arch:** this branch cannot be linked on a box running
`onnxruntime-cuda 1.29.0-3` — a protobuf ABI mismatch in the system
`libonnxruntime.so` breaks 326 targets. Pristine `origin/main` fails
identically, so it is environmental, not this change. The numbers above
are from a build configured with onnxruntime hidden from pkg-config.

## Not in this PR

- No individual log site is changed, per the issue's stated scope — the
coverage is in the redactor so existing sites are fixed together. One
site raised in review does need a call-site decision rather than a
redactor rule, and is left for a separate change with its own scope
ruling.
- `SettingsSanitizer` is untouched; it is already policy-driven.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Share NASA GIBS and NWS outage cooldowns across map consumers while retaining local cache access. Bound Retry-After, preserve cached images on transport failures, and keep radar readiness independent of provider backoff and camera movement.

Verified full macOS ARM64 build with RADE, three focused CTests, five detected regression mutations, current-main merge-tree equivalence, and an isolated DEMO-0001 map lifecycle smoke test. Review requests addressed and all 11 threads resolved.

Co-authored-by: jensenpat <patjensen@gmail.com>
Co-authored-by: Robbie Foust <rfoust@gmail.com>
## Summary

RTL RFC aethersdr#5468 requires every receiver's complete guarded RF passband to
fit the shared capture before tuning, filter/mode changes or sample-rate
changes are accepted. Add a pure `SharedCapturePolicy` helper that
selects the nearest legal quantized tuner center, validates coherent
readback and produces a deterministic restore plan within the current
capture.

The helper validates asymmetric filter/usable margins, signed
carrier/BFO translation, transition guards, disjoint hardware domains,
bounded numeric inputs, stable slot IDs and caller-supplied receiver
capacity. It keeps an already legal center, refuses an impossible
desired set atomically, and restores fitting entries in ascending
stable-ID order without recentering or renumbering. Conservative
floating-point arithmetic rejects unrepresentable boundary fringes
rather than widening acceptance with a tolerance.

This is F2 of aethersdr#5468 and is independent of P01 aethersdr#5471. The helper compiles
into the static `aethercore` library; the registered test compiles that
same implementation. No live backend calls it yet.
USB/DSP/audio/viewport/UI/persistence integration and supported receiver
counts remain later RFC phases. The new contract is documented in
`docs/shared-capture-policy.md`. This PR does not close the umbrella
RFC.

## Review follow-up

An additional regression rejects a center grid whose 1e-5 Hz step is
smaller than the double spacing at 2^39 Hz while its grid indices remain
within the independent index bound. The capture and slice otherwise fit.
Disabling only the resolution guard makes this new check fail, closing
the reported mutation-coverage gap. The final readback validation
remains, with its defense-in-depth purpose made explicit.

The contract now distinguishes adapter-declared logical readback from
measured physical RF frequency. In [Osmocom librtlsdr
v2.0.2](https://github.com/osmocom/rtl-sdr/blob/v2.0.2/src/librtlsdr.c#L884-L913)
and [RTL-SDR Blog commit
aed0ea19](https://github.com/rtlsdrblog/rtl-sdr-blog/blob/aed0ea19f3a273370a13c9009b96313c75d54c7b/src/librtlsdr.c#L887-L936),
`rtlsdr_get_center_freq()` returns the cached requested frequency stored
by successful tuning; it does not measure achieved PLL frequency. Other
versions and forks need verification. Actual readback must still satisfy
the declared domains and complete-set containment checks.

## Constitution principles honored

Principle VII — invalid numeric descriptors, impossible intervals,
malformed identities, oversized collections and incoherent readback fail
at the policy boundary. Principle II — a selection is only a proposal;
the integration layer must validate coherent capture state before
publication. Logical library readback does not establish physical RF
accuracy.

## Validation

- Follow-up on macOS ARM64, Apple clang 21.0.0, standalone C++20 `-O2`:
**260 checks, 0 failures**. The test is socket-free and requires no Qt
event loop, hardware, DSP or settings.
- Follow-up mutation in a temporary source copy: disabling only `stepHz
< resolution` produces **260 checks, 1 failure**, specifically the new
unresolved-grid assertion. Production guards remain unchanged.
- `git diff --check` passes for the follow-up.
- Prior reviewed head `5cd78ad10bf3ce86c404cddf7c2aea9b4215c844`: Linux
normal engine policy object and registered CTest built with GCC 16.2.1;
all 259 then-existing assertions passed. The independent rational oracle
matched all 2,000 cases, upper-transition-guard and generation mutations
failed as expected, and the exact restored source passed. Strict
engine-boundary, test-registration, frozen CI-gate and touchpoint checks
passed on that head. Those are recorded prior-head results, not a new
Linux run of this follow-up.
- Linux follow-up: normal engine policy object and registered CTest
rebuilt; **260 checks pass**. Removing only the resolution guard
compiled and failed the new assertion. Exact source restoration, rebuild
and final CTest pass. Strict engine-boundary, registration, frozen CI
gate, touchpoint manifest and whitespace checks pass.
- Signed follow-up head `bec2815c605442864fc425133ddd887cb200680d`; full
source matches validation base
`e60c3bea2ac8e5b762be9a3c6f52aa1bab8fb9ad` plus six-file digest
`b2237190f1a875addf40557d39d9e5e8c01fcc171906908861a1c51ac46c4cf5`. All
seven PR checks passed on this follow-up head.

- Review follow-up `beb054c384c9bb1334c68ab6ecb8bc8ec96ebd93` corrects
only the Osmocom source anchor; implementation and tests are unchanged.
Independent review of `bec2815c` merged with main `f0ed969b` passed all
260 checks in native macOS ARM64 optimized and ASan/UBSan builds.
Grid-resolution, upper-guard, generation and restore-capacity mutations
all failed as expected. Engine-boundary, registration, frozen CI-gate,
touchpoint and whitespace checks passed. See the live Checks tab for the
documentation follow-up status.

No live-radio or performance claim is made for this unintegrated policy.
The test is registered in the default graph without expanding the frozen
per-PR allow-list. No dependencies, threads, settings owner,
socket-owning test, UI controls or CHANGELOG entry are added. Full
application behavior, physical RF accuracy and supported receiver
capacity remain integration gates.

---

73, Ozy **K6OZY**  · GPT-6 Astra-Ultra

---------

Co-authored-by: Robbie Foust <rfoust@gmail.com>
RTL discovery sometimes uses `rtl:<index>` to select a device. This PR
separates that connection locator from the USB-reported serial, so
restore, retry and save use the same reported-serial identity. A
same-family swap flushes and disconnects the old session before changing
its scope.

Adds the compiled `RtlSliceSettings` schema-1 owner, exact-row
patch/remove semantics, explicit legacy migration, and fixed-capture
restore planning through F2. Empty documents suppress reimport;
unsupported/corrupt documents fail closed; failed atomic writes remain
retryable; omitted slices and unknown members survive. Status-aware
exact reads distinguish missing, corrupt and unavailable rows, including
corrupt JSON with a newer raw schema. Generic fallback readers retain
their behavior.

## Identity and upgrade impact

The identity rule implements [Jeremy's RFC
approval](aethersdr#5468 (comment))
and RFC §1.1. It deliberately allows shared saved state:

- Unique genuine reported serials, including numeric serials, retain
their scope.
- Anonymous dongles write the RTL family row and share its tuning, mode,
passband, rate and RF-gain memory. An identified dongle with no exact
row inherits that family state; an existing exact row takes precedence.
- Duplicate reported serials share one exact row, including the second
dongle previously connected through an index locator. They can overwrite
each other's saved state.
- Old locator-keyed `OperatingState` rows such as `rtl:0` remain intact
but unclaimed. When no applicable serial/family row exists, the first
connection receives empty restored state and uses backend defaults until
state is saved under the new scope. If an applicable row exists, it is
restored instead. No automatic migration or per-dongle recovery of
ambiguous index-keyed rows is provided.

These changes affect anonymous and index-selected duplicate identities,
not every RTL operator. The owner migration below is not a migration of
these active `OperatingState` rows. [The contract
document](https://github.com/aethersdr/AetherSDR/blob/6efba467bbe7773eec5c634fa154e05b83d8f27a/docs/rtl-slice-settings.md)
details the consequences.

The family-row sharing follows Jeremy's explicit RFC approval; this PR
does not claim per-dongle isolation. The earlier review request for a
separate anonymous namespace or disabled persistence is superseded by
that approved rule.

## Staging and ownership

This is F3a of RFC aethersdr#5468, following merged F2 aethersdr#5472 and targeting main.
Current `OperatingState` remains the sole runtime owner of RTL
tuning/passband/rate. `RtlSlices` migration and runtime writes remain
inactive until F4/M1 supplies accepted capture state, measured margins
and mode/BFO metadata. The documented cutover preserves the frozen
legacy snapshot through an explicit RF-gain writer. No multi-RX or DSP
behavior is enabled here.

Principle V governs the single feature owner and stable keyed entries;
Principles VII/XIV govern malformed/newer-state refusal and atomic
writes.

## Original Linux validation (head `4c2e6e97808d`)

- Linux x86-64 on Nobara-Dell, GCC 16.2.1, Qt 6.11.1, RelWithDebInfo
with RTL enabled: complete `aethercore` library and four focused test
targets build.
- Four registered CTests pass: `rtl_slice_settings_test`,
`radio_state_memory_test`, `rtl_backend_test`, and
`shared_capture_policy_test`. The owner/model test passes **119
assertions**; the F2 policy test passes **260 checks**.
- The new assertions exercise production model save/connect/restore
paths using an injected transport-free backend:
anonymous-to-unseen-identified fallback, exact precedence,
anonymous/duplicate sharing, and preserved/unclaimed old locator rows.
They also test the inactive `RtlSlices` owner's corresponding behavior
and rejection of a malformed old document even when the proposed patch
would replace its bad field.
- Removing only the old-document decode gate makes the new corruption
assertion fail. The source was restored exactly, the engine/tests
rebuilt, and all four CTests passed again. Earlier F3a mutation evidence
also covers wrong preconnect scope, newer-schema bypass and dropped
unknown gain fields.
- Strict engine boundary, test registration, frozen CI gate, touchpoint
manifest and whitespace checks pass. Existing boundary debt and the
frozen seven-test gate are unchanged.

Validated source for signed head
`4c2e6e97808d8ddd47b54d35634020a76c2de262` is Linux base
`5cd78ad10bf3ce86c404cddf7c2aea9b4215c844` plus source digest
`27d689ae7faf74310728d34b6de744846d02d3d08868f613ee27da675b2e82cb`.
Tests used isolated Linux user/network namespaces and temporary settings
profiles; the new test opens no socket listener or synthetic peer. This
establishes engine/focused-test compilation and behavior, not a full
application, GUI, physical USB reorder, hardware or live RF result. No
app launcher changed.

## Bookmark repair and current-main integration

The canonical identity change also affected Band Stack, whose old
empty-ID guard refused anonymous bookmark saves. Connected anonymous RTL
scopes now explicitly carry anonymous-identity provenance. Band Stack
accepts that deliberate family scope, while ordinary unknown empty
scopes and disconnected anonymous models still refuse bookmark access.
Manual/automatic adds, expiry, removal, reconnect and store-reopen
persistence are covered through the production model and storage
classes. Identified bookmark scopes retain exact-row behavior; anonymous
dongles share their family bookmark row. Ambiguous old locator-keyed
bookmarks and legacy sections remain intact and unclaimed; no automatic
recovery is provided.

Merged current main, including aethersdr#5472
(`ab03be0629a498603ff02ef68a7dbfbd0de91d10`), preserving both
settings-consumer registrations and the corrected librtlsdr source link.
The PR now targets main for full platform CI.

Repair validation on macOS ARM64 (Qt 6.11.1, RelWithDebInfo, RADE
enabled):
- Built aethercore and four focused targets; `rtl_slice_settings_test`,
`bandstack_scoped_test`, `radio_state_memory_test` and
`shared_capture_policy_test` pass.
- Reinstating Band Stack's old empty-ID guards makes six new regression
assertions fail. Restored the fix, rebuilt, and all four tests pass.
- Strict engine boundary, test registration, frozen CI gate, touchpoint
manifest and whitespace checks pass. Test executable and CMake
processors verified ARM64; RNNoise x86 sources absent.
- No new socket test, live radio, GUI or USB validation. Local librtlsdr
is unavailable, so the RTL hardware backend target was not built
locally. The earlier Linux RTL-enabled validation above remains separate
evidence.


Related: aethersdr#5468. Does not close the multi-stage RFC.

---

73, Ozy **K6OZY**  · GPT-6 Astra-Ultra

---------

Co-authored-by: Robbie Foust <rfoust@gmail.com>
)

## Summary

Fixes aethersdr#5501. When Flex restores manual SQL 26 after a band change, the
RX applet currently enters Manual using its stale cached threshold 20.
Adopt the incoming threshold before refreshing the manual controls, so
the slice cache, RX slider and VFO slider agree without another SQL
write.

Combined slice deltas publish SQL before `modeChanged`. Reuse the
existing mode-eligibility rule against the current model mode, rather
than the button's previous enabled state. This keeps incoming CW state
out of an Off-to-Manual transition and permits an incoming USB threshold
to be adopted in the same delta. The existing mode/capability policy
itself is unchanged.

Production changes are limited to RxApplet. No new persistence, command
encoding, timer, backend logic, or dependency is introduced. Detached
slices already reopen their manual-echo gate; the tests exercise that
production detach path.

## Constitution principle honored

Principle II: reconcile incoming radio state without replaying the
client's remembered threshold. Principle XI: drive the real applet/model
transition and verify that removing the fix makes the regression fail.

## Validation

- Local macOS ARM64 RelWithDebInfo app build, `cmake --build build
-j22`.
- Headless, socket-free CTest selection:
`rx_applet_squelch_reconciliation_test`,
`slice_model_squelch_memory_test`, `rx_filter_step_test` (3/3 passed at
signed commit `05c68846cc0dcbfe882962a036cc09a6b6aa27da`).
- The new target constructs production SliceModel, RxApplet and
VfoWidget objects, with no RadioModel instance, radio connection or
firmware peer. It checks the captured band/status burst in both widget
connection orders, split-field updates, repeated reports, later
SQL-button writes, Auto(8)/margin(10)/Manual(45) separation, detached
and distinct slices, Kiwi exclusion, combined CW/USB status, and the
existing digital override/detach behavior. The Auto-off starting
sequence from [issue
3505](aethersdr#3505) is an injected
regression check only. That separate issue remains open and requires
independent verification.
- Mutation: restoring the previous manual-cache condition makes both
band-burst rows fail with actual 20 versus expected 26. The level-first
split row and combined mode/status row also fail. The production fix was
restored, rebuilt at the signed commit, and all 3 selected CTests passed
again.
- Engine-boundary, test-registration, frozen CI test-gate, bridge
documentation, and whitespace checks passed.
- Commits signed. No new socket-owning test, CI gate addition, or
CHANGELOG entry.

## Review follow-up validation

Signed follow-up `b539715affa079dde897685a13478881bc7ddac0` adds two
socket-free full-on-report rows and documents the reconciliation
contract. The focused ARM64 RelWithDebInfo build (dedicated
`build-codex-arm64`, local ARM64 toolchain, RADE enabled) passed all
three selected CTests. Removing the Off-to-Manual cache adoption made
both new rows fail with manual cache 45 versus expected 8; restoring the
guard returned all three CTests to green. The prior independent review
also removed the complete production fix and observed four failures in
the original band/split/mode rows. No additional hardware run was
performed for this test/documentation follow-up.

The new test runs locally and joins the unfiltered post-merge Full Suite
and weekly sanitizer workflows. The frozen per-PR CI selection compiles
it but does not execute it.

## Live proof

Receive-only agent automation bridge run on FLEX-8400M firmware
4.2.18.41174, one owned slice/pan, isolated settings, stable
GUIClientID, TX permission disabled.

| Observation on return to 20m USB | Radio/live SQL | Manual cache | RX
slider | VFO slider state |
|---|---:|---:|---:|---:|
| Unfixed binary | 26 | 20 | 20 | 20 |
| Fixed, before restart | 26 | 26 | 26 | 26 |
| Fixed, after normal Quit/relaunch | 26 | 26 | 26 | 26 |

Seeded 20m USB Manual/26 versus 40m LSB Off/20 with actual SQL controls,
then used the production BAND actions. The old binary reproduced the
mismatch without an intervening mode change. The fixed binary reconciled
two complete pre-restart returns and two complete post-restart returns;
an additional post-restart return was sampled during temporary slice
teardown, retained, then observed converged before proceeding. Normal
restart changed PID 71740 to 71766 with unchanged GUIClientID and first
process exit code 0. Before restarting, a Manual → Auto → Off → Manual
button cycle preserved the manual 26 separately from the computed Auto
level (62 observed) and margin 10, then sent/restored 26.

The retained capture contains 201 model snapshots and 5,815 unique log
events across old/fixed phases, with zero detected log gaps. Captured
band-return intervals contained no outgoing SQL commands; the
socket-free regression is the proof of the isolated no-write
requirement. Original 20m and 40m slice/pan values were restored with
empty comparison diffs (excluding session object/handle identifiers),
returning to 20m USB SQL Off/20. All observed snapshots reported no
transmitting; the client quit and lock was released.

The live binary is verification merge
`9a8ea1b59eea5226dd6e1788608c5f9a22abc650`: this fix at
`05c68846cc0dcbfe882962a036cc09a6b6aa27da` plus aethersdr#5500 at
`93bbff39cf59605bde00405a0bb4a935ba4aae38`, which supplies the Persist
diagnostic fields. Those diagnostic changes are excluded from this PR.
The combined app also built successfully and passed the same 3/3 focused
CTests. Fixed executable SHA-256:
`a785be3ed2f4904c0ac9528334024ee61ec1075bd8ddcae4166bc40b8c2dfa43`;
unfixed executable:
`9e0b0b7447ee48a354e5a8cd97778550ff5ec2eb8b877327d6e536e6131ad8e3`.

Before → after RX applet captures below. The native slider has no
numeric label; the table and [structured
evidence](https://raw.githubusercontent.com/jensenpat/AetherSDR/7096420249e7cd6c4098f12aeb687a8bc664b676/.pr-assets/5501-proof.json)
provide its numeric state. VFO state was read from its widget while that
surface was hidden; no rendered VFO claim is made.

![Before: stale RX squelch slider at
20](https://raw.githubusercontent.com/jensenpat/AetherSDR/7096420249e7cd6c4098f12aeb687a8bc664b676/.pr-assets/5501-before.png)
![After: RX squelch slider reconciles to
26](https://raw.githubusercontent.com/jensenpat/AetherSDR/7096420249e7cd6c4098f12aeb687a8bc664b676/.pr-assets/5501-after.png)

This is focused SQL diagnostic evidence, not a full RadioCert Persist
certification or proof of physical audio gating. Other Flex
models/firmware, MultiFlex, hardware occurrence of arbitrary delayed
full SQL-on report ordering, and Icom remain unverified. The Auto
regression covers active Auto, delayed threshold-only/off reports, and
full on-reports after both operator-driven and radio-driven Off. Full
on-reports are reconciled as current radio truth, including their
threshold; a later Off/45 acknowledgement supersedes a delayed on/8
report. Both sequences assert RX/VFO agreement and zero outgoing
commands or squelch intents. These are injected ordering tests, not
claims that specific firmware produces these sequences. No bridge
addition was needed beyond the already proposed aethersdr#5500 diagnostics.

Generated with OpenAI Codex (GPT-6 Astra)

---------

Co-authored-by: Robbie Foust <rfoust@gmail.com>
## Summary

Part of aethersdr#2554: add the Runtime Monitor Overview tab on top of the landed
Threads, Logs, and Memory increments. Four cards show CPU Total, Max
Thread, resident memory, and GUI Tick Lag; four charts show CPU, memory,
the top five threads, and tick lag across 1 min / 5 min / 15 min / 1 h
ranges.

The Overview page scrolls at small dialog sizes while preserving the
graphs' 220 px minimum height. This keeps the complete five-row thread
legend and plot available without forcing every tab or restored dialog
geometry above screen height.

CPU Total uses cumulative whole-process CPU time
(`getrusage(RUSAGE_SELF)` / `GetProcessTimes`) over the measured
interval, normalized by `idealThreadCount()`. CPU work from threads that
start and exit between snapshots is retained. Per-thread sampling
supplies Max Thread and the window-ranked top-five chart; an unavailable
busiest-thread reading displays a dash. Worst tick lag buckets by
maximum, while mean fields average. `MainWindow` destroys the dialog
before its owned rings and meter, and unchanged card bands avoid
redundant restyling.

The maintainer accepted GUI Tick Lag instead of frame rate, five
independent thread lines instead of stacked area, and per-tab timeframe
selectors; selector consolidation is tracked in aethersdr#5496. Thresholds remain
the issue's starting values (CPU 50/80%, thread 70/90%, memory 1/2 GB);
GUI Tick Lag is unbanded. The heartbeat uses `Qt::PreciseTimer`, which
also sharpens `PerfTelemetry`'s measurement.

## Scope

| File group | Purpose |
|---|---|
| SystemInfo / SystemInfoCollector | Whole-process CPU accounting and
typed samples with explicit busiest-thread validity |
| CpuHistoryRing / UiTickLagMeter | Bounded history, window ranking,
bucket maxima, and event-loop lag |
| SystemInfoDialog / MainWindow | Cards, charts, scrollable Overview,
sampling and ownership |
| TimeSeriesGraphWidget | Wrapped legends and value-hint/tick-label
overlap correction |
| Tests / tests.cmake / pipelines.md | Socket-free regression coverage,
registration, and sampling documentation |

The shared graph changes also affect Network Diagnostics: wrapped
entries remain named/clickable rather than being dropped, and tick
labels no longer show through value hints. The Overview scroll fix is
confined to SystemInfoDialog. No feature changes to radio protocols,
settings, CI gates, or CHANGELOG. Main is integrated through `604127e7`;
its unrelated changes are not part of the feature diff.

## Verification

Final integrated head: `661b800f` (fix `878b5225`).

- Five focused registered tests: `system_info_test`,
`system_info_collector_test`, `system_info_dialog_test`,
`cpu_history_ring_test`, and `ui_tick_lag_meter_test`.
- CPU workloads now measure native thread CPU time, synchronize snapshot
boundaries, and require bounded real work. The collector queues churn
before startup and completes it before its first timer tick. No fixed
wall-clock sleep is treated as guaranteed CPU availability. Both CPU
targets passed three repeated pairs with 16 concurrent CPU-load
processes; all load processes were stopped afterwards.
- The populated dialog test supplies ten records and five duplicate
thread names requiring TID labels, shrinks the dialog, checks scrolling,
and verifies all five series colors render. Restoring the previous
layout makes all five render checks fail.
- Restoring the collector's defective live-thread sum fails the measured
churn assertion (0.00% versus 0.64% required in the mutation run).
Changing Worst back to bucket mean fails the 40 ms versus 26.25 ms
assertion; removing the lag floor is also caught.
- Strict engine boundary, test registration, touchpoint manifest, frozen
CI gate, whitespace, color ratchet (+0), and changed-GUI accessibility
checks pass.
- The focused Runtime Monitor targets run after merge in full-suite and
weekly in sanitizers; they are not in the frozen per-PR test gate. The
sanitizer CI check only configures the sanitizer options.

Full application build passed on macOS ARM64, Qt 6.11.1, RelWithDebInfo,
RADE enabled; CMake host/system and executable verified ARM64, with no
RNNoise x86 sources. The final five focused tests passed in 4.51 s while
compilation was still active. All five GitHub checks passed at this head
(Linux/macOS/Windows builds plus static checks and sanitizer
configuration).

Final full-app smoke: owned offscreen instance, isolated settings,
explicit authenticated socket, PC output mute verified, TX disabled,
only `DEMO-0001` (AetherSDR Demo). All four timeframe selections
succeeded; 750x480 resize returned exactly 750x480; `scrollTo
systemInfoOverviewThreadsGraph` returned `inViewport:true` and
`vScroll:218`, with all five thread labels visible in the inspected
screenshot. Close/reopen retained history (12 samples before, 13 after
resuming). Cards continued updating after disconnect (CPU0.7%, Max
Thread8.9%, memory163.5MB). Normal quit with the monitor open completed
and the owned process exited. Qt's offscreen plugin cannot render the
QRhi spectrum; the Runtime Monitor QPainter charts and automation
remained functional. No final native Windows/Linux runtime or sanitizer
runtime pass is claimed.

Windows timing evidence supplied by the author on `7874f875`:
coarse-timer idle card readings of 13.6–14.0 ms became 1.0–3.6 ms with
the precise timer on the same Windows 11 / Qt 6.8.3 machine. This was
reviewed but not independently rerun for the final patch. [Before/after
thread](aethersdr#5427 (comment)).

## Contribution checks

- Signed commits; current-main integration signed.
- Clean-room implementation; no new engine-to-GUI dependency.
- No live radio or TX validation; the change measures the client's own
runtime.
- Principle XI: fixes are demonstrated with failing mutations and
restored passing tests.

## Earlier author evidence

The following artifacts cover their explicitly named earlier SHAs, not
the final head:


[aethersdr-pr5427-overview-tab-2026-09-04.zip](https://github.com/user-attachments/files/31852689/aethersdr-pr5427-overview-tab-2026-09-04.zip)
— heads `939db8e7` and `d14709d1` (Overview cues, legend wrap,
Network-dialog sweep, observer-effect bench)


[aethersdr-pr5427-cpu-total-fix-2026-09-05.zip](https://github.com/user-attachments/files/31868502/aethersdr-pr5427-cpu-total-fix-2026-09-05.zip)
— head `d45e2f7e` (CPU Total fix: the two regression targets' full
outputs, mutation transcript, full ctest log, card-vs-footer tree dump,
About screenshot)


[aethersdr-pr5427-review-round-2026-09-08.zip](https://github.com/user-attachments/files/31939275/aethersdr-pr5427-review-round-2026-09-08.zip)
— head `aa71ec6c` (About SHA, focused targets, full ctest log, the
bridge `resize` before/after, quit-with-dialog-open shutdown trace),
sha256
`128bba0f4b909670618b3329c488add05acd4d93980adbe0bc1bc8fdcaf7e006`


[aethersdr-pr5427-windows-ticklag-2026-09-08.zip](https://github.com/user-attachments/files/31969002/aethersdr-pr5427-windows-ticklag-2026-09-08.zip)
— heads `aa71ec6c` (before) and `7874f875` (after, `Qt::PreciseTimer`),
Windows 11 x86-64: README with the before/after table, both
configure+build logs, both app logs, transcript of every reading, box
facts, timer-resolution readings, six screenshots; attached on [comment
5589502642](aethersdr#5427 (comment)),
sha256
`b9accaec9a0ee3315cd4f7cb4de37937791058966c4004f282216556c07fb575`


<img width="760" height="1336" alt="1-about-d14709d1"
src="https://github.com/user-attachments/assets/cbf55eb2-b7c8-4eb2-911b-fbd36d425f25"
/>

<img width="1800" height="1392" alt="2-overview-tab"
src="https://github.com/user-attachments/assets/ca77e19a-fa8a-44fb-ad45-d3c40a424121"
/>

<img width="1800" height="1392" alt="3-legend-all-five-named"
src="https://github.com/user-attachments/assets/0d480445-1d9e-4093-8ea4-a9906a792695"
/>

<img width="854" height="442" alt="4-legend-third-row-click"
src="https://github.com/user-attachments/assets/6f6657b7-50bb-4484-b8b8-3c52589b9aa3"
/>

<img width="1960" height="1520" alt="5-network-overview-before"
src="https://github.com/user-attachments/assets/0f9e0d8d-f725-455f-930e-3d3e4f2d4a4f"
/>

<img width="1960" height="1520" alt="6-network-overview-after"
src="https://github.com/user-attachments/assets/d2d19d85-2f0d-4ac7-8e6c-9a2ab73b28ab"
/>


— authored by agent (Claude Code) on behalf of @skerker

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

https://claude.ai/code/session_01K15daEuCZ3NwghXsXcfiBV

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Ozy311 <Ozy311@users.noreply.github.com>
Co-authored-by: Robbie Foust <rfoust@gmail.com>
…rsdr#5493)

## Summary

Closes aethersdr#5492.

The PSK Reporter controls currently crowd horizontal rows above the map.
This change moves them into a resizable, scrollable left sidebar: WSPR
first, then Reports, Band conditions, Map, City lights, and Weather
radar. It uses existing theme tokens and combo styling, with explicit
keyboard tab order.

Transmit once and the beacon status dot/text share a row. A single
footer beneath the map contains the mode legend, reception statistics,
update status, and connection indicator. Attribution stays on the map,
with wrapping and collision handling in both flat and globe renderers.
Sidebar wheel gestures scroll instead of changing WSPR spin boxes or
overlay sliders; intentional click, drag, and keyboard editing remain
available.

This is a visual/UX proposal for maintainer review. Dark basemap
rendering is deferred to a separate change.

## Constitution principle honored

Principle XI — the scroll regression was reproduced in a Qt harness, and
the fix was exercised with focused controls, the spin-box editor, and
scroll limits. Principle V — existing feature-owned settings remain in
the PskReporter object.

## Test plan

- [x] macOS ARM64 application build with the dedicated toolchain;
verified ARM64 host/system/executable and absence of RNNoise x86
sources. RADE remains enabled.
- [x] Existing `psk_reporter_map_behavior_test` passes. This covers
client/map behavior, not the new dialog layout.
- [x] Isolated Qt harness verifies wheels preserve spin-box/slider
values and scroll the sidebar, keyboard editing still works, and
scrolling at the limit preserves values.
- [x] Earlier isolated UI checks covered 1200×900 and 720×480 layouts,
light/dark app themes, and native globe rendering. Final wheel fix was
built and harness-tested.
- [x] `git diff --check` passes; colour and accessibility audits passed
during implementation.
- No live transmit testing. Linux/Windows runtime checks have not been
performed; upstream CI is pending.

## Checklist

- [x] Signed commit
- [x] No new flat-key settings
- [x] Clean-room implementation
- [x] Scope limited to PSK Reporter layout/status and shared map overlay
sizing
- No meter changes, new dependencies, protocol changes, or changelog
entry.

## Review clarification

The map renderers retain their existing optional on-map legend API. PSK
Reporter now leaves that API empty because its legend is in the footer;
the shared wrapping/stacking code is retained so an existing optional
legend, if populated by a future caller, follows the same narrow-map
sizing as attribution. No additional on-map legend is displayed by this
dialog. The operator approved this layout direction and explicitly
authorized review takeover and merge on 2026-09-08.
…nciple XI.

Restore HF300 decode discrimination by narrowing the correlator lowpass below the tone shift. Reset HDLC frame assembly after consumption so frames sharing one flag decode independently, and repair linkage for both offline AX.25 diagnostic tools.

Independent review reproduced 0 versus 7 frames on the supplied recording, verified the HDLC regression fails without the fix, and passed four focused tests at the PR head. The author also reports a successful live FLEX-8400/BPQ session.

Squashed-from: aethersdr#5494
Co-authored-by: Jay Hendershott <133457133+Chipensaw@users.noreply.github.com>
Wire FreeDV Reporter row double-clicks to the active slice through the shared tuning policy. Follow the existing auto-mode preference to activate RADE when available, with compile and backend guards.

Fixes aethersdr#4125.

The operator waived additional regression tests for this PR. Existing PR CI passed; no new local tests were run.

Squashed-from: aethersdr#5512
Co-authored-by: Ryan Butler <26440683+NF0T@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## Summary

Add an external `radiocert persist` supervisor that diagnoses settings
retention across band, mode, RX antenna and normal client restart
transitions. It records model and visible-control disagreements over
time, so later convergence cannot conceal an earlier failure. This is
the first Flex mutation plan for the broader cross-family persistence
design.

The runner uses a separate settings profile, stable GUIClientID across
real Quit/relaunch, exact radio discovery identity, ownership/TX
prerequisites, an atomic write-ahead journal and guarded restoration.
Forty applet contracts cover power setpoints, slice audio, manual SQL,
AGC, microphone/processor/monitor, AM/VOX-delay/expander, CW and both
complete radio EQ curves. Unsupported or unexercised controls remain
explicit gaps.

Two supporting repairs make those observations usable: explicit settings
profiles no longer import/move ordinary native preferences, and FFT
Average/FPS models follow successfully dispatched requests when Flex
acknowledges without echoing. Separate request flags and last radio
publications preserve provenance; subsequent radio status always wins,
with no persistence or timer replay.

The read-only bridge snapshot, runner instructions, cross-family issue
matrix, control inventory and live results are documented. An opt-in
two-slice runner now exercises distinct SQL/AGC/filter/audio state,
active-slice selection, SQL on/off isolation, normal process restart
with both slices present, and test-created slice removal/reopening with
guarded restoration. Both Persist entry points keep TX permission
disabled. The separate explicitly authorized TX harness now checks exact
radio/frequency/mode/antenna/offsets, uses unsmoothed forward power for
the physical-watt cutoff, reads the correct bridge liveness field, and
requires explicit false TX flags when confirming unkey. The SQL defect
discovered by this run is tracked in aethersdr#5501 and its repair in aethersdr#5508; this
PR does not change squelch behavior.

- [Research and
design](https://github.com/jensenpat/AetherSDR/blob/93bbff39cf59605bde00405a0bb4a935ba4aae38/docs/research/radiocert-persist-research-2026-09-07.md)
- [Expanded run and 40-control
results](https://github.com/jensenpat/AetherSDR/blob/93bbff39cf59605bde00405a0bb4a935ba4aae38/docs/research/persist-flex8400m-expanded-run-2026-09-07.md)

## Constitution principle honored

Principle VIII — evidence distinguishes dispatched intent, radio
publication, model and presentation. Principles II/III — radio state
remains authoritative. Principle VI — receive-only operation excludes
keying and VOX enable. Principle XIV — action intent is journaled
atomically before dispatch.

FFT request-update behavior was checked against the published FlexLib
4.2.18 `Panadapter.Average` and `FPS` setters; no proprietary binary was
inspected.

## Test plan

- [x] Local macOS app build passed with `cmake --build build -j22`.
- [x] 25/25 selected headless CTests passed: 20 AppSettings safety
scenarios, panadapter model, display status gate, MCP/probe field
mappings and Persist policy (30 internal Python cases). No new
socket-owning CTest or fake firmware peer.
- [x] FFT regression mutation check: omitting request-state updates
failed the expected 17/15 versus old 50/25 assertions; production
source/binary remained intact.
- [x] Bridge documentation generation, test registration, engine
boundary and whitespace checks passed.
- [x] Live FLEX-8400M firmware 4.2.18.41174, one client/slice/pan,
separate profile, 20m/40m and ANT1/ANT2 receive switching. All 40 applet
setpoints matched after normal restart; band revisits exposed SQL
radio=26 versus manual cache/slider=20. Context-aware final verification
matched all 40 original values. FFT revisit/restart/cleanup checked
radio-published values.
- [x] CI passed for the expanded Icom report head; the final
review-repair head requires its own fresh checks.

Local build/tests and hardware evidence used the source now committed
here, built from base `ab03be0629a498603ff02ef68a7dbfbd0de91d10` with
the implementation changes present. Only report portability edits
followed that run. App SHA-256:
`9e0b0b7447ee48a354e5a8cd97778550ff5ec2eb8b877327d6e536e6131ad8e3`.

The final experiment captured 901 snapshots, 275 action intents and 5418
wire-log events, with zero detected log gaps. TX permission stayed
disabled; no keying action was requested. This does not establish
physical RF behavior or a whole-radio pass/fail verdict. Raw artifacts
remain with the operator; publishable observations and ordered SQL
excerpts are in the linked report.

Remaining coverage includes Icom/other-family mutation plans, MultiFlex,
multiple panadapters, client DSP/CHAIN, audio-device lifecycle,
systematic automatic SQL and live validation of the new AGC
OFF/FM-boundary matrix, profiles and crash/power-cycle recovery. Two
owned slices on one pan are covered by the follow-up run below; this is
not general multislice/MultiFlex certification. Process supervision
currently supports macOS/Linux. Cleanup covers seeded fields; contextual
band-stack/frequency/span side effects are journaled for review rather
than claimed restored.

## Follow-up Flex testing — 2026-09-08

The tooling through commit `775b3876` was exercised against integration
build `9a8ea1b59eea5226dd6e1788608c5f9a22abc650`, combining original
aethersdr#5500 head `93bbff39` with the separate aethersdr#5508 squelch fix `05c68846`.
Executable SHA-256:
`a785be3ed2f4904c0ac9528334024ee61ec1075bd8ddcae4166bc40b8c2dfa43`. The
squelch implementation is not part of this PR; these combined-build
results must not be attributed to standalone aethersdr#5500.

- **Two-slice Persist:** all 18 final diagnostic checkpoints established
on FLEX-8400M firmware 4.2.18.41174. Distinct manual SQL 26/39, AGC,
filter and audio settings stayed isolated across selection and mode
round trips. SQL off/on on B left A unchanged. Both slices survived
normal Quit/relaunch with model and actual RX controls agreeing. Only
test-created B was removed/reopened. Final original slice/pan/TX/EQ
comparisons were empty, and both processes exited normally.
- **TX/control sweep, separately authorized:** ANT2 dummy load, 14.200
MHz, USB/LSB/AM/FM/CW/DIGU, maximum 10 W. All six modes produced TUNE
output; USB/LSB/DIGU two-tone and FM PTT also produced observed RF. Peak
radio-reported unsmoothed forward power was 7.326 W. All 24 recorded
windows, including the first instrumentation-stopped attempt, confirmed
unkeyed and zero post-TX visible power. Independent radio subscription
confirmed ANT2 before/during 15 later bursts; this is protocol/state
evidence, not an external measurement of jack/relay routing. Twenty-five
TX/EQ controls had model/widget and restoration observations;
external-change proof was RF power only, before and after restart. AM/CW
control restart and physical microphone/DSP effects remain untested.
- **AM PTT limitation:** about 0.184 W, below the harness's 0.3 W
positive-carrier reporting threshold; retained as inconclusive rather
than passed. A pre-key scratch-script error, repaired liveness-field
mismatch, operator-requested pause and the AM stop remain documented.
The first multislice run also retained a pan cleanup discrepancy; the
final full run exercised the corrected guarded cleanup.
- **AGC finding isolated:** a non-FM round trip retained
threshold/off-level 43/17. USB→FM→USB changed both 43/17 and 65/10 to
50/50 in direct radio status while the owned RX-only app was suspended.
After resume, model and slider matched; an explicit fresh `sub slice
all` two seconds later still returned 50/50. This rules out stale UI or
missing subscription as the explanation in this reproduction. Whether
the reset is intentional firmware behavior or a Flex persistence defect
remains unconfirmed. No client AGC replay workaround was added. The TX
sweep's receive AGC side effect was explicitly restored, and full
baseline comparisons matched afterward.
- **Local verification:** the four selected headless CTests passed on
the combined worktree (Persist policy, TX safety and the two squelch
tests). In the isolated PR-update tree, 39 Python Persist policy cases
and all 12 TX safety checks passed. Ownership and unsmoothed-power
regressions were mutation-checked in temporary copies. Bridge-doc
generation, test registration, engine-boundary and whitespace checks
passed. No new native build was required for this Python/docs extension;
native application behavior is unchanged in this update.

The reports in this PR contain the
control/protocol/reply/restart/restoration matrix and the AGC
reproduction. Raw journals and exact runner-source snapshots for
`validation-multislice-20260908` remain with the operator; identifying
radio data is excluded. These Flex reports do not claim full-radio
certification or physical audio-gating proof. Icom observations are
recorded separately below.

- [Two-slice and TX/control
results](https://github.com/jensenpat/AetherSDR/blob/775b3876fa909e89e552fd82ab356378d4d88a32/docs/research/persist-flex-multislice-tx-2026-09-08.md)
- [AGC FM-boundary isolation and fresh-subscription
proof](https://github.com/jensenpat/AetherSDR/blob/775b3876fa909e89e552fd82ab356378d4d88a32/docs/research/persist-flex-agc-fm-boundary-2026-09-08.md)

## FM / AGC off-level extension and Icom handoff

The two-slice runner now tracks distinct AGC threshold/off-level pairs
43/17 and 61/29, checks the mode-dependent RX slider with AGC Off and
enabled before/after restart, and exercises each slice through FM and
back. Retention concerns are recorded before any explicit cleanup; only
stable AGC/filter changes on the exercised slice are accepted for
guarded restoration. Peer changes, missing/mistyped fields, unrelated
drift and unstable state interrupt the run. This changes diagnostic
tooling only; no production AGC replay was added.

- 45 socket-free Persist policy cases and all 12 TX safety checks passed
on this extension. Deliberately weakening the FM cleanup guard failed
its test. Bridge-doc generation, test registration and whitespace checks
passed.
- **The expanded matrix has not yet run on hardware.** The earlier
18-checkpoint result covers the previous runner. No new native app build
was performed for this Python/docs update.
- The [Flex-to-IC-7300MK2
handoff](https://github.com/jensenpat/AetherSDR/blob/06021fbbedf94764df8d7a7bce5583fad60bd07e/docs/research/persist-flex-to-icom-handoff-2026-09-08.md)
captures all prior findings, the intermittent Icom Auto SQL/reconnect
reproduction plan, family capability boundaries, and a per-field
freshness measurement proposal. Existing CI-V queue/response/generation
diagnostics should be joined to confirmed model/widget state before
optimizing polling priorities. The proposed per-setting telemetry and
scheduler tuning are not implemented here. The first Icom round
subsequently ran under its own explicit authorization; see the
observations below.

## First IC-7300MK2 round — 2026-09-08

[Full Icom report: controls, freshness, reconnect failures, TX limits
and
restoration](https://github.com/jensenpat/AetherSDR/blob/06021fbbedf94764df8d7a7bce5583fad60bd07e/docs/research/persist-icom7300mk2-first-run-2026-09-08.md)

This used the existing combined integration app `9a8ea1b5` and
executable hash listed above, with dedicated local Icom lab scripts. No
native rebuild or production Icom/scheduler change was made. Public Flex
mutation runners still reject Icom; this report is not a generalized
Icom runner or standalone-PR certification.

- **Controls and retention:** ten actual SQL/AGC/AF/microphone widget
actions obtained matching CI-V value replies and model/widget
convergence. Initial write confirmations were approximately 70–98 ms;
optimistic presentation often preceded them. Corrected exact-model
reconnect trials retained Off, Manual 26 and explicit Auto. A normal
process restart adopted seeded Manual 39 at 2.548 seconds and AGC Fast
at 3.077 seconds; default SQL 20/AGC Med appeared before fresh adoption.
Unexpected Auto activation was not reproduced. Physical front-panel
convergence was not tested in that first round; the expanded round below
subsequently exercised inbound adoption through safe raw writes.
- **Freshness:** a 30-second idle receive window counted 937 replies
with no timeouts or stale replies. After discarding a four-second
collector initialization interval, SQL/AGC/RF-control confirmation ages
reached 3.24–3.33 seconds, versus 177 ms for the S-meter. These are
client confirmation ages, not exact physical sample ages. The original
transaction deduplication was insufficient because periodic reads reuse
request generations; its latency percentiles are not a complete
distribution. The report retains that correction and proposes stable
event IDs plus per-field/context confirmation timestamps before
scheduler optimization.
- **Readiness failures:** several authenticated connections failed CI-V
identity, including directed B6. Early apparent persistence outcomes
under Unknown Icom are inconclusive. Exact model and fresh seed gates
were added to the local experiment before valid reconnect trials. A
later reconnect succeeded; no retry-delay fix is claimed.
- **Separately authorized TX:** ANT1 dummy load, 7.200 MHz, all modes,
maximum 10 W. Nine windows produced fresh reported RF; highest
unsmoothed calibrated peak was 7.692 W, with SWR 1.0. Two zero-output
AM/DIGU attempts stopped for missing valid SWR, and CW text stopped for
missing fresh forward-power telemetry. All twelve windows confirmed
unkey and zero actual post-TX gauge; final USB also sampled the gauge
while keyed. CW reverse received mode/readback coverage only. First DIGU
TUNE remained inconclusive despite later two-tone success; DATA and
non-DATA inputs already read LAN. Physical RF-jack routing,
microphone/DAX/VOX/ATU and DSP effects are not certified.
- **Cleanup:** final slice/pan/EQ comparisons matched baseline. Original
7.224540 MHz LSB, Manual SQL 14, AGC Med and power setpoints were
restored; RF 100% and Tune 10% restoration occurred with verified TX
permission disabled. The tuner remains bypassed; no ATU tuning cycle was
run. Final app quit normally and released the lock. Harness preflight
failures and the RX-only raw-write refusal remain documented.
- **Current local checks:** 45 Persist policy cases, all 12 TX safety
checks, bridge-doc generation and whitespace validation passed for this
report update. Native tests/build were not rerun for documentation-only
changes. Raw private radio/session artifacts remain with the operator
and are excluded from the PR.

## Expanded IC-7300MK2 Persist and combined findings — 2026-09-08

[Expanded matrix, combined backlog and restoration
limitation](https://github.com/jensenpat/AetherSDR/blob/7e6c4805481c73ae814210c83c15edd4c8721e8e/docs/research/persist-icom7300mk2-expanded-run-2026-09-08.md)

The same combined integration app completed ten mode routes, 30 factory
filter-preset recalls, five band checkpoints, fourteen primary control
setpoints plus additional DSP/RIT/XIT/CW/VOX setpoints, four normal
process retention restarts, and six same-process Off/Manual/Auto
reconnect trials. Custom filter width/PBT, unselected VFO state,
SSB/DATA transmit passbands and FM CTCSS obtained independent CI-V
readback and retained through the specified transitions. Raw
SQL/AGC/AF/microphone/RF-gain writes also reached matching model/widgets
before and after restart; this is not physical front-panel timing
evidence.

- **Four new restart concerns:** RX-ANT remains selected in radio
replies while the app shows ANT1, and waterfall rate 63 returns to 100;
both occurred at all four process retention restarts. Separately,
Off→restart→Manual used default 20 instead of remembered 26, and
explicit Auto→restart returned as Manual. All six same-process reconnect
trials retained their requested SQL intent. Unexpected Auto activation
remains unreproduced.
- **Additional gaps preserved:** mode changes carry the current filter
slot; factory-preset recall is not slot-only selection. Scope Zoom in
did not establish a narrower span. VFO exchange returned `FA`, while
direct unselected-VFO reads/writes retained. NR and custom-filter values
required radio normalization. The report combines these with first-round
identity/freshness failures and unresolved CW/AM/DIGU TX paths, plus the
prior Flex FM/AGC and squelch findings.
- **One final guarded TX window:** USB two-tone on ANT1 at 7.200 MHz,
maximum 10 W authorization, RF 3%/Tune 2%, native watchdog and
calibrated-meter cutoff. Six positive samples, peak 2.098 W, SWR 1.0,
successful unkey and zero immediate/later actual gauge. This is
protocol/radio-meter evidence, not independent physical routing or
wattmeter proof.
- **Restoration exception:** the 30 factory preset recalls changed
hidden width/PBT definitions whose pre-recall originals were not all
captured. The known active LSB FIL1 and post-recall USB FIL3 baseline
were restored; full hidden filter-bank restoration cannot be claimed.
Final captured slice/pan/TX/EQ/display comparisons were otherwise empty,
raw unselected-VFO/TX-register restoration was verified, original RF
100%/Tune 10% were restored with TX permission disabled, then the app
quit normally and released the lock. Tuner remains bypassed.
- **Validation:** 45 Python Persist policy cases, all 12 TX safety
checks, bridge-doc generation and whitespace checks passed in the
publication worktree. No new native build/tests or production
Icom/scheduler change. Both public Persist mutation runners remain
Flex-only; this is explicit local Icom lab evidence, not a generalized
Icom runner.

## Checklist

- [x] Commits signed. Mergeability against newer upstream changes
remains a separate review gate.
- [x] No new flat AppSettings feature keys; existing AutoConnect key is
initialized only in the dedicated test profile before first launch.
- [x] Clean-room code; no meter display changes.
- [x] Automation and research documentation updated; CHANGELOG
unchanged.

Generated with OpenAI Codex (GPT-6 Astra)


## Review repairs

The applet diagnostic now combines model and widget evidence before
assigning a
scenario or per-control verdict. Widget disagreements are sampled over
the whole
observation window and survive later convergence. A failed seed
presentation
makes subsequent retention inconclusive; hidden, disabled, missing,
ambiguous
and unselected controls remain explicit presentation gaps. Markdown uses
the
same combined outcomes as the JSON journal.

The aetherd panadapter resource now includes Average/FPS request flags
and last
radio publications, including same-value requests and confirmations. The
v1
catalogue documents those fields. Settings regression coverage now
exercises an
explicit profile outside Qt test mode using only a private INI fixture,
plus the
legacy XML relocation guard. Native preference fixtures are deliberately
avoided
because CFPreferences can ignore HOME. Windows/native-path cases that
cannot be
confined to a private temporary directory skip explicitly with exit 77.

The no-echo change remains limited to Average/FPS; weighted averaging
and
waterfall-rate echo behavior are not newly claimed. The settings
isolation repair
is a prerequisite for safely launching the supervisor's dedicated
profile. The
TX harness remains a separate, explicitly authorized entry point;
Persist never
runs it. RFC aethersdr#5342's remaining universal-framework scope stays open.

Validation of the review repairs: 51 socket-free Persist policy cases
and all 12
TX safety checks; 26 focused ARM64 CTests (settings safety, panadapter
model,
control resource service and Python policies); bridge-doc generation,
test
registration, frozen CI gate, engine boundary and whitespace checks.
Mutation
checks failed as expected when widget evidence or seed history was
discarded,
either settings isolation guard was removed, or same-value FFT
provenance
notifications were omitted. No live radio, keying or new hardware claims
were
used for this repair. Native verification builds the focused targets,
not the
whole desktop application.

---------

Co-authored-by: Robbie Foust <rfoust@gmail.com>
…inciple V. (aethersdr#5379)

Fixes aethersdr#5353. Dismissing the RTTY pane now persists the operator enable
flag in the existing RttyDecoder object; mode and frequency changes
respect it. Radio Setup → Phone & CW → RTTY Decode re-enables the pane.
Sensitivity remains in the same feature-owned object and is preserved by
enable changes (Principles V and XIV).

Two small review repairs are included. The new settings label uses
ThemeManager, clearing the hardcoded-color ratchet. Native testing then
demonstrated that wirePanadapter() removed the original
close-to-MainWindow connection after decoder routing had installed it.
The dismissal handler is now connected immediately after that bulk
disconnect, so it survives startup and pan rewiring.

Scope: the nine original GUI/test/generated-doc paths plus
MainWindow_Wiring.cpp for the demonstrated signal fix. No engine, radio
protocol, transmit, workflow, dependency or governance changes.

Validation: the settings CTest passes13 assertions; forcing enabled=true
fails4 and dropping sibling settings fails1. Static registration, frozen
gate, engine boundary, color and generated manifest checks pass. Full
macOS arm64 Release app builds and its development signature verifies
(ASR/RADE/DFNR off). Native before/after verification and final CI
results are recorded in the follow-up review. The final incremental
build compiles repair746cd8cf; embedded configure-time version metadata
remains0415206d, so that label alone must not be mistaken for its source
revision. Native private-profile testing uses the built-in RX-only demo
simulator, with TX disabled; no live radio/RF proof is claimed.

Native after-proof on the signed repair: dismiss→USB/RTTY
switch→simulator frequency change stayed hidden and persisted
enabled=False; Radio Setup re-enable reopened it. Dismissal survived a
fresh process restart in the same private profile. The earlier
implementation failed this same sequence. Demo is single-slice; no
multi-slice live-radio assertion is made.

Final CI: all seven checks passed on746cd8cf, including
macOS/Windows/Linux builds.

---------

Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ozy311 <Ozy311@users.noreply.github.com>
Co-authored-by: Robbie Foust <rfoust@gmail.com>
…ethersdr#5514)

Normal app restarts lost three client-owned Icom preferences and
displayed a stale RX antenna selection. This fixes the four
discrepancies found during RadioCert Persist aethersdr#5500 in a separate,
main-based PR.

| Finding | Result |
|---|---|
| Radio retained RX-ANT but the app showed ANT1 | IC-7300MK2 bare `12`
readback now updates both antenna controls at startup, periodically, and
after writes. The scheduler expects the reply's `00` subcommand and
retires the transaction promptly. |
| Manual 26 → Off → restart → Manual returned 20 | Remember the previous
manual choice in a scoped client document; a later operator click
returns to 26. Fresh enabled radio SQL remains authoritative. |
| Explicit Auto became Manual after restart | Remember client Auto
intent and resume only after fresh enabled SQL readback. A radio
reporting Off wins and cancels old Auto intent. |
| Waterfall speed 63 reset to 100 | Persist explicit local display-rate
changes by radio/pan slot and restore before seeding the local shaper. |

Waterfall speed was already mapped to AetherSDR's local display timing;
this adds persistence, not a native CI-V waterfall-speed command. The
two SQL repairs use the shared Icom path, and the waterfall repair uses
local-rate shaping, so they also apply to IC-705/IC-9700 in code. RX-ANT
readback is MK2-profile-only; injected tests verify that IC-705/IC-9700
do not acquire those reads or publications. Neither model was
hardware-tested in this run.

The documents use checked atomic writes, exact radio scopes,
schema/range validation and future-schema protection. No radio-owned SQL
threshold is replayed on attachment, and Flex radio-owned cadence
remains outside this store. This does not claim to resolve the
unreproduced report of unexpected Auto activation.

## Review fixes and final validation

- Reject malformed or unsupported antenna replies before scheduler
completion and scrub bookkeeping. Empty, invalid-enum and oversized
first replies cannot make the default antenna value eligible for replay.
- Coalesce SQL and waterfall settings edits within 250 ms; capture the
original radio scope/value and flush on disconnect, reattachment, owner
teardown and normal quit.
- Reattach from current-session SQL readback without a transient Off
state. Construction defaults, previous-session state and unconfirmed
optimistic edits cannot resume Auto.
- Tested the combined PR/current-main tree (main `ac92489b`) with a
complete ARM64 desktop build, RADE enabled, and six focused registered
CTests. Strict boundary, registration, CI-gate and generated-manifest
checks passed.
- Separate mutation builds failed on all three repaired issues. Coverage
also verifies rapid SQL edits followed by disconnect, Auto reattachment,
stale/optimistic state rejection and future-schema protection after
flushing.
- This review did not repeat the contributor's hardware run below. Its
isolated offscreen desktop attempt could not initialize QRhi before the
automation bridge started; review-owned processes were stopped, and no
final GUI-render or hardware-convergence claim is made.

## Original contributor validation (before review fixes)

- Final macOS build and **10/10 selected headless CTests passed**.
Coverage includes protocol/scheduler/profile guards, real
RxApplet/SliceModel reconciliation and scoped display persistence. No
new radio peers or socket listeners enter CTest.
- Mutation checks failed with antenna adoption, SQL saves, and waterfall
saves disabled; both SQL restart cases were detected. The new
transaction test also failed before correcting the
bare-query/subcommand-reply matcher.
- **Live IC-7300MK2, RX-only:** 16 established observations across two
normal Quit/relaunch cycles, two same-process reconnects,
Off/Manual/Auto transitions and restoration. Actual antenna controls,
threshold and rate sliders agreed with the expected model/readback.
Recorded antenna transactions completed in 3–26 ms; all sampled
scheduler snapshots showed zero timeouts.
- Original slice/pan/transmit snapshots matched after restoration; fresh
ANT1 and Manual 14 replies confirmed cleanup. App quit normally, TX
remained disabled/unkeyed, and lab lock was released.
- Strict engine-boundary and test-registration checks plus `git diff
--check` passed. CI is separate; this is focused proof rather than a new
full Persist/TX sweep.

The focused proof report is included in
`docs/research/icom-restart-fixes-2026-09-08.md`. Detailed screenshots
and session observations are retained locally and are not attached
publicly.

Generated with OpenAI Codex (GPT-6 Astra)

---------

Co-authored-by: Robbie Foust <rfoust@gmail.com>
## Summary

Adds two independent PSK Reporter map controls: **Map brightness**
(20–100%, default 100%) and **Dark map** (off by default). Dark map
recolours the existing OpenStreetMap tiles into dark navy/grey
backgrounds and lighter printed labels. Brightness adjusts the resulting
base imagery without changing data-overlay colours. Start at 100%
brightness when comparing styles.

The sidebar/footer work from aethersdr#5493 is already merged. This PR combines
the brightness and dark-map controls on the integrated upstream base
(`0ac8721aa`), with the squash-merge conflict resolved.

- Flat view: brightness is a camera-bounded black compositing layer;
dark mode recolours display tiles through a small QGeoView tile-image
factory hook. The original images remain in the decoded/network caches.
Existing tiles can return to their originals without another fetch.
- Globe: the atlas/detail shader uses the same luminance ramp and
brightness multiplier. City lights, radar, paths, and markers render
afterward. When Dark map is enabled, night shading derives from its dark
palette in both app themes.
- Shared state initializes a lazily created globe and survives
projection changes. Settings stay in the existing `PskReporter` object.
Brightness participates in the sidebar wheel guard and tab order.
Attribution and the map-only footer remain outside the adjustments.

No new map provider, paid service, credentials, tile URL, refresh
policy, or dependency. This is raster colour remapping, not semantic
cartography: feature colours and printed label halos are also remapped,
so some map details lose their original distinctions. See
`docs/map/psk-basemap-brightness.md`.

The beacon status dot now uses an explicit stop outcome (completed,
cancelled, or interrupted), so changing translated status text cannot
change its colour. Existing colours and stop behaviour are preserved.

## Validation

- macOS ARM64 RelWithDebInfo build against the integrated upstream base;
RADE enabled, ARM64 host/system/executable verified, no RNNoise x86
sources.
- Five focused registered tests: `dark_basemap_test`,
`map_tile_reply_test`, `map_image_cache_test`,
`psk_reporter_map_behavior_test`, and `weather_radar_loading_test`.
New/modified tests use images or injected HTTP replies; no sockets or
synthetic radio peers.
- Mutation checks: bypassing tile styling fails the dark-map test;
bypassing the display factory fails the new cache/factory case.
- Isolated UI verification covers defaults, normal-quit/restart
persistence, minimum/wide layout, wheel guards, and light/dark app
themes. Native prototype checks additionally covered flat/globe detail
rendering, city lights, dark-map toggling, and the corrected light-theme
night shadow. The final integrated review uses the built-in RX-only
demo; no live-radio or transmit testing.
- Colour ratchet, theme seed, accessibility, test registration, engine
boundary, and whitespace checks pass. Existing engine-boundary warnings
remain.
- The status-outcome follow-up passed an ARM64 application rebuild and
the focused PSK Reporter behaviour test. The generated architecture
touchpoint inventory was refreshed after CI detected an outdated include
count.
- Linux/Windows runtime rendering has not been tested. CI on the updated
head is a separate check; this is not a full-suite claim.

## Review status

The operator tested and accepted the dark-map appearance and requested
that it be combined here. Ready for maintainer review. Visual/UX
acceptance remains a maintainer decision; operator testing does not
substitute for that approval.

Principle V: both preferences belong to the feature-owned settings
object. Principle X: current-main integration preserves the merged
sidebar and intervening changes. No CHANGELOG or radio/protocol changes.
aethersdr#5357)

Compensate measured ANAN-G2 DDC0 edge droop with an in-app RX calibration sweep. Keep calibration ownership, rate changes, bypass and apply inside AnanBackend; gate GUI and bridge controls on connected ANAN capability through IRadioBackend extensions.

Validate with four focused tests and a non-ANAN refusal regression, including a guard-removal mutation. Cross-radio staged-result persistence certification remains deferred by the operator.

Squashed-from: aethersdr#5357
Co-authored-by: tropo1234 <299180028+tropo1234@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: jensenpat <patjensen@gmail.com>
…rinciple IV.

Derive waterfall start units from the server-advertised zoom_max/zoom_cap for request encoding, quantization tolerance, and frame-header decoding. Preserve the default KiwiSDR 2^24 scale and add socket-free regression coverage for non-default server scales.

Implementation and original regression tests by Ingomar Otter, DL9ET (@kgbvax). Robbie Foust's follow-up is limited to review, validation, correcting a comment, pinning the protocol-source reference, and issue/PR documentation.

Fixes aethersdr#5536. Related to aethersdr#5528; Web-888 family support follows in aethersdr#5530.

Co-authored-by: Ingomar Otter <dl9et@n44.de>
Co-authored-by: Claude Code <noreply@anthropic.com>
Add Web-888 receiver family support on the existing KiwiSDR path, including persistent selection and reliable waterfall setup replay.

Original implementation and PR by Ingomar (@kgbvax). Review fixes and validation by Robbie Foust (@rfoust).

Closes aethersdr#5528.

Co-authored-by: Ingomar Otter <dl9et@n44.de>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Robbie Foust <rfoust@gmail.com>
…r#5458)

## Summary

Continues RFC aethersdr#3849 after merged aethersdr#5455 with opt-in, same-user **non-TX**
connection control for the headless daemon. This is one Stage 3
sub-slice, not completion of the RFC.

- Default local clients remain observers. `--allow-local-control` grants
every client admitted by the current-user endpoint observe + control; it
is not per-application consent or remote authentication. Discovery
remains independently opt-in.
- Add typed `radio.connect` / `radio.disconnect` for the daemon-owned
`radio-1`. Connect selects an existing catalogue ID at its exact current
revision; arbitrary endpoints, credentials, force flags and raw commands
are rejected. Busy sessions cannot be replaced.
- Keep native translation below the vendor seam. The injectable target
reserves connection/teardown state, publishes bounded lifecycle/error
observations, and cancels legacy reconnect on failure/disconnect.
`accepted:true` acknowledges intent, not RX readiness. Controllers share
disconnect authority; client loss does not undo accepted intent.
- Enforce the advertised per-session 100/s, burst-200 request budget on
every post-negotiation frame, including malformed/rejected requests.
Exhaustion is terminal. This applies to every negotiated client,
observers included, so it is a protocol-wide behavior change for any
pre-existing observe-only client above that rate.
- Integrate the maintainer's aethersdr#5455 catalogue changes unchanged. Claim
the endpoint before settings/model construction, load native settings
before model consumers, bind the target once before dispatch, and stop
delivery before teardown.

No receive setters, transmit grants/methods, remote listener, credential
provisioning, Icom manual setup, desktop migration, or default native
discovery/connection is introduced. The existing 30-second timer
requests cancellation; it does not bound synchronous backend teardown.

## Constitution principle honored

Principle VII — typed, bounded boundary input validation and
catalogue-only destination selection. Principle VI — no protocol
transmit authority or keying surface. Principles VIII/XI — source-backed
review and demonstrated regression checks. Principle XIV — preserve
settings initialization ordering and avoid touching settings on failed
listen.

## Test plan

- [x] ARM64 macOS app and daemon built using the dedicated local
toolchain, RelWithDebInfo and RADE enabled. Host/system processor and
final executables are arm64; no RNNoise x86 sources; daemon has no
QtWidgets dependency.
- [x] Seven focused registered CTests pass:
`control_protocol_codec_test`, `control_authorization_test`,
`control_resource_service_test`, `radio_catalogue_test`,
`control_connection_test`, `radio_discovery_source_test`,
`aetherd_discovery_startup_test`.
- [x] Mutations individually fail their assertions: control-grant guard,
catalogue revision, busy reservation, request charging, terminal rate
latch, startup ordering, and pre-dispatch target-binding latch. Fixed
tests return green. Mutations were confined to scratch copies.
- [x] Actual daemon, isolated settings/wisdom and endpoint, production
simulator only: default observer connect refusal; three
connect/slice-0/peer-observation/disconnect cycles; three immediate
queued cancellations; authoritative subscription delivery. Test daemons
stopped; existing desktop process untouched.
- [x] Strict test registration, frozen CI-test gate, engine-boundary
ratchets, generated touchpoint manifest and whitespace checks pass. No
frozen per-PR gate expansion.
- [x] Final source/security review covers all 20 changed files at
`37a7fdd30f8d5565f6f5c2961066621a86bcb82a` against merged catalogue/main
`9a90802a577eabba3bdb9127340a59e46272ac30`: no remaining actionable
findings, no deferred source-review work. Current-main `17d61de5`
prospective merge is conflict-free; its later changes are Windows Store
packaging/tests.
- [ ] PR CI results pending.
- [ ] Physical-radio convergence and Windows/Linux runtime verification
were not performed. No LAN/USB scan or RF/TX test was authorized or
attempted. Simulator evidence is not hardware proof.

Socket/process disclosure: the new protocol test is socket-free and
injects the target/source. The expanded startup test launches our real
daemon with an invalid logical endpoint name, which fails before socket
binding; no firmware stand-in or native discovery is used. Only the
manual daemon integration smoke opens isolated current-user endpoints
belonging to the server under test. Existing fresh-process settings
readback stays isolated.

Local build limitations: optional DFNR/RTL libraries were unavailable on
this Mac. Compiled source matches the reviewed commit; the desktop's
embedded SHA reflects the pre-commit configure. This does not claim a
full unfiltered suite or cross-platform runtime pass.

## Checklist

- [x] All branch commits have verified signatures.
- [x] No new flat-key AppSettings calls or persistence format.
- [x] Clean-room implementation; no proprietary binary derivation.
- [x] No meter/UI changes.
- [x] Protocol catalogue, Stage 3 documentation and AGENTS migration
status updated; no CHANGELOG entry.
- [x] No reportable vulnerability requiring a GHSA was found in the
final diff.

Tracking note: aethersdr#5455 automatically closed aethersdr#3849 when it merged. This PR
deliberately uses a non-closing reference; remaining RFC work is still
outstanding. Existing issue coordination with @ten9876 is preserved.

---------

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ethersdr#4181). Principle VIII. (aethersdr#5105)

Automation access tokens now use `QRandomGenerator::system()` for both
64-bit halves. Bridge enablement is persisted by MainWindow after the
asynchronous bind succeeds, so closing Radio Setup during the token read
cannot lose the operator's opt-in. Failed binds clear saved enablement;
environment-forced starts preserve the saved opt-in on either outcome.
Radio Setup reconciles the result without emitting another toggle,
including when reopened during a pending read, and repeated starts reuse
the pending request.

Addresses aethersdr#4181 items 2 and 3. Item 1 (`dump_tree` filtering on
`toolTip` and `accessibleDescription`) remains open under aethersdr#4181. Startup
failure without an open Radio Setup dialog is logged; this change does
not add a global notification.

Scope: five GUI files (`MainWindow.cpp/.h`, `MainWindow_Session.cpp`,
`RadioSetupDialog.cpp/.h`). No map/QGeoView files or committed tests are
included.

Validation at `dafc4339cf7e0952e245d8db28afae93d72ad320`:

- Socket-free scratch probe compiling production
callback/guard/result-handler code with injected token-read and bind
boundaries: 8 failed assertions before the repair, zero after. Covered
dialog destruction/reopen, failed binds, forced results, stale callbacks
after stop/restart, and repeated starts. This is local evidence, not a
committed regression test.
- Full macOS arm64 Release app build passed (Qt 6.11.1; ASR, RADE and
DFNR disabled; `-j3`); local development bundle signature verified. All
seven PR checks passed, including macOS, Windows and Linux builds.
- Engine-boundary, color-ratchet, accessibility and generated
touchpoint-manifest checks passed.

Real OS credential-store behavior and bind-failure GUI notification were
not exercised by the scratch probe; no radio or TX testing was
performed.

Native signed Mac app: private settings, authenticated real bridge
startup and Radio Setup open/close/reopen remained responsive. The
environment-forced run kept TX disabled and never connected a radio;
deferred token delivery and failed bind remain covered by the injected
harness, not a native keychain test.

---------

Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ozy311 <Ozy311@users.noreply.github.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…ethersdr#5130)

## Summary

Fixes aethersdr#5087

TCI has no client-identification message and the WebSocket handshake is
a bare
upgrade, so `TciServer: client connected from "::ffff:127.0.0.1"` was
all a
support bundle ever held about a misbehaving client. For the common
same-machine
case the OS knows which process owns that socket; this asks it.

What changed:

- New `src/core/TciPeerProcess.{h,cpp}` —
`resolveLoopbackPeerProcess(peerAddr,
peerPort)`: pure OS lookup, no Qt networking beyond `QHostAddress`.
Linux:
`/proc/net/tcp6` then `/proc/net/tcp` (a loopback IPv4 client on the
Any-bound
listener appears v4-mapped in `tcp6`) → socket inode → `/proc/*/fd` →
`comm` +
`exe`. macOS: `proc_listpids` → `PROC_PIDLISTFDS` →
`PROC_PIDFDSOCKETINFO`
matching the client's local port/address → `proc_name` + `proc_pidpath`.
Windows: `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` for AF_INET and
AF_INET6
→ `QueryFullProcessImageNameW`; version from the exe's authored
`StringFileInfo`
strings — ProductVersion first (it is where build metadata lives:
WSJT-X's reads
"3.0.1 c04dd8"), then FileVersion, trying the declared Translation pairs
plus the
standard Unicode blocks 040904b0/000004b0 (real exes ship Translation
entries that
don't match their actual block — measured on WSJT-X, Windows proof
below; .NET's
FileVersionInfo carries the same fallbacks); the numeric
`VS_FIXEDFILEINFO` quad
only as a last resort — its always-4-part shape is rewritten by the log
sanitizer's IPv4 rule, authored strings are not. macOS: the bundle's
`Info.plist`,
found by walking up from `…/Foo.app/Contents/MacOS/foo` — a file read,
never an
  execution of the client; reported as `CFBundleShortVersionString` plus
`CFBundleVersion` in the form "3.0.1 (123)" when the build number
differs. Linux:
an ELF binary embeds no version; for distro-installed clients the dpkg
database,
read as plain files (`/var/lib/dpkg/info/*.list` maps the exe path to
its package,
`/var/lib/dpkg/status` maps the package to its version, which carries
the
packaging build, e.g. "2.6.1+repack-2build1"), supplies it — never a
package
tool executed; home-built binaries match no `.list` and stay
version-less; rpm's
  database is not plain text, so non-dpkg distros remain a follow-up.
  Non-loopback peers and every failure return unresolved.
- `26d7ebfb` (found by the Linux leg of the per-platform proof below):
the Linux
fd sweep now compares `/proc/<pid>/fd` link targets by raw
`readlink(2)`.
`QFile::symLinkTarget()` absolutizes a relative-looking target against
the
  link's directory, so the raw `socket:[N]` came back as
`/proc/<pid>/fd/socket:[N]` and the comparison never matched — the Linux
resolver resolved nothing, ever. macOS/Windows use native APIs,
untouched.
- `TciServer::onNewConnection()` logs today's line unchanged, then
  `resolvePeerProcess()` runs the lookup on `QtConcurrent` with a
`QFutureWatcher` — the descriptor sweep is unbounded and must not delay
  `sendInitBurst()`. When it lands, a second line:
  `TciServer: client ::ffff:127.0.0.1:51234 process="wsjtx"`
(+ `version="…"` when known), and `clientsChanged()` fires. The
executable path
is never logged; it is kept in memory for the Network Diagnostics
tooltip only
  (maintainer ruling, 2026-09-07). A non-loopback peer
gets one `qCDebug` saying identity is unavailable, so the absent field
is
self-explaining in a bundle. Identity is kept on
`ClientState`/`TciClientInfo`.
- Network Diagnostics → TCI clients table: the endpoint cell reads
`127.0.0.1:51234 (wsjtx)` with exe path (+ version) as its tooltip. No
new
  column; nothing changes when unresolved.
- No unit test. The lookup's positive claim (a loopback socket resolves
to its
owning process) needs a real socket, and the self-connected `QTcpServer`
test
that carried it was removed in `9476eb04`: AGENTS.md routes positive
convergence
to the automation bridge, and the three-platform bundles below prove it
with
real clients. The negative guard (remote peer, port 0 never resolve) is
a
one-line check documented at the top of `resolveLoopbackPeerProcess()`.
- CMake: new `.cpp` in the aethercore list; Windows links `iphlpapi
version`.

## Limitations

**What is collected.** Only the one process that opened a TCP connection
to the TCI port, and only three fields: its short name, its executable
path, and a best-effort version. The log line carries the name and
version; the path appears only in the Network Diagnostics tooltip,
in-app. The version comes from file reads scoped to that one executable
(`Info.plist`, the dpkg status file, the exe's resource strings) —
nothing is executed. Remote peers get nothing. On Linux and macOS the
lookup briefly reads other same-user processes' descriptor lists to find
the match (what `lsof -i` does); nothing from that walk is kept. The log
is local and leaves the machine only if the user shares a bundle.
Privacy text, as the maintainer proposed: *"When a TCI client connects
from this computer, AetherSDR records the connecting program's name and
version in its local log."*

Packaged clients now report a version — with build metadata where the
packager
authored it — on all three platforms. The version is read from what the
OS or
package manager records about the binary; the client is never executed.
The
honest remainder:

- **A home-built client reports name + exe but no version, on every
platform.**
A bare binary's version usually exists only as compiled-in strings
(fldigi's
Help→Build info stamp, for example), which no metadata query can see —
so for
self-built clients the aethersdr#5087 support question ("which version is the
user
  running?") still comes back empty.
- macOS reports the bundle's `Info.plist` keys; a build hash appears
only if the
packager put one there (WSJT-X's mac plist carries `v3.0.2`/`3.0.2`, not
the
  `ccdfaf` its own About shows).
- Linux covers dpkg-owned binaries; rpm-based distros are a stated
follow-up.
- Same-user loopback clients only, by design (above).

## Constitution principle honored

Principle VIII — Evidence Over Assertion: the bundle now records which
program
was connected instead of the operator reconstructing it from memory.

## Test plan

- [x] Local build passes — clean configure + full build, macOS (Intel,
Qt 6.8.3): 2952/2952 clean, then reconfigure+rebuild at the commit
- [x] Behavior verified on a real radio if applicable — **n/a**, no
radio involvement
(the TCI server listens without one); verified with a live local client
via
      the agent automation bridge, below
- [x] Existing tests pass (CI) — CI compiles all three backends (`build`
/
`check-macos` / `check-windows`). This PR registers no test (see "What
      changed"); the proof is the bridge bundles below
- [x] Reproduction steps documented — n/a (enhancement)

### Proof — `b3d25350` on three platforms (agent automation bridge)

The quoted lines below were read at `b3d25350`; as of `ad427370` the log
line has no `exe=` field (the tooltip still carries the path).

Each platform leg: clean configure + full build at `b3d25350`,
Help→About SHA
verified (screenshots in the zips), real local clients over
`ws://…:50001`,
radio (where connected) RX only — nothing keyed. Readings verbatim from
the
attached complete unedited logs:

| Platform | Client | Reading |
|---|---|---|
| Windows 11 | WSJT-X 3.0.1 (installed exe) | `TciServer: client
::1:61576 process="wsjtx" exe="C:\WSJT\wsjtx\bin\wsjtx.exe"
version="3.0.1 c04dd8"` — authored ProductVersion: version AND build
hash, unmasked, 1 ms after connect |
| Linux | wsjtx 3.0.1 (dpkg-owned) | `TciServer: client
::ffff:127.0.0.1:59908 process="wsjtx" exe="/usr/bin/wsjtx"
version="3.0.1"` — the dpkg-recorded version (independently confirmed on
the box: `dpkg-query -W wsjtx` → `3.0.1`), 116 ms |
| Linux | fldigi (home-built) | `process="fldigi"
exe="…/fldigi-tci/src/fldigi"` — no `version=` clause (owned by no
package — the designed version-less path), 159 ms |
| macOS | WSJT-X 3.0.2 (`Info.plist` keys differ: `v3.0.2` vs `3.0.2`) |
`TciServer: client ::1:50159 process="wsjtx"
exe="/Applications/wsjtx.app/Contents/MacOS/wsjtx" version="v3.0.2
(3.0.2)"` — both keys, `short (build)` form, 11 ms |
| macOS | Python.app 3.14.4 (bare WebSocket upgrade; plist keys equal) |
`process="Python" exe="…/Python.app/Contents/MacOS/Python"
version="3.14.4"` — equal keys de-duplicate to the short version alone |
| macOS | fldigi (home-built, no bundle) | `process="fldigi"
exe="…/fldigi-tci/src/fldigi"` — no `version=` clause: a bare binary
carries no OS-queryable version metadata (its git stamp is a compiled-in
string, visible only in its own Help→Build info) |

Network
Diagnostics → TCI Clients shows the endpoint cell as `127.0.0.1:<port>
(wsjtx)`
with exe path (+ version) in the tooltip:

![Network Diagnostics — TCI Clients, endpoint cell with the resolved
process](https://raw.githubusercontent.com/skerker/AetherSDR/feat-5087-assets/.pr-assets/aethersdr-5087-netdiag-tci-clients-2026-08-21.png)

How the per-platform legs got here (before-readings live in the attached
zips):

- **Linux** (`26d7ebfb`): at `cfb74fa2` no Linux client ever resolved —
the
`QFile::symLinkTarget()` defect under "What changed"; the unit test
failed
  6 checks on the box.
- **Windows** (`1897df2f`, `b3d25350`): the numeric-quad-only read
rendered every
Windows version sanitizer-masked (`version="*.*.*. 0"`), and WSJT-X
declares a
`\VarFileInfo\Translation` block it doesn't use — both measured with the
version-resource probe in the Windows zip, both described under "What
changed".
- **macOS**: bundle metadata carries no git hash (WSJT-X's own About
shows
`v3.0.2 ccdfaf`; its plist carries `v3.0.2`/`3.0.2`) — stated under
Limitations.

Evidence zips (complete unedited logs, About screenshots, unit-test
outputs,
probe/client scripts):


[aethersdr-pr5130-linux-version-2026-08-24.zip](https://github.com/user-attachments/files/31382260/aethersdr-pr5130-linux-version-2026-08-24.zip)


[aethersdr-pr5130-windows-d2-2026-08-24.zip](https://github.com/user-attachments/files/31368161/aethersdr-pr5130-windows-d2-2026-08-24.zip)


[aethersdr-pr5130-mac-version-2026-08-24.zip](https://github.com/user-attachments/files/31392447/aethersdr-pr5130-mac-version-2026-08-24.zip)


[aethersdr-pr5130-ruling-revision-2026-09-08.zip](https://github.com/user-attachments/files/31941139/aethersdr-pr5130-ruling-revision-2026-09-08.zip)
— head `d1e69c7e` (About SHA, Python client log line without `exe=`, TCI
Clients cell, `tci_peer_process_test` 12/12, quit-with-dialog-open
trace), sha256
`c8fdb2a4b6b76f2d1539c28683a62aef7d7db91aaf1200efaabf757993b8da4b`

**Review round (2026-09-08).** `ad427370` name + version only in the
log; `d1e69c7e` macOS sweep limited to this user's pids. Maintainer
ruling 2026-09-07. `74a92bf7` then `9476eb04`: the socket-owning unit
test first met the AGENTS.md disclosure/exit-77 obligations, then was
removed — the bridge bundles are the proof.

## Checklist
- [x] Commits are signed
- [x] No new flat-key `AppSettings` calls — **n/a**, no settings
- [x] Code is clean-room
- [x] All meter UI uses `MeterSmoother` — **n/a**
- [x] Documentation updated if user-visible behavior changed — **n/a**,
diagnostic
      log line + one table cell; described above
- [x] Security-sensitive changes reference a GHSA — **n/a**. Logged data
is the
name/path of a program that connected to us on the operator's own
machine
(what `ss -tp`/`lsof` show any local user); same-user processes only.

— authored by agent (Claude Code) on behalf of @skerker

**Review round (2026-09-10), `7ff41b5e`.** Every finding and nit from
the second review pass, plus a merge of `origin/main`:

- `findSocketInode()` skips zero-inode (TIME_WAIT/orphaned) rows instead
of returning them — measured 6/6 misses before, 6/6 resolves after, with
a client that reused its source port; same guard on both Windows tables
(`dwOwningPid == 0`).
- `redactPii()` now exempts the literal prefix `version="` alongside
`ver=`, so a 4-part authored version (`2.2.159.0`) reaches the bundle
intact; new case in `async_log_writer_test`, and the field is listed in
`docs/log-redaction.md` under *Deliberately NOT redacted*.
- The identity line spells the peer the way the Network Diagnostics
table does (`::1` / `::ffff:127.0.0.1` → `127.0.0.1`) and escapes
quotes, backslashes and control characters in the client-chosen
name/version (a `comm` of `x"\nINF forged` logs as one field on one
line).
- Linux checks `st_uid` of `/proc/<pid>` against `getuid()` before
reading a descriptor table — "same-user only" is now enforced on Linux
and macOS; the header states that Windows is bound only by
`OpenProcess()` rights.
- dpkg lookups are cached per executable path + mtime.
- `/proc/net` hex words are read back with `qFromBigEndian`/`memcpy`
(endian-correct); the tcp/tcp6 comment names the right socket.
- With main merged, the finished lambda uses `clientStateFor()` and
`disconnectSnapshot()` carries `processName`/`processVersion`.

The log line above therefore reads `TciServer: client 127.0.0.1:51234
process="wsjtx" version="3.0.1 c04dd8"` on all three platforms. Verified
on Linux against the built-in demo (v4, `::1`, in-process client,
hostile name, TIME_WAIT reuse, quit with lookups in flight).

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
## What it does

Adds `sweep-closed-prs` to `cache-cleanup.yml`: a daily (`17 4 * * *`)
plus on-demand job that deletes caches on `refs/pull/N/merge` once the
PR is no longer open.

Those caches live on a ref only that PR can read. When it closes they
become permanently unreachable but keep holding budget for up to the
full 7-day retention, which is not configurable.

## Why scheduled, not `pull_request: closed`

The obvious trigger cannot work, which is why aethersdr#5008 pulled the original
version rather than shipping it: for a `pull_request` event whose head
is a fork, GitHub issues a **read-only** `GITHUB_TOKEN`, and a
`permissions:` block can only lower that ceiling, never raise it. `gh
cache delete` returns 403 and the job fails. **44 of the last 60 closed
PRs here are from forks** (measured 2026-09-10).

`pull_request_target` would get a writable token and is the
documented-safe shape for a job that checks nothing out. It's
deliberately not used: a schedule needs no privileged trigger at all, is
idempotent, and **self-heals** — it picks up PRs closed while the
workflow was broken, renamed, or disabled, which an event-driven job
misses forever.

The cost is latency, and it is not quite free: while the repo is over
its allowance, dead entries compete with main's live ones for LRU until
the sweep runs. If that bites, the job is idempotent and cheap enough to
also hang off the existing `workflow_run` trigger, with the schedule
kept as the backstop. Not done here; see follow-ups.

## Measured premise (2026-09-10, 25 days after aethersdr#5008)

The original draft held this PR until it could be shown that anything
still accumulates on PR refs after aethersdr#5008's save-on-main split. It does:

```
PR-ref caches: 20 entries, 6881 MiB
repo usage:    23 entries, 9.88 GiB of 10 GiB

aethersdr#5458 MERGED 1720 MiB   ← unreachable, swept by this job
aethersdr#5462 OPEN    396 MiB
aethersdr#5539 OPEN   1323 MiB
aethersdr#5542 OPEN   1720 MiB
aethersdr#5547 OPEN   1720 MiB
```

That is past the "> 2 GiB, or any single Qt-sized entry → merge"
threshold the draft set for itself. The "PRs should restore rather than
write" hypothesis did not hold: each open PR carries its own copy of the
same 1,297 MiB Qt key main holds, because at the allowance LRU evicts
main's copy between runs and the next PR re-saves it. This job reclaims
the closed-PR leg of that loop; the loop itself is a ci.yml matter
(follow-ups).

## Failure behaviour

| case | behaviour |
|---|---|
| PR state unresolvable | Skipped and retried next run — a transient API
error must not become data loss. Counted, and surfaced as a
`::warning::` with gh's stderr so a persistent cause (missing scope,
rate limit) cannot hide |
| `--limit 500` truncation | Emits `::warning::`, because silent
truncation looks exactly like "nothing to clean" |
| Individual delete fails / LRU took it first | Deleted by id like
prune-main; a 404 is logged, does not abort the loop, and does not count
toward `freed` |
| Dispatch overlaps the cron | `concurrency` group queues the second run
|

## Cannot be exercised on this PR

`schedule` and `workflow_dispatch` only fire from the **default
branch**. First real run is after merge: trigger it via
`workflow_dispatch` immediately rather than waiting for 04:17, and read
the log before trusting it. That first run is also what confirms the
`pull-requests: read` grant is sufficient for `gh pr view` under
`GITHUB_TOKEN`.

## Not addressed here

- Caches on **tag refs** (`refs/tags/v*`) from the release workflows are
swept by neither job. Low volume; noted so it isn't silently forgotten.
- The open-PR duplication loop above. The durable fix is giving the
dependency caches (Qt, FFTW3, DeepFilterNet3, qtkeychain) the same
restore-on-PR / save-on-main split aethersdr#5008 gave the compiler caches;
`install-qt-action`'s built-in cache has no restore-only mode and needs
its own decision.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ethersdr#5548) (aethersdr#5551)

## What

Two one-line settings changes plus the comments that justify them, for
aethersdr#5548 items **4**, **5a** and **5b**:

| file | change |
|---|---|
| `cache-cleanup.yml` | `KEEP: "2"` → `"1"` in `prune-main` |
| `ci.yml` | Linux `CCACHE_MAXSIZE: 2000M` → `1500M` |
| `cache-cleanup.yml` header | note that `GET
…/actions/cache/usage-policy` 404s here (item 4); replace the stale
entry-size table |
| `ci.yml` comments | rewrite the cap budget with measured sizes and
KEEP=1; fix the Windows sccache comment's stale 628 MiB figure |

## Why

Ten of the fifty main CI runs between 2026-09-06 and 2026-09-10 were
cold builds — Build step 27–37 min instead of 3–6 — because "Restore
ccache objects" completed in 0 s with `Cache not found for input keys:
ccache-linux-build-`. `prune-main` was skipped on every trigger between
the previous save and the miss, so GitHub's LRU eviction removed it: the
repo has read 9.2–10.8 GiB on every `prune-main` usage line since
2026-09-04.

The 2026-08-15 budget projected ~6.3 GiB on main. It used entry sizes
measured while the caches were still filling, and assumed entries
compress to ~55% of the ccache directory. Neither held. Measured from
the Save steps on every main run 2026-09-01..05:

| entry on main | cap | budgeted | measured | ×2 kept |
|---|---|---|---|---|
| `ccache-linux-build-` | 2000M | 1.1 GiB | **1.88 GiB** (ccache
compresses its own objects; entry ≈ 95% of dir) | 3.8 |
| `sccache-windows-` | 1G | 0.38 | 1.02 | 2.0 |
| `ccache-sid-canary-` | 1000M | 0.57 | 0.94 | 1.9 |
| `ccache-ccache-macos-ci-` | 500M | 0.18 | 0.44 | 0.9 |
| deps (Qt macOS 1.3, install-qt 0.4, …) | | 1.8 | 1.8 | 1.8 |
| | | **6.3** | | **~10.4** |

That is over the allowance with zero PR-ref entries, so aethersdr#5009 and aethersdr#5548
item 1 (which trim PR refs) cannot fix it on their own.

**KEEP=1** frees ~4.5 GiB and brings main to ~6.1 GiB. The second copy
was insurance against a sparse/corrupt save; the `success()` gate on
every save step already prevents the sparse case, the insurance was
never used in 25 days, and one corrupt-entry recovery would cost a
single cold build where the insurance was causing them weekly.

**1500M, not 1000M**: the 2000M cap was earning its keep — main hit rate
99.2–99.7% at 2000M versus 94.21% recorded at 1000M, PR runs 91–99%.
1500M trims the largest entry to ~1.4 GiB (budget ~5.5 GiB) without
giving the whole window back. The comment says how to judge it from the
"ccache stats" step.

## Verification

Cannot be exercised on this PR: `prune-main` runs only on a main push,
and the Linux save only fires from main. Locally: both files parse as
YAML; no behavioural change beyond the two values.

After merge, over the following week:
- `prune-main`'s "Report repo cache usage" line reads ≤ ~6 GiB (≤ ~8 GiB
while open PRs still hold dependency caches, until item 1 lands).
- "Restore ccache objects" on main is never 0 s; Build stays in the 3–6
min band.
- `ccache --show-stats` on main: "Cache size" ~1.5/1.5 GB and hits ≥
~99% once the entry has re-warmed (the first save after merge starts
from the 661 MB cold entry now on main).

Items 1 (dependency caches save-on-main, with the install-qt-action →
aqt port) and 3 (tag-ref sweep) follow in a separate PR.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…#5548) (aethersdr#5552)

## What

Items **1** and **3** of aethersdr#5548. Independent of aethersdr#5551 (items 4/5a/5b);
the two can land in either order.

### Item 1 — dependency caches restore on PR, save only on main
(`ci.yml`)

Every dependency cache was a bidirectional `actions/cache` step:

| job | step | key | size |
|---|---|---|---|
| build (Linux) | DeepFilterNet3 | `deepfilter-Linux-X64-…` | 17 MiB |
| check-windows | Qt (via `install-qt-action`, `cache: true`) |
`install-qt-action-windows-…` | 396 MiB |
| check-windows | FFTW3 | `Windows-fftw3-3.3.5-…` | 2 MiB |
| check-windows | DeepFilterNet3 | `deepfilter-Windows-X64-…` | 31 MiB |
| check-macos | Qt | `qt-6.8.3-macos-clang64-…` | **1,297 MiB** |
| check-macos | DeepFilterNet3 | `deepfilter-macOS-ARM64-…` | 24 MiB |
| check-macos | qtkeychain | `qtkeychain-macos-arm64-…` | ~0 |

A PR that missed wrote its own copy onto `refs/pull/N/merge`, readable
by nothing else. With the repo at its allowance, LRU evicted main's copy
between runs and the next PR re-saved it — four open PRs each holding
the same 1.3 GiB Qt entry on 2026-09-10.

Each step is now `actions/cache/restore` next to its setup script, plus
an `actions/cache/save` **at the end of the job, after the tests**,
gated on `github.ref == 'refs/heads/main' && success() &&
steps.<id>.outputs.cache-hit != 'true'`. All three halves matter: the
bidirectional step these replace saved from a post step with `post-if:
success()`, so a tree only reached main once the whole job had passed
with it, and an inline save would persist a tree the build never
validated under a content-hash key nothing re-saves; the keys are
content hashes, so a save against an existing key would only warn on
every main run. `cache-hit` semantics are unchanged (no step uses
`restore-keys`), so the existing setup-step gating is untouched. The
reasoning and the accepted cost — a PR that bumps Qt or edits a setup
script misses on every push until it merges — are written once on the
Linux job's steps.

`QT_VERSION` and `AQTINSTALL_VERSION` move to workflow-level `env:` so
check-windows and check-macos read one value; `QT_ARCH` stays on
check-windows.

**Behaviour change beyond the split:** the Windows `Install FFTW3` step
is now gated on the cache hit (it ran on every run before).
`setup-fftw.ps1` writes only under `third_party/fftw3` and exports
nothing, so a hit needs no script run; this matches
`windows-installer.yml`'s copy of the step.

**Windows Qt port.** `jurplel/install-qt-action` has no restore-only
mode, so check-windows now installs Qt the way check-macos does: pinned
`aqtinstall==3.3.0` in a venv, `aqt install-qt windows desktop 6.8.3
win64_msvc2022_64 -m qtmultimedia qtwebsockets qtserialport
qtshadertools`, explicit restore/save on
`qt-6.8.3-windows-win64_msvc2022_64-aqt3.3.0-mm.ws.sp.st`. A separate
"Export Qt environment" step (runs on hit and miss) sets what the action
set — `QT_ROOT_DIR`, `QT_PLUGIN_PATH`, `QML2_IMPORT_PATH`, `bin` on PATH
— plus `Qt6_DIR` and `CMAKE_PREFIX_PATH`, and fails if `qmake.exe` is
not at the expected layout path. Version and arch are spelled exactly as
in `windows-installer.yml`. The Configure step is unchanged;
`find_package(Qt6)` resolves through the exported variables as before.

### Item 3 — sweep tag-ref caches (`cache-cleanup.yml`)

`appimage.yml`, `macos-dmg.yml` and `windows-installer.yml` run on
`push: tags` only, and main never runs them, so their caches (Opus,
FFTW3, hidapi, Vulkan SDK, macos-deps, DMG-leg ccache, install-qt) exist
only on `refs/tags/v*`. A run on `v26.9.2` can read its own ref and
main, never `v26.9.1`'s, so every release writes a full set nothing
later reads; neither `prune-main` nor the closed-PR sweep looked there,
and `macos-dmg`'s `ccache-action` append-timestamps its key (~0.5 GiB
per leg per release).

`sweep-closed-prs` → `sweep-dead-refs`, one step that handles both
closed-PR refs and tag-ref entries whose `lastAccessedAt` is older than
48 h. Last-access rather than "not the newest tag" because the one
legitimate reader is a re-run of the same tag's workflow after a flaky
release step; 48 h keeps a next-morning retry warm. One `gh cache list`
(sorted oldest-access first, so hitting the 500 cap drops entries we
would keep rather than the candidates), one jq pass emitting `id:size`
pairs, a null-safe idle test, and a shared delete-by-id loop with 404
tolerance.

### Also

- `cache-cleanup.yml`'s closed-PR comment described the dependency-cache
loop as an open ci.yml problem; it now records that ci.yml saves nothing
from PR refs and that this sweep is the backstop for a regression to a
bidirectional step.
- The two sentences aethersdr#5551 left in `ci.yml` describing item 1 as pending
are flipped.
- Both "no qmake" errors now say how to clear a poisoned main entry (`gh
cache delete --key … --ref refs/heads/main`), since no sweeper matches
`qt-*` on main.

## Verification

- Both workflows parse; every bash `run:` block passes `shellcheck -S
warning` (the only hits are pre-existing `${{ }}` false positives).
- **Cannot be fully exercised on this PR:** the `Save` steps only fire
from main, and the sweep only from schedule/dispatch on the default
branch. What this PR's own CI does prove: the Windows aqt install +
export path (a cold miss, since no `qt-6.8.3-windows-…` entry exists
yet), the FFTW3 miss gating, and that all three jobs still configure and
build. Expect check-windows to be slower on this PR only — it is
downloading Qt with no cache to restore.
- The sweep script was dry-run locally against the live listing with `gh
cache delete` stubbed: no candidates today (no PR-ref or tag-ref caches
exist), and the pair parser / 404 path verified with synthetic input.
- **After merge, by hand:** `gh cache delete` main's now-orphaned
`install-qt-action-windows-…` entry (396 MiB; nothing reads it after
this PR and no sweeper matches it on main). Then read the first main
run's log to confirm all seven `Save` steps executed (not skipped) and
note the Windows Qt entry's size against the 396 MiB the action's entry
was.
- After merge: first main run writes the Windows Qt entry; subsequent
PRs restore it. Over the following week `gh cache list --json key,ref`
should show no `qt-`/`deepfilter-`/`fftw3`/`qtkeychain-` keys on
`refs/pull/*/merge`, main's dependency entries should keep a stable
`createdAt`, and after the next release `sweep-dead-refs` should report
the tag's set swept two days later. Trigger it once via
`workflow_dispatch` after merge and read the log.

Item 2 (sweep on `workflow_run`) stays deferred per the issue: with this
in, there is little left on PR refs for it to reclaim.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
## Summary

Continues aethersdr#3849 after aethersdr#5458 with one bounded receive-control method:
`slice.setFrequency` for an existing owned slice. Coordination:
aethersdr#3849 (comment).

- Retains observe-only startup and the existing explicit
`--allow-local-control` opt-in. No TX method, raw command, new
credential path, discovery behavior or desktop migration.
- Validates the control grant, canonical session/slice identity, exact
observed resource revision, integer Hz, live ownership/lock/connection
state and backend coverage before one typed dispatch.
- Separates backend frequency observations from the existing optimistic
desktop value. `accepted:true` is not a hardware ACK or convergence
guarantee; revisions are freshness checks, not CAS or controller
reservations.
- Refuses TX-capable sessions until explicit idle readback, invalidating
that knowledge on local/radio TX activity and connection/backend
transitions.
- Documents backend authority and coverage explicitly. Sim is the
exercised path. RTL-SDR can qualify when built; TX-enabled HL2, Flex and
ANAN remain unavailable where readback or verified bounds are missing.
Icom stays outside the daemon's current catalogue connection scope.
Existing desktop tuning remains unchanged.

See `docs/aetherd-local-slice-frequency-control.md` for the precise
contract, errors, competing-controller behavior and limitations.

## Constitution principles

Principle II: accepted intent never fabricates observed frequency.
Principle VI: no TX operation or grant is added. Principle VII: strict
boundary validation and fail-closed coverage/idle checks. Principle XI:
behavioral tests and deliberate guard mutations demonstrate the safety
claims.

## Verification

- ARM64 `AetherSDR` and `aetherd` built with the dedicated Mac
toolchain, RADE enabled; ARM64 processors/executables verified, no
RNNoise x86 sources, no QtWidgets daemon dependency.
- Nine focused CTests passed: `control_protocol_codec_test`,
`control_authorization_test`, `local_control_server_test`,
`control_resource_service_test`, `radio_catalogue_test`,
`control_connection_test`, `control_slice_frequency_test`,
`radio_discovery_source_test`, `aetherd_discovery_startup_test`.
- New socket-free production-target tests cover grants, schema,
ownership, limits, delayed/differing/same-value observations, optimistic
desktop separation, revision conflicts/recreation, multiple controllers,
TX-idle admission, lifetime, owning thread and request budgets.
- Mutation checks: removing revision validation and assuming idle at
construction each failed the new test. A separate local-keying
regression failed before the fix; all tests pass with guards restored.
- Real daemon/local-server smoke with production simulator: observe-only
refusal, opt-in connection/tuning, subscription events, two controllers,
stale revisions and disconnect.
- Native Cocoa desktop MCP proof on the exact rebuilt ARM64 artifact:
authenticated identity, isolated settings, `txAllowed:false`,
`DEMO-0001` tune/disconnect/reconnect/tune. The test instance was
stopped afterward.
- Engine boundary, generated touchpoint manifest, test registration,
frozen CI gate and whitespace checks pass. Existing engine-boundary
legacy warnings remain unchanged.
- Git merge-tree confirms clean integration with refreshed upstream/main
`df0520ad7725153a2802b1643d8c84edb01b450a`, preserving both subsequent
upstream changes. Runtime evidence is for the reviewed branch on the
aethersdr#5458 merge base, not a rebuilt synthetic merge.

The new CTest does **not** bind sockets or simulate third-party
firmware. Its `LocalControlServer` objects are inert binding-policy
checks. The existing local-server test and manual daemon smoke bind our
actual current-user local service; the server test required an
unsandboxed run to bind on macOS. The daemon used `--discover-sim`,
never `--discover-local`. Desktop startup retains its normal discovery
listeners, but only `DEMO-0001` was selected: no hardware connection or
TX testing occurred. RTL-SDR is not available in this local build;
hardware convergence and Windows/Linux runtime validation remain
untested. Per-PR CI is pending.

## Review

The functional review found and fixed a missing invalidation edge: a
local transmitting/MOX/TUNE active interval must discard previously
confirmed idle, and a subsequent local false edge must not restore it.
The regression failed before the fix and passes afterward.

The independent architecture/control-path reviews and the security diff
review found no remaining actionable findings. Security scan
`eaf6de71-33fa-4562-9d92-597bb4b13a7e` reviewed the exact pre-commit
working-tree patch on `981b7fe6593699ac8a5317c4cbe513ca1f56b6d7` and
recorded zero findings. **Report artifact caveat:** its exporter
retained two already-completed checkpoint items as deferred, so the
sealed report labels coverage partial. The preserved final submitted
checkpoint explicitly has complete coverage, no deferred items, and all
five surfaces reviewed; the sealed report also includes those five
completed surfaces. The sealed artifacts were preserved unchanged, and
this is not presented as a clean automated coverage verdict. The
hardware/platform limitations above still apply.

## Checklist

- [x] Signed commit `abd7d72f29faa567cc17255e7362ebe74769f214`; local
SSH signature verification passed.
- [x] No new settings keys or dependencies; no `CHANGELOG.md` change.
- [x] Clean-room typed backend integration; Flex range uncertainty is
left unavailable rather than using commented-out FlexLib limits.
- [x] Protocol/resource/capability documentation and test registration
updated; no frozen PR test-gate expansion.

---------

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…sdr#5548) (aethersdr#5553)

## What

Two CI-budget fixes in `ci.yml` and `codeql.yml`:

1. **Pin `py7zr==1.0.0`** in both aqt venvs (check-windows,
check-macos), as a workflow-level `PY7ZR_VERSION` next to
`AQTINSTALL_VERSION`. Fixes red main after aethersdr#5552.
2. **`trap-caching: false`** on the CodeQL init step. Removes a 2.9 GiB
cache entry that is written on every source-touching push to main and
never read.

## Why — py7zr

The first main run after aethersdr#5552
([34528490345](https://github.com/aethersdr/AetherSDR/actions/runs/34528490345))
failed in check-windows during `Install Qt LTS via aqtinstall`:

```
WARNING : Caught Bad7zFile, terminating installer workers
ERROR   : Specified path is bad: lib/cmake/Qt6ShaderToolsTools
py7zr.exceptions.Bad7zFile: Specified path is bad: lib/cmake/Qt6ShaderToolsTools
```

This is [aqtinstall
aethersdr#995](miurahr/aqtinstall#995): py7zr 1.1.x
added symlink sanitising that intermittently rejects Qt's archives on
Windows (~50% of runs per the reporter; maintainer confirmed the source;
pinning py7zr 1.0/1.1.0 is the reported workaround).
`jurplel/install-qt-action` never hit it because it pins `py7zr==1.0.*`
alongside `aqtinstall==3.3.*`. The venv aethersdr#5552 introduced installs
`aqtinstall==3.3.0` alone, and its `py7zr>=0.22.0` requirement resolves
to 1.1.3. It passed both PR runs of aethersdr#5552 and then failed the first main
run — the shape of an intermittent bug, not a main-specific one.

The Qt cache key is deliberately unchanged: the extractor does not
change what it lays down. Not touched: the aqt installs in
`.github/docker/Dockerfile`, `appimage.yml`, `macos-dmg.yml`
(Linux/macOS, no reports there).

## Why — CodeQL TRAP caching

`codeql-action` uploads a TRAP cache keyed on the commit sha from every
default-branch run (`trap-caching.ts`, `uploadTrapCaches`), and
`codeql.yml` runs on every source-touching push to main — 5 to 23 times
a day over the last two weeks. TRAP caches only speed up PR analyses: on
the default branch the action logs `Analyzing default branch. Skipping
downloading of TRAP caches`, and this workflow has no `pull_request`
trigger, so **no run has ever restored one**. The action's own
`cleanupTrapCaches` needs `actions: write`, which `codeql.yml`
deliberately does not grant, so it 403s silently; LRU eviction was the
only thing removing the entries, and each eviction round could take
`ci.yml`'s compiler caches with it.

Measured 2026-09-10: one `codeql-trap-1-2.27.0-cpp-<sha>` entry on main
at **2,939 MiB** — the largest single cache in the repository, absent
from every budget table in aethersdr#5008 / aethersdr#5551. Sept 6, the day the cold Linux
builds began, had 23 CodeQL runs on main. With this off, main's steady
state drops from ~9.5 GiB to ~6.6 GiB and the transient peak from over
10 to ~8.2 GiB.

Analysis results are unaffected; only the extractor's cache of parsed
files is not persisted.

## Verification

- A PR run exercises the Windows aqt path cold (no `qt-6.8.3-windows-…`
entry on main yet, since main's save never ran). One green run does not
prove an intermittent bug fixed; the pin matching install-qt-action's
known-good configuration is the evidence.
- CodeQL does not run on PRs, so the `trap-caching` change is exercised
by the first source-touching push to main: its log should show no
`Uploading TRAP cache` line, and no new `codeql-trap-*` entry should
appear.
- After merge: the first main run should show `Save Qt` executed on
check-windows and the entry on `refs/heads/main`.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The v26.9.2 production release generated `26.9.205.0` because aethersdr#5346
applied the Windows workflow run counter to both production and
development MSIX versions. Production tag pushes now normalize the CMake
release version instead: `26.9.2` produces `26.9.2.0`, independent of
run number or flight configuration.

Store-ineligible release versions no longer abort Windows delivery. A
nonzero hotfix revision, unsupported Store patch, or mismatched/suffixed
tag returns `storeEligible = false`, an empty MSIX version and an
explanatory warning. The workflow skips MSIX creation, Store
symbol-package validation and production Store submission while
retaining portable ZIP, Inno installer, debug-symbol artifacts and
GitHub release attachment. Supported releases retain their existing
symbol packaging and draft-only submission behavior.

Developer flights intentionally keep higher run-based versions
(`26.9.<run>.0`), as requested by the operator. Their separate CLI issue
and live certification proof remain outside this production fix.
Documentation restores the instruction to compare production versions
with the highest accepted Partner Center package before upload.

Validation on the updated changes:
- PowerShell 7.6.5 on macOS ARM64: all 140 socket-free Store policy
assertions pass (review follow-up f2bfee5 trimmed an inert loop and
added tag-spelling and workflow-gate cases). Publisher calls use an
in-process substitute; no Store authentication or network calls occur.
- Executed the actual planning step extracted from the workflow YAML
with normal, hotfix, suffixed and mismatched tag inputs. Every case
retains `releaseArtifacts=true`; only the normal release enables
`storeEligible` and `productionDraft`. Checked the parsed workflow gates
preserve ZIP, EXE and symbol delivery.
- Five independent mutations fail: restoring either release-path throw,
disabling all release artifacts for ineligible Store versions, enabling
an ineligible Store submission, and restoring production run-counter
numbering. Restored code passes all assertions.
- actionlint 1.7.12, strict test-registration and whitespace checks
pass. No test registration or frozen CI gate changes.
- No application compile, Windows SDK packaging or live Store ingestion
was run for this code change. The automation bridge cannot exercise
packaging policy. Hosted PR CI is separate; it does not execute the
tag-triggered Windows Installer workflow.

The Windows release workflow change requires maintainer CODEOWNERS
review. The already-repackaged 26.9.2.0 artifact is separate release
recovery work.

Generated with OpenAI Codex (GPT-6 Astra)

---------

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
on8st and others added 28 commits September 25, 2026 15:53
…'s own carrier stops reaching the demodulator (aethersdr#5850)

Closes aethersdr#5497.

> ## Read this before merging: this PR must NOT land alone, and the CW
rule here is narrower than it was.
>
> **Landing order, ruled by @ten9876 on 2026-09-24: hold aethersdr#5850 until a
fix for aethersdr#5498 is ready, then land them together or back to back.**
Merging this alone widens the post-unkey dropout from ~114 ms to ~250
ms. Nothing below should be read as this PR being ready to merge on its
own.
>
> **1. aethersdr#5498.** aethersdr#5497 and aethersdr#5498 occupy the same 200 ms and do **not**
share a fix. The +57 dB leak this removes was partially *masking*
aethersdr#5498's dropout, so deferring the unmute makes that hole **2.2× worse**:
measured at `final`, **113.74 ms median on stock → 250.65 ms with this
hold**.
>
> **No fix for aethersdr#5498 has landed.** aethersdr#5498 is still an open issue; aethersdr#5855
measures its staleness but does not close it. On current `main`, merging
this alone turns a 114 ms dropout into roughly 250 ms. The leg carrying
both candidate fixes measured 151.10 ms — still 37 ms worse than stock —
so aethersdr#5498 does not fully pay it back either.
>
> **2. CW full break-in — ruled by @ten9876 / KK7GWY, 2026-09-24:** per
`AGENTS.md` § Autonomous Agent Boundaries the break-in behaviour is UX
and the maintainer's call. **The hold is skipped only when the break-in
delay is shorter than the hold.** At the default 500 ms delay the key-up
that ends the over gets the normal hold. Details in *"The CW question"*
below.
>
> **Landing order and a trivial conflict.** aethersdr#5880
(`hl2/health-block-expiry`) touches `Hl2Backend.h` too. The overlap is
**one `friend` declaration at a shared anchor** — both insert
immediately after `friend struct Hl2TxGateTestAccess;` — and is resolved
by keeping both lines. aethersdr#5880 adds no data members and nothing this
change reads; whichever lands second needs a trivial rebase and no
re-measurement.

## The CW question — ruled by @ten9876

**The ruling (@ten9876 / KK7GWY, 2026-09-24, on this PR): CW full
break-in skips the unkey hold only when the break-in delay is shorter
than the hold.** That is the case where the hold would swallow the space
between elements and take QSK away. At a longer delay, including the
default `cwDelay` of 500 ms, the hang fires once at the end of the over
and that key-up gets the ordinary hold. The predicate is `cwBreakIn &&
m_cwHangTimer && m_cwHangTimer->interval() < m_unkeyUnmuteHoldMs`;
`QTimer::interval()` still returns the hang `setCwKeying()` started
after the single-shot fires, so nothing new is latched.

### Why the hang timer now calls `applyKeying` directly

`applyKeying(…, cwBreakIn)` has exactly two call sites. `setKeying()`
hard-codes `false`. `setCwKeying()`'s **key-DOWN** branch passes `true`
— and that is the only `true` in the tree. The key-**UP** in full
break-in never came through either: it arrives from the `m_cwHangTimer`
timeout, which called `setKeying(false, …)`. So on the one edge that
arms the hold, `cwBreakIn` was **always false, including in full
break-in**. A hold gated on the parameter as it stood would have been
dead code in precisely the case it exists for, and the change would have
shipped looking correct and doing nothing.

**The fix is to make the flag reach the release edge, not to invent a
predicate.** The hang timer now calls `applyKeying(false,
m_cwHangOperation, completion, /*cwBreakIn=*/true)` directly. **Nothing
moves on the wire:** `applyKeying`'s only other use of the argument
chooses `MetisClient::setCwMox()` over `setMox()`, and `setMoxImpl()`
reads `cwBreakIn` only inside its `keyed ? … : operation` arm — so on an
**unkey** the two are the same function. Checked against the source
rather than assumed, because this is a transmit path.

There is no reach across a thread boundary for
`TransmitModel::cwBreakIn()`, and there must not be: the operator's
setting arrives as `setCwKeying()`'s `breakIn` argument, the backend
latches it as `m_cwAutoKeyed` for the element and its hang, and the hang
timer — which is parented to the backend and therefore fires on the
backend's own thread — hands it on as the parameter.

### Semi-break-in keeps the hold, and that is asserted

With break-in off, `setCwKeying()` never keys at all: CW rides an
MOX/PTT the operator asserted, and the over ends when **they** release
it. That release arrives through `setKeying()` with `cwBreakIn` false,
has a real T/R turnaround behind it, and gets the full hold. Only full
break-in skips it.

### The ~17 WPM crossover is arithmetic, not a measurement

Restated because it would otherwise read as observed. **There is no CW
measurement anywhere behind this change** — aethersdr#5497's eleven windows are
all 4-second SSB keys, and nothing in this lab has keyed CW.

`setCwKeying()` starts `m_cwHangTimer` at `max(kCwEnvelopeReleaseMs /* 6
ms */, cwDelay)` per element, so at the deliberate-QSK setting of
`cwDelay = 0` every element's release reached `applyKeying(false)` and
armed the 70 ms hold; the next element's key-down stopped the timer and
re-muted. At PARIS timing the inter-element space is `1200/WPM` ms, so
it falls below:

- `kUnkeyUnmuteHoldMs` = 70 ms at **1200/70 = 17.1 WPM** — the figure
this PR and both reviews quoted; and
- the **76 ms** that actually elapsed from element key-up to unmute, the
6 ms hang **plus** the hold it armed, at **1200/76 = 15.8 WPM**.

Two divisions on two constants in `Hl2Backend`. The second is the honest
one and is 1.3 WPM lower than the number on the thread, because the
earlier arithmetic counted the hold and not the hang that arms it.
Neither is measured, and the tests assert armed-or-not-armed rather than
a speed.

### Options considered

1. **Skip the hold whenever full break-in is on.** Not taken: at a delay
longer than the hold (including the default 500 ms) the hang fires only
at the end of the over, so skipping there buys no between-element
listening and lets the +57 dB burst back in for every break-in over.
2. **Keep the hold always and accept semi-QSK.** Not taken: at a short
delay it removes the thing a QSK operator switched QSK on to get — a
mode that reads "full break-in" and behaves as semi-break-in above ~16
WPM.
3. **Scale the hold to the element rate.** Not taken: more machinery
than this defect deserves, and it would need a CW measurement that does
not exist.

**What the leak costs at a short delay.** What the receiver hears in
those milliseconds is the operator's own PA decaying into their own
front end, forty-plus times a second at speed — the same +57.55 dB /
+10.60 dBFS artefact this change removes everywhere else. It is accepted
only where suppressing it would take QSK away.

## The mechanism

`Hl2Backend::setKeying` queued the demodulator unmute and *then*, later
in the same function, queued the MOX-off. Both targets live on **the
same thread** — `Receiver::dsp` is moved to `m_ioThread` in
`openReceiverDsp`, `MetisClient` in the constructor — and both edges are
`Qt::QueuedConnection`. So this was never a race the unmute might lose:
it is FIFO in posting order and the unmute was **guaranteed** to be
delivered first. The demodulator listened at full gain while the PA was
still up.

Measured on ON8ST's own radio into a dummy load: **+57.55 dB** over the
receiver's own resting floor, sample peak **+10.60 dBFS** — 3.4× full
scale — on 11 of 11 windows.

The T/R turnaround was measured with an estimator that uses **both
edges** of the leak, so the threshold level cancels algebraically: **W =
59.40 ms median (51.66–66.15, n=11)**, spread/median 0.224. A registered
falsifier — that the spread would be comparable to W itself, making a
fixed timer the wrong shape of fix — was tested and did **not** fire.

**There is nothing to gate on instead, and this was checked rather than
assumed.** `Ep6Response::ptt` is decoded, but `MetisClient`'s own note
records `ptt_resp` as `cw_on | ext_ptt` — the radio's *external* keying
inputs, which never go high for a host MOX key. There is no readback of
host MOX on the EP6 wire. A delay is forced.

## The change

- Key-down mutes immediately at the top of `applyKeying`; the key-up
release moved **below** the `setMox` invoke, so the ordering invariant
holds even at a hold of zero, and `m_unkeyUnmuteHoldMs` defers on top of
that.
- **The hold is armed by the key-up EDGE, not by the unkeyed STATE.**
`applyKeying` already computes `keyChanged` for `m_sinceUnkey` and
`transmitChanged`; the release now consults it. A second
`setKeying(false)` arriving with `m_keyed` already false started no T/R
turnaround and so has none of its own to cover — it inherits the one
already being covered.
- **`mixReceiverAudio` gates on `m_rxAudioMuted`**, the same flag the
demodulator uses, instead of `m_keyed`. The deferral window is then a
*gap* in `audioFrameReady` rather than 70 ms of digital zeros — zeros
fill the engine's presentation prebuffer with silence that must play out
before live audio can, which is why the bench's deferred-unmute leg cost
~137 ms at the device for a 70 ms constant. This also removes the
`m_keyed`/mute skew `setKeying`'s own comment apologised for.
- `applyRxAudioMute` checks its thread affinity in **every** build: a
`qCWarning(lcHl2)` always, with `Q_ASSERT_X` on top so a debug run stops
at the offending call. `Q_ASSERT` alone compiles out under
`QT_NO_DEBUG`, which Qt defines for every non-Debug configuration — i.e.
exactly the build that ships.
- `setTxAudioMonitor` **splits on `on`**: turning the monitor ON is
answered immediately, in every state, because asking to hear the
receiver is asking for something; turning it OFF while an unkey's hold
is armed leaves the hold running, because asking for off is asking for
nothing and cancelling it would unmute inside the T/R window.
- **CW full break-in skips the hold only when the break-in delay is
shorter than the hold**, per @ten9876's ruling. At the default 500 ms
delay the hold arms normally. The predicate is `cwBreakIn &&
m_cwHangTimer && m_cwHangTimer->interval() < m_unkeyUnmuteHoldMs`. The
`m_cwHangTimer` timeout now calls `applyKeying(false, …,
/*cwBreakIn=*/true)` instead of `setKeying()`, which is what makes the
flag reach the key-up edge at all; the release branch gains an `else if
(cwBreakIn)` arm that stops the timer and unmutes now. Wire-identical on
an unkey. Semi-break-in takes the branch below it and keeps the full
hold.
- The constant is `kUnkeyUnmuteHoldMs = 70` seeding a member a test can
set to zero, with the measurement and the coverage table (70 ms covers
11/11 with 3.85 ms to spare; 65 covers 9; 60 covers 7) in its comment.
There is no setting behind it: a second station needing a different
value needs a new measurement and a rebuild.

**Two things the diagnosis did not name, both found while
implementing.** `setTxAudioMonitor` also drives the mute, and it is now
the split above rather than a single immediate path. And
`pushInitialState`'s session reset cleared `m_keyed` only; since the
mixer no longer reads it, a reconnect-while-keyed session would have
been handed a permanently closed gate on the path where its
`setKeying(false)` is skipped. That reset now also runs its unmute
**after** the cleanup `setKeying(false)`, so the relink path does not
reproduce the posting order this PR exists to remove.

## Evidence

`tests/hl2_unkey_hold_test.cpp` — an **ordering** test, because this is
a scheduling defect. No WDSP, no socket; a real `Hl2RxDsp` probe on the
real I/O thread, flushed by a blocking round-trip so "one event-loop
turn" is exact.

**Before** (stock ordering restored, accessors kept so it compiles):
```
[FAIL] the demodulator is STILL MUTED after the MOX-off has been applied
[FAIL] the MOX-off is queued BEFORE the unmute lands, not after
[FAIL] the mixer gates on the same flag, so the hold is a GAP and not zeros
[FAIL] re-key: the hold is armed
[FAIL] re-key: still muted half way through the hold
[FAIL] re-key: the second unkey arms a fresh hold
[PASS] POSITIVE CONTROL: at a hold of ZERO the release is IMMEDIATE
```
**After:** **80 checks, all pass.**

Mutations, each applied, rebuilt, run and reverted:

| mutation | reds |
|---|---|
| M1 — hang timer back to `setKeying(false, …)`, so `cwBreakIn` never
reaches the key-up | **7** |
| M2 — QSK arm disabled (always hold) | **7** |
| M3 — predicate widened to an unconditional `else if (cwBreakIn)` |
**3**, all in leg 7c (break-in at the default 500 ms) |
| M4 — boundary loosened from `<` to `<=` | **2**, both in leg 7d (delay
equal to the hold) |

**The positive control passes on both legs, and that is the point.**
"Still muted after one turn" is also what a dropped `invokeMethod`, a
renamed slot, an unattached DSP and a forgotten `processEvents()` all
look like. At a hold of zero, stock and fixed are identical — so that
leg cannot mask the defect and can only rule out a dead harness.

The redundant-unkey leg **measures** rather than asserts, because the
number is charged to aethersdr#5498: with a 200 ms hold, 311 ms of mute past the
real MOX-off before the edge guard and 210 ms after it.

**The CW legs, with the numbers read off the run.** The structural
assertions read `holdArmed()` at the instant the MOX-off lands, while an
armed hold would still be running:

```
[PASS] QSK: NO HOLD IS ARMED at the instant the MOX-off lands
    MEASURED: MOX-off applied 6 ms after the element key-up; hold armed at that instant: NO (hang 6 ms, hold 400 ms)
    MEASURED: the receiver opened 6 ms after the element key-up (hold 400 ms)
[PASS] between-elements: STILL open a full 48 ms inter-element space after the KEY-UP
    MEASURED: 48 ms after the element key-up, muted: NO (shipped hold 70 ms)
[PASS] break-in/500: THE HOLD IS ARMED — a break-in delay LONGER than the hold does not skip it
    MEASURED: MOX-off applied 476 ms after the key-up; hold armed at that instant: YES (hang 500 ms, hold 400 ms)
    MEASURED: the hold held the mute 380 ms past the MOX-off (hold 400 ms)
[PASS] break-in/equal: THE HOLD IS ARMED — a delay equal to the hold is not shorter than it
[PASS] semi: THE HOLD STILL ARMS — only short-delay full break-in skips it
    MEASURED: semi-break-in held the mute 189 ms past the MOX-off (hold 200 ms)
[PASS] double-unkey/QSK: a redundant unkey on an open chain arms no hold
    MEASURED: with the CW branch present, the mute lasted 210 ms past the real MOX-off (hold 200 ms)
```

**6 ms is `kCwEnvelopeReleaseMs` and nothing else** — the CW envelope
hang at `cwDelay = 0`, with no hold behind it. Against a deliberately
inflated 400 ms hold, so "skipped" and "short" cannot be confused. The
between-elements leg runs on the **shipped 70 ms** constant rather than
a test value, because that is the number an operator would have met.

**The double-unkey edge guard holds with the CW branch present:** 210 ms
for a 200 ms hold, against ~300 ms had the new arm restarted it.

**The legs assert on `m_unkeyUnmuteTimer->isActive()`, not only on the
mute.** An expired hold also leaves a chain unmuted, so "the receiver is
open" alone would not distinguish a skipped hold from a hold that had
simply run out.

**The build slot was held across the compile AND the run.** These legs
measure mute duration against the wall clock, so a competing build does
not merely slow them, it moves them.

Full `ctest`: **100% of 515 passed**, `HAVE_HIDAPI` OFF. One caveat
stated rather than quietly dropped: the first run of that suite had
`vkamp_connection_test` fail under `-j4`. It passes alone and passed on
the clean re-run; it is a socket test that touches nothing in this
change (`grep` for `Hl2Backend|setKeying|cwBreakIn` in it: zero hits).
Reported because a flake I do not mention is a flake the next person
rediscovers.

## For whoever picks up aethersdr#5498

The RXA-hold candidate removes the bit-zero anchor the bench's
T/R-window estimator depends on, so **W cannot be re-measured the same
way** on a build carrying it. Measure W first, or keep a binary without
the hold.

**One caveat on the 250.65 ms figure, stated rather than quietly left.**
It was measured on a build that could restart the hold on a redundant
unkey (the edge guard above is new). On a single clean unkey it is
unaffected; on any path that issues a second `setKeying(false)` inside
the window it was an over-estimate. **The bench has not been re-run** —
this build is not on the radio, and the figure stands as measured.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…ool from the ratchet

Closes aethersdr#5727.

direct_bool_fields() accumulated the RadioCapabilities struct a line at a time and split on semicolons, so a member function deleted the next bool from the capability ratchet. A bool added after an accessor was invisible growth with the count left flat. The scan now drops everything at depth >= 2, emits a terminator only when a brace opens a function body, and blanks comments, literals, numbers, and directives in one pass. A constructor that brace-initialises more than one member ends the statement when that block closes, and a C++14 digit separator is no longer read as a character literal.

The parser test is registered as capability_record_parser and also runs on the pre-merge Static checks job. FROZEN_BOOL_COUNT stays 71: the fixed parser returns the same 71 names in the same order as the parser it replaces.

Squashed-from: aethersdr#5860

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…Principle XI.

## Summary

Closes aethersdr#5828. `Hl2Backend::setAutoRfGain` opens on a guard that compares against
the **running** flag, and its refusal path deliberately leaves the loop off with
the operator's wish recorded. From that state an explicit "off" matched the
guard and returned at the first line, never reaching the disarm branch — the
only writer of `m_autoRfGainWanted = false`. The profile kept
`rfGain.autoEnabled: true`, and the next connect from a baseline the loop trusts
armed a control the operator had switched off.

**The fix widens the guard onto both flags and routes the withdrawal through the
existing disarm branch** — `if (on == m_autoRfGainEnabled && on == m_autoRfGainWanted)`.
An earlier revision of this body argued for a separate block *before* the guard and
had a whole section explaining why not to widen it. That approach was abandoned
during review and this body has been corrected; see *"One exit for 'off'"* below for
why widening turned out to be the right call and not merely the tidier one.

## The correction that matters more than the fix

Both the triage comment on aethersdr#5828 and **my own reply agreeing with it** concluded
that the withdrawal path is unreachable, so a backend-only fix would ship dead
code. **The observation is right and the conclusion is wrong**, and that is the
most important sentence in this PR.

The observation holds. `SpectrumOverlayMenu::setAutoRfGainEnabled` does bring the
checkbox back down under a `QSignalBlocker`, and says why — *"The checkbox has to
be able to come back down without that looking like the operator unticking it, so
this path must not emit."* After a decline there is no tick left to remove, and
`MainWindow::wirePanadapter`'s `autoRfGainChanged` lambda never emits the `false`.

What neither of us checked is whether the GUI is the only caller. It is not.
`AutomationServer::doPan`'s `pan autorfgain on|off` verb on current `main` calls
the seam directly, with no `isArmed()` guard:

```cpp
ag->setArmed(on);
```

So **`pan autorfgain off` from the refused state drops the withdrawal today**, on
shipped code, with no GUI involved. The verb's own comment is what makes the drop
invisible rather than merely present:

> DELIBERATELY NOT AN ECHO OF THE ARMED STATE. The backend may decline to arm and
> log why; reporting `requested` rather than `on` keeps this verb honest about
> the difference.

It returns `requested: false`, which is true, while the preference stays `true`
on disk. A headless caller has no way to see it.

The third caller is guarded and is unaffected: `RadioCertification` takes
`const bool autoGainWasOn = autoGain && autoGain->isArmed();` and only calls
`setArmed(false)` inside `if (autoGainWasOn)`, so it never reaches the refused
state. Those three plus `Hl2Backend`'s own `linkUp` handler are the whole caller
set; there is no other route above the seam.

So the backend fix is not dead code and it is not waiting on the GUI question.
The GUI affordance is still worth having, is still a maintainer's call on
presentation, and is deliberately **not** in this PR.

## The mechanism

`Hl2Backend::setAutoRfGain`:

```cpp
if (on == m_autoRfGainEnabled) {
    return;
}
```

The refusal path sets `m_autoRfGainWanted = true` and returns with
`m_autoRfGainEnabled` still `false` — deliberately, so the asking survives a
decline. Those two compose into a state the control cannot leave: from
`enabled=false, wanted=true`, an explicit `setAutoRfGain(false)` matches the
guard and returns before the disarm branch.

`Hl2Backend::currentOperatingState` persists the **wish**
(`{"autoEnabled", m_autoRfGainWanted}`, and its comment explains at length why
that is the right thing to persist), so the profile keeps
`rfGain.autoEnabled: true`. The `linkUp` handler's
`if (m_autoRfGainWanted && !m_autoRfGainEnabled) setAutoRfGain(true);` then arms
on the next connect from a baseline it trusts.

**This is the default path, not a corner.** `kLnaDefaultGainDb` is +20 dB and
`Hl2Backend::kAutoRfGainMaxBaselineDb` is +19, so the first tick on a radio
nobody has retuned lands in the refusal. `applyRestoredState`'s own comment
already says exactly that.

## One exit for "off", and why the earlier plan was wrong

This body previously argued for a second exit *before* the guard, on the grounds
that falling through to the disarm branch would log *"disarmed, baseline …
restored"* for a loop that never ran. **That reasoning does not survive contact
with what the caller is owed.**

A withdrawal is not a different event from a disarm. It **is** a disarm, of a loop
that happens not to be running, and it owes the caller exactly what a disarm owes:
the wish cleared, the refusal reason dropped, a settled verdict emitted, the
document republished. Hand-copying that subset into a second exit is what produced
the defect this PR's own review caught — a "successful off" that went on reporting
*"declined"* through the bridge reply and through the checkbox's accessible
description. **One exit, one list.**

**And every side effect of the disarm branch is inert from the refused state**,
which is what makes routing it there safe rather than merely tidier:

- `m_autoRfGainEnabled` is already false.
- `applyBandscopeForAutoGain()` computes `wanted` false and returns at
  `if (!m_bandscopeOwnedByAutoGain)`, because that flag is set only where
  `m_autoRfGainEnabled` was true.
- `m_autoGainReason` is already `Disarmed` — its member default, and the value
  every route out of a running loop leaves behind.
- `m_autoGainState` is default-constructed for the same reason.
- `setLnaAutoOffsetDb(0)` returns at its own `requested == m_lnaAutoOffsetDb`, the
  offset being 0 unless the loop ran.

**The one thing that was not inert is the log sentence, and it is conditioned.**
That was the whole of the original objection, and it costs one predicate.

**The combination the widening newly admits to the arm branch — `on` true,
enabled true, wanted false — is unreachable.** `m_autoRfGainEnabled = true` is
written in exactly one place and the next statement sets `m_autoRfGainWanted =
true`, while every writer of `wanted = false` (the disarm below, and
`applyRestoredState`) has already set enabled false.

The disarm branch's existing comment — that a REFUSAL does not reach it — stays
true and is not contradicted: it is about the refusal itself, and a later explicit
off is a different event.

Squashed-from: aethersdr#5862

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…theme token

Closes aethersdr#5857.

Radio Setup value labels used the colour literal #00c8ff, which is neither theme's color.accent.bright (#00c8f0 on Default Dark, #0098c0 on Default Light). A plain setStyleSheet does not re-resolve when the theme changes. Default Gateway and Network Name already called applyStyleSheet, but they passed that literal, so they were tracked and still never moved.

All sixteen sites now go through makeValueLabel() and {{color.accent.bright}}. The literal is deleted. radio_setup_region_field_test switches Default Dark and Default Light and reads the resolved colour back.

Squashed-from: aethersdr#5863

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…reading rfGain.defaultDb (aethersdr#5829). Principle VII.

Closes aethersdr#5829.

## Behaviour change every operator will hear, stated first

**An unvisited band now comes up at +20 dB instead of −6 dB on any profile that carries a stuck `rfGain.defaultDb` — 26 dB hotter.** It is receive-only; no transmit path reads this value and nothing here touches the TX gate. But an operator who upgrades and changes nothing will hear a louder band on the first visit to one they have never tuned, and should not have to discover that from a changelog line.

On a profile without the key — which is every profile any shipped build has ever produced — nothing changes: +20 was already what they got. The change is visible exactly on the profiles the issue is about.

Bands the operator has actually visited are untouched. Those live in `rfGain.lnaDbByBand`, they are real operator memory, and this PR preserves them byte for byte.

**This is a maintainer's to accept, not a PR's to assume — DECISION 2 of 2 below.** aethersdr#5829 asked for the consequence to be announced and announcing it is what this section does; whether a 26 dB receive-only change is acceptable to land on an upgrade is a separate judgement, and it is not made here.

## What the key was

`Hl2Backend` persisted `rfGain.defaultDb`, read it back on restore into `m_lnaDefaultDb`, and used that member as the LNA gain an **unvisited** band comes up on. Nothing in the tree ever wrote it. Its value was a closed loop — read from the document, used, written straight back out — so whatever a profile held, it held forever, and no operator action could move it. The reporting station's profile sat at −6 dB permanently, with ten of its twelve stored bands at that value because each inherited it on its first visit.

## 1. Nothing writes it — the search, not the claim

`git log -S` and `git blame` are not trustworthy in a shallow clone, so this is a **symbol search over the whole tree at `origin/main`**, not a history search.

Every occurrence of the member, tree-wide:

```
$ git grep 'm_lnaDefaultDb' origin/main
origin/main:src/core/backends/hl2/Hl2Backend.cpp:            m_lnaDbByBand.value(m_currentBandKey, m_lnaDefaultDb),
origin/main:src/core/backends/hl2/Hl2Backend.cpp:            m_lnaDefaultDb, kLnaGainMinDb, kLnaGainMaxDb);
origin/main:src/core/backends/hl2/Hl2Backend.cpp:    m_lnaDefaultDb = hl2::kLnaDefaultGainDb;
origin/main:src/core/backends/hl2/Hl2Backend.cpp:        m_lnaDefaultDb = qBound(kLnaGainMinDb,
origin/main:src/core/backends/hl2/Hl2Backend.cpp:    QJsonObject rfGain{{QStringLiteral("defaultDb"), m_lnaDefaultDb},
origin/main:src/core/backends/hl2/Hl2Backend.cpp:                           m_lnaDbByBand.value(newBand, m_lnaDefaultDb),
origin/main:src/core/backends/hl2/Hl2Backend.h:    int m_lnaDefaultDb = hl2::kLnaDefaultGainDb;
```

(Line numbers deliberately omitted throughout — a line citation in this file does not survive the next merge, as the citation check on the issue thread demonstrated. Every reference below is by file and symbol.)

Seven sites, and **exactly two are assignments** — both inside `Hl2Backend::applyRestoredState`: the full reset at its head, and the read-from-document further down the same function. One is a persist, in `Hl2Backend::currentOperatingState`. The remaining four are the reads and the in-class initialiser. A case-insensitive `git grep -in 'lnadefault'` over the same tree returns the identical seven lines plus the constant `hl2::kLnaDefaultGainDb` — **no setter, no extension verb, no GUI control, no automation path.**

And the key itself, in `src/`:

```
$ git grep 'defaultDb' origin/main -- 'src/'
origin/main:src/core/backends/hl2/Hl2Backend.cpp:    if (rfGain.contains(QStringLiteral("defaultDb")))
origin/main:src/core/backends/hl2/Hl2Backend.cpp:                                rfGain.value(QStringLiteral("defaultDb")).toInt(),
origin/main:src/core/backends/hl2/Hl2Backend.cpp:    QJsonObject rfGain{{QStringLiteral("defaultDb"), m_lnaDefaultDb},
origin/main:src/core/backends/hl2/Hl2BandMemoryPolicy.h:                             int defaultDb, int minDb, int maxDb)
origin/main:src/core/backends/hl2/Hl2BandMemoryPolicy.h:        out.liveDb = clampDb(minDb, hasStoredEntry ? storedDb : defaultDb, maxDb);
origin/main:src/core/backends/hl2/Hl2BandMemoryPolicy.h:    out.liveDb = defaultDb;
```

The three `Hl2BandMemoryPolicy.h` hits are a **function parameter**, not the document key — `hl2::connectLna` receives whatever the caller passes.

The one other producer in the tree that can touch an `rfGain` sub-object is `RadioStateMemory::storeRtlRfGainPreservingLegacy`. It **refuses any scope whose family is not `rtl`** as its first act, and even within that scope it inserts `gainDb` and nothing else, deliberately preserving unknown siblings. So it can propagate an existing `defaultDb` but cannot mint one, and it cannot reach an HL2 scope at all. That path is ruled out, not assumed.

## 2. What reads it, and the absent-versus-present delta

Three readers, all of them answering the same question — *what gain does a band with no stored entry come up on?*

| reader | what it does | key absent (before) | key present (before) |
|---|---|---|---|
| `Hl2Backend::connectRadio`, the `hl2::connectLna` seed | start band's gain at connect: `m_lnaDbByBand.value(m_currentBandKey, m_lnaDefaultDb)` for the stored value, and `m_lnaDefaultDb` again as the policy's `defaultDb` argument | `kLnaDefaultGainDb`, because the reset at the head of `applyRestoredState` left the member there | the persisted value, clamped to −12..+48 |
| `hl2::connectLna` in `Hl2BandMemoryPolicy.h` | returns that `defaultDb` verbatim for a restored session whose start band has no entry, and for the no-restore/no-param case | `kLnaDefaultGainDb` | the persisted value |
| `Hl2Backend::applyPerBandStateFor` | every band change: `m_lnaDbByBand.value(newBand, m_lnaDefaultDb)` | `kLnaDefaultGainDb` | the persisted value |

So **the key-absent column is already `kLnaDefaultGainDb` everywhere.** That is the whole behaviour delta: this PR makes the key-present column read the same as the key-absent one, which it could not do before because the document won.

## 3. Where `kLnaDefaultGainDb` was already authoritative, and where it was not

It was already the single answer in four places, and this is why the change closes a gap rather than adding a second source of truth:

- `Hl2Backend::m_lnaGainDb`'s in-class initialiser, and its reset at the head of `applyRestoredState` — the **live** gain a virgin backend and a memoryless radio come up on.
- `m_lnaDefaultDb`'s own in-class initialiser in `Hl2Backend.h` — the member being removed here was *itself* initialised to the constant, which is why an absent key already behaved correctly.
- `MetisClient::Params::lnaGainDb` — the wire layer's own default.
- `Hl2DbReference::kDefaultLnaGainDb`, defined as `= kLnaDefaultGainDb`, so the dB reference chain already derives from it.

And the arithmetic in `Hl2Backend.h` and `Hl2Backend.cpp` that explains why the auto-RF-gain loop cannot be armed by default depends on the constant being the shipped baseline: `kLnaDefaultGainDb` (20) sits one dB above `kAutoRfGainMaxBaselineDb` (19). `tests/hl2_gain_split_test.cpp` asserts that relation directly. **That reasoning was already false for any profile carrying a stuck `defaultDb`** — on the reporting station's radio the effective baseline was −6, comfortably inside the trusted region, and the comment's account of what a fresh connect does did not apply. Making the constant authoritative is what makes that documented arithmetic true for everyone.

The one place it was **not** authoritative is the unvisited-band fallback, which is precisely the defect. There is now no member: `Hl2Backend.h` carries a comment where `m_lnaDefaultDb` was, saying why a member there would reintroduce the class.

## 4. The change

In `src/core/backends/hl2/Hl2Backend.cpp`:

- `applyRestoredState` no longer reads the key. The `if (rfGain.contains("defaultDb"))` block is replaced by a comment explaining the closed loop and saying that a document still carrying the key is **not rejected — simply not consulted**, which is what "authoritative" has to mean.
- `currentOperatingState` no longer emits the key. This is the half that heals an existing document: the next snapshot writes the `rfGain` object without it and the stuck value decays out with no migration code.
- The three readers take `hl2::kLnaDefaultGainDb` directly.
- **A document that still carries the key says so in the log**, added in review follow-up. This boundary logs every other value it declines — the pre-aethersdr#4914 CW passband just above, the invalid mic level just below — and this was the one drop that left no trace, on exactly the profiles whose operator hears the change above. It names both numbers, in the mic level's own `toVariant()` form so a hand-edited non-number shows as what it is:

  ```
  HL2: ignoring stale restored LNA default (aethersdr#5829) QVariant(qlonglong, -6) — unvisited bands come up on 20 dB
  ```
- The reset at the head of `applyRestoredState` goes with the member.

In `src/core/backends/hl2/Hl2Backend.h`, `m_lnaDefaultDb` is removed, with a note on the asymmetry against `m_driveDefaultPercent` — that one has a real first-use latch seeded from the first drive the operator sets, and is genuinely per-profile. The two keys sit beside each other in the document and the resemblance is misleading; the issue says so and the header now says so too.

**This also retires a clamp the codebase's own rule rejects.** The old read `qBound`ed the document value and `currentOperatingState` then persisted the clamped result. That is exactly what the AGC threshold restore earlier in the same function refuses to do, in its own comment: *"a value outside it is DROPPED rather than clamped: clamping would invent a setpoint the operator never chose and then persist it back."* Under this change the inconsistency disappears with the key rather than needing its own decision.

### The direction is DECISION 1 of 2, and it has not been ruled on

aethersdr#5829 carries `maintainer-review` and left the choice between a first-use latch and "the constant is authoritative" explicitly to a maintainer. **There is still no maintainer comment on that thread** — the only two commenters are the triage bot and me — so the ruling the issue asked for is being made at review time rather than before it.

I said on the issue that I would not start before a maintainer picked the direction, and then started anyway. That was mine to get wrong; it does not change what is owed here. **A ruling against this direction is not wasted work — say so and I will take the latch instead.** The reasoning that led me to option 2, for a maintainer to accept or overrule:

### The option that was not taken

The issue offered a first-use latch instead, mirroring `m_driveDefaultPercent`. It was rejected because it reproduces the same class in milder form: once latched, `m_driveDefaultPercent` is equally unreachable for the life of the profile. Adding a second frozen-value key to fix the first one is not a fix. It would also have kept the clamp-versus-drop inconsistency alive.

## 5. The three inverted assertions

These are inversions, not additions. Each carries a comment in the test itself saying why, so none of them reads as an assertion bent to make a build pass.

**(a) `tests/hl2_state_restore_test.cpp`, the validation-boundary block.** Was `rfGain.value("defaultDb").toInt() <= 48` — *"a restored LNA default clamps to the AD9866's range"*, fed a document holding 999.
Now `!rfGain.contains("defaultDb")` — *"a restored LNA default is neither consulted nor written back: the key is absent from the capture, not clamped into it."*
**This one had to be inverted rather than deleted or left alone.** Left as a bound it would have passed vacuously: a missing key reads 0 through `toInt()`, and 0 is ≤ 48. The assertion would have gone green while testing nothing.

**(b) `tests/hl2_state_restore_test.cpp`, the radio-swap block.** Was `rfGain.value("defaultDb").toInt() == 20` — *"the LNA default resets to the virgin construction value"*, checking that `applyRestoredState({})` cleared radio A's member before radio B came up.
Now `!rfGain.contains("defaultDb")` — *"radio A's persisted LNA default does not survive the swap, and nothing writes a new one: the key is gone from the capture."*
There is no member to reset any more, so the property that replaces it is that radio A's stuck 6 cannot appear in radio B's capture at all, because no capture emits the key. Ozy311's radio-swap-leak finding from the aethersdr#4619 review is still guarded, by a stronger statement.

**(c) `tests/hl2_gain_restore_test.cpp`, the native-gain loop over {20, 24, 32, 48}.** Was `bandGain(captured, "20m") == gain && ...value("defaultDb").toInt() == gain` — *"capture preserves the stored band and default gain."*
Now `bandGain(captured, "20m") == gain && !...contains("defaultDb")` — *"capture preserves the stored band gain and emits no LNA default."*
**Only the second half is inverted.** The per-band value is real operator memory and nothing about it changed; that half is asserted unchanged. The `defaultDb` half asserted a round trip of a key nothing could write, and is asserted absent instead.

For completeness: `tests/hl2_gain_split_test.cpp` and two other fixtures in `hl2_state_restore_test.cpp` *seed* `defaultDb` but never assert on it. Those fixtures are left seeding it deliberately — a stale key in an input document is now exactly the condition the fix has to survive.

## 6. The test, and why it goes where it goes

Extended `tests/hl2_gain_restore_test.cpp` — an already-registered, socket-free target (`tests/tests.cmake`) that constructs `Hl2Backend` directly against TEST-NET-1 with `boardMaxRx` set, so discovery never opens a socket and no event loop runs.

It is the right home over the other two candidates because **its subject is exactly the question the key answered**: what gain does a restored profile produce. Its `GainSession` helper already gives a synchronous connect, a live-gain read through the health snapshot, and band hops via `setSliceFrequency`. `hl2_state_restore_test.cpp`'s subject is the validation boundary — whether fields are dropped or clamped — and `hl2_gain_split_test.cpp`'s is the auto-gain split and the arming refusal. Both needed assertion edits; neither is where a *resulting gain* belongs.

The assertion, and the second half is the point:

```cpp
check(gainWithNoKey == gainWithStaleKey,
      "a profile carrying a stale rfGain.defaultDb comes up on the same "
      "unvisited-band gain as a profile without the key");
check(gainWithNoKey == hl2::kLnaDefaultGainDb,
      "and that gain is the shipped kLnaDefaultGainDb, which is now the "
      "only answer to what an unvisited band comes up on");
```

Two profiles, identical except for the key — one without it, one carrying **−6, the number the reporting station's profile actually held** — each hopped to 15 m (21.074 MHz), a band neither has an entry for, since `rememberedGain()` stores only 20 m and 40 m. A fix that stopped writing the key but kept reading it would satisfy the first profile and fail the second, which is the failure mode that matters: *the fix is worthless if an old profile keeps steering the radio.* A third check asserts the key is gone from the stale profile's next capture — the healing half.

## 7. Mutation evidence

The mutation is the whole source change reverted while the new test stands, which is the sharpest form available here: `git checkout origin/main -- src/core/backends/hl2/Hl2Backend.cpp src/core/backends/hl2/Hl2Backend.h`, rebuild, run.

**Mutant — `src/` at `origin/main`, tests as in this PR.** The rebuild is real; the compiler output is included so the result cannot be a stale binary:

```
$ git checkout origin/main -- src/core/backends/hl2/Hl2Backend.cpp src/core/backends/hl2/Hl2Backend.h
$ cmake --build build --target hl2_gain_restore_test hl2_state_restore_test
[2/10] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/Hl2Backend.cpp.o
[5/10] Linking CXX static library libaethercore.a
[6/10] Building CXX object CMakeFiles/hl2_gain_restore_test.dir/tests/hl2_gain_restore_test.cpp.o
[7/10] Linking CXX executable hl2_gain_restore_test
[8/10] Building CXX object CMakeFiles/hl2_state_restore_test.dir/tests/hl2_state_restore_test.cpp.o
[9/10] Linking CXX executable hl2_state_restore_test

$ ./build/hl2_gain_restore_test ; echo exit=$?
...
[FAIL] capture preserves the stored band gain and emits no LNA default      (x4, the {20,24,32,48} loop)
[FAIL] a restored stale defaultDb is dropped from the next capture
[FAIL] a profile carrying a stale rfGain.defaultDb comes up on the same unvisited-band gain as a profile without the key
[ OK ] and that gain is the shipped kLnaDefaultGainDb, which is now the only answer to what an unvisited band comes up on
exit=1

$ ./build/hl2_state_restore_test ; echo exit=$?
...
[FAIL] a restored LNA default is neither consulted nor written back: the key is absent from the capture, not clamped into it
[FAIL] radio A's persisted LNA default does not survive the swap, and nothing writes a new one: the key is gone from the capture
exit=1
```

**Read which check stayed green, because it is the diagnosis.** Under the mutant, *"and that gain is the shipped kLnaDefaultGainDb"* still passes — the no-key profile was always right, and a test that only checked that half would have gone green against the unfixed code. The check that dies is the **equality between the two profiles**: the stale one comes up on −6 while the clean one comes up on +20. That is the defect, reproduced by the test, in the only place it lives.

**Fix restored, rebuilt, re-run:**

```
$ cmake --build build --target hl2_gain_restore_test hl2_state_restore_test hl2_gain_split_test
[5/12] Linking CXX static library libaethercore.a
[6/12] Building CXX object CMakeFiles/hl2_gain_restore_test.dir/tests/hl2_gain_restore_test.cpp.o
[7/12] Building CXX object CMakeFiles/hl2_gain_split_test.dir/tests/hl2_gain_split_test.cpp.o
[8/12] Linking CXX executable hl2_gain_restore_test
[9/12] Linking CXX executable hl2_gain_split_test
[10/12] Building CXX object CMakeFiles/hl2_state_restore_test.dir/tests/hl2_state_restore_test.cpp.o
[11/12] Linking CXX executable hl2_state_restore_test

hl2_gain_restore_test  exit=0   48 OK, 0 FAIL
hl2_state_restore_test exit=0   68 OK, 0 FAIL
hl2_gain_split_test    exit=0   70 OK, 0 FAIL
```


## 8. Build and test

Configured with the line this lab mandates, in a fresh worktree branched from `origin/main` (`e944ec49`):

```
cmake -B build -G Ninja \
  -DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local" \
  -DCMAKE_PREFIX_PATH=/Users/stanclaes/Qt/6.8.3/macos \
  -DENABLE_ASR=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo
```

Full build clean: `[3628/3628]`, no warnings introduced.

```
$ ctest --test-dir build --output-on-failure -j4
99% tests passed, 2 tests failed out of 522
Total Test time (real) = 254.68 sec

The following tests FAILED:
	 45 - wdsp_channel_test (Failed)
	390 - backend_seam_affinity_test (Failed)
```

**`HAVE_HIDAPI` is OFF for this build**, read back from this tree's own `CMakeCache.txt` (`HIDAPI_FOUND` is empty, so the `target_compile_definitions(aethercore PUBLIC HAVE_HIDAPI)` branch in `CMakeLists.txt` never ran). That describes the configure line above, not the machine — it matters because it is the opposite of what CI does, so an optional-off break shows up here and nowhere else. Neither failure below is one.

**Neither failure is from this change**, and both were checked rather than assumed:

- **`backend_seam_affinity_test` — pre-existing on `main`.** It fails deterministically, standalone as well as under ctest: *"probe table covers every declared seam signal (51 probed, 52 declared; unprobed: `autoRfGainArmSettled`)"*. That signal is declared in `AutoRfGainControl.h` / `IRadioBackend.h` and emitted from `Hl2Backend`, all on `origin/main`; the test's own probe table has no entry for it. `git diff origin/main` for this branch contains **zero** occurrences of `autoRfGainArmSettled` — this PR neither adds nor removes a seam signal. It is a gap in that probe table and wants its own fix; I have not touched it here and have not filed anything for it.
- **`wdsp_channel_test` — a `-j4` load flake.** Run on its own it **passes**, exit 0, ending `WDSP channel tests passed`. It measures block timing against a starved caller, and four-way parallelism on an 8-core box is exactly the condition that perturbs it.

The four HL2 targets this change actually bears on all pass: `hl2_gain_restore_test` (48 checks, **49 after the review follow-up below**), `hl2_state_restore_test` (68), `hl2_gain_split_test` (70), `hl2_band_memory_test`.

## Review follow-up — second commit

ten9876's pass (no blockers) plus the earlier bot pass. Everything below is in `b3bdfdb`; the two maintainer decisions above are untouched by it.

**The log line is the one that changes behaviour.** Covered in section 4. It fires on every restore of a document that still carries the key, which is exactly the population that hears the gain change, and it names the ignored value alongside the one replacing it so a support log traces back to aethersdr#5829 instead of reading as a radio fault. Observed firing across the existing fixtures at `-6` (the reporting station's own number), `6`, `12`, `20`, `24`, `32`, `48` and `999` — the last being the validation-boundary fixture's hand-edited garbage, which is the case the `toVariant()` form exists for.

**The heal claim is now pinned at the document layer, and it is not a vacuous assertion.** The review asked for one line on the existing `RadioStateMemory` store/load round trip rather than on `currentOperatingState()`. Taken, and the input to that round trip is `rememberedGain()`, which seeds `defaultDb: 20` — so it is a genuinely stale document going to disk and coming back. Replaying this PR's own mutation (`src/` at `origin/main`, tests as here) it is the **first** check to fail:

```
$ git checkout origin/main -- src/core/backends/hl2/Hl2Backend.cpp src/core/backends/hl2/Hl2Backend.h
$ cmake --build build --target hl2_gain_restore_test hl2_state_restore_test
[2/10] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/Hl2Backend.cpp.o
[5/10] Linking CXX static library libaethercore.a
[7/10] Linking CXX executable hl2_gain_restore_test

$ ./build/hl2_gain_restore_test ; echo exit=$?
[FAIL] the stored document itself carries no LNA default after a real RadioStateMemory store/load round trip
[FAIL] capture preserves the stored band gain and emits no LNA default      (x4)
[FAIL] a restored stale defaultDb is dropped from the next capture
[FAIL] a profile carrying a stale rfGain.defaultDb comes up on the same unvisited-band gain as a profile without the key
exit=1
```

It doubles as the standing guard on the thing both reviewers went after and could not break: `RadioStateMemory::store` rebuilds the gated extension wholesale from `state.extension`, which is *why* dropping the key from the capture is sufficient. If that ever became a preserve-unknown-siblings merge — as `storeRtlRfGainPreservingLegacy` already is for its own family — the key would survive on disk forever and this is the check that says so.

**The swap assertion is labelled as documentation, not coverage.** It cannot fail on any post-fix build, so swap-isolation for this field is *gone rather than moved*; the comment now says that in those words, and names what is still live beside it — the per-band map and the drive baseline, which have members to leak and are checked for real.

**Three comment slips and a run-on.** "The read above `qBound()`ed" pointed at a read that no longer exists above; the `currentOperatingState()` twin paragraph had a broken wrap; and `Hl2Backend.h` still documented `applyPerBandStateFor` as falling back to "the restored defaults", which since this PR is true only of the drive half. All three fixed, and the new comment no longer runs into `// ARMED LATER, NOT HERE`, so the `autoEnabled` paragraph reads as its own again.

**Squash subject** now carries `Principle VII.` — validated at the boundary, dropped rather than clamped, which is the load-bearing one here.

Re-run after restoring the fix: `hl2_gain_restore_test` 49 OK / 0 FAIL, `hl2_state_restore_test` 68 / 0, `hl2_gain_split_test` 70 / 0, `hl2_band_memory_test` ALL PASS. `HAVE_HIDAPI` read back OFF from this tree's own `CMakeCache.txt` at run time, not recalled.

## Collision with aethersdr#5862 — textual, not semantic

**Derived at review time, not recalled.** aethersdr#5862 (`hl2/5828-autogain-withdrawal`) is `OPEN` and `MERGEABLE`, and touches the same two files: `src/core/backends/hl2/Hl2Backend.cpp` and `tests/hl2_gain_restore_test.cpp`.

In `tests/hl2_gain_restore_test.cpp` both branches insert at the **same base offset** — its third hunk and my stale-key block both anchor at pre-image line 191, the end of `main`'s body. That is adjacency at one anchor: **keep both**. Its other two hunks (a helper and `rememberedGain`) are nowhere near mine, and the review-follow-up assertion added here lands in the `RadioStateMemory` round-trip block much earlier in the file, inside none of its hunks — so the follow-up adds no new collision.

In `Hl2Backend.cpp` there is no overlap at all: its hunks are in `setLnaAutoOffsetDb` and `setAutoRfGain`; mine are in `connectRadio`'s LNA seed, `applyRestoredState`, `currentOperatingState` and `applyPerBandStateFor`. No shared function, and it reads none of `defaultDb`, `m_lnaDefaultDb` or `kLnaDefaultGainDb`.

**Not rebasing onto it** — that would couple two independent fixes. Whichever lands second takes a one-anchor manual resolution, and I am happy for that to be this one.

## Collision with aethersdr#5867

**Checked, and the hunks do not overlap.** aethersdr#5867 (`feat/hl2-squaresdr2-hardware-options`, @jcmerg) touches `Hl2Backend.cpp` and `Hl2Backend.h`, so this is a same-file pair.

Its `Hl2Backend.cpp` hunks sit at `publishWideState`, `mixReceiverAudio`, two places in `connectRadio`, `applyKeying`, `repushAllFrequencies`, `setTune`, `invokeExtension`, `pushInitialState` and `applyBandFilter`. Mine sit in `connectRadio`'s per-band LNA seed block, `applyRestoredState`, `currentOperatingState` and `applyPerBandStateFor`. The only shared **function** is `connectRadio`, where aethersdr#5867's two hunks are roughly fifty lines either side of the block I change, well outside each other's context. In `Hl2Backend.h`, aethersdr#5867 adds to the includes and to the public section around the hardware-options accessors; I remove one member from the private state block far below. Neither file should conflict textually.

No semantic interaction either: aethersdr#5867 declares which HL2 variant is attached — codec, dither bit, filter board, CL1, ATU — and reads none of `m_lnaDefaultDb`, `rfGain.defaultDb` or `kLnaDefaultGainDb`. Whichever lands first, the second should rebase cleanly. Happy to take the rebase if this is second.

## Not done here

- **No migration code**, and this is deliberate rather than an omission. Dropping the key from `currentOperatingState` heals a document on its next snapshot, so a stuck profile fixes itself without a versioned migration. What that does **not** establish is how the −6 got there: nothing in the tree can write it, and neither the issue nor its review could account for it. "No other stuck profiles exist" is unverified, not established — it just happens that the same change repairs them all if they do.
- `hl2::connectLna`'s `defaultDb` **parameter stays**, receiving the constant — but the reason given here at first ("it keeps the policy header testable in isolation") does not survive checking, so it is withdrawn. Following ten9876's note that the parameter is now vestigial, I enumerated every call site tree-wide: **15 of them, and all 15 pass `hl2::kLnaDefaultGainDb`** — one in `Hl2Backend::connectRadio`, one in `tests/hl2_dbref_test.cpp`, and thirteen in `tests/hl2_band_memory_test.cpp` through a file-local `kDefault` that is defined as the constant. It is not a test seam: no test passes a different value, and tracing the `(haveRestoredState, hasStoredEntry, paramPresent)` triple through each, **not one test reaches a path that reads `defaultDb` at all.** The parameter has no assertion coverage today.
  Two corrections to the note itself, both in its favour. The `.value(key, kLnaDefaultGainDb)` fallback beside it really is unobservable — `storedDb` appears twice in `connectLna` and both are short-circuited by `hasStoredEntry` — and `hasStoredEntry` at that call site is literally `m_lnaDbByBand.contains(m_currentBandKey)`, the same map and key, so the two cannot disagree. But **`defaultDb` itself is vestigial, not dead**: it is live on exactly one reachable production path, `m_haveRestoredState && !hasStored && !paramPresent` — a restored profile whose start band has no entry. That is the path this PR's new test covers, and the path no unit test covered before.
  **Left for a follow-up rather than taken here**, because it is a seam change and this PR is a bug fix. Worth recording for whoever takes it: the final `out.liveDb = defaultDb;` return is the only one that does not clamp, so inlining the constant makes an out-of-range default structurally impossible; and the superficially similar `m_lnaDbByBand.value(newBand, hl2::kLnaDefaultGainDb)` in `applyPerBandStateFor` is **not** dead — it has no `hasStoredEntry` companion, the `.value()` default is the actual mechanism, and a cleanup sweep must not take it along.


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

Squashed-from: aethersdr#5869

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
## What this fixes

`band_zoom=`/`segment_zoom=` are FlexLib wire text. Only a Flex radio's command plane answers them — `RadioModel::sendCmd` drops the write at `hasCommandPlane()` and emits `commandDropped()` — so on a Hermes-Lite 2 the control moves and nothing reaches the radio.

`SpectrumWidget::setBandSegmentZoomAvailable()` closed that **for the two on-screen buttons only**. Counted at the surface an operator can actually press, there are **seven**, and the buttons were the only gated one:

| # | Surface | Symbol | Gated on `main`? |
|---|---|---|---|
| 1 | The two waterfall-corner `QPushButton`s | `MainWindow::wirePanadapter` | **yes** |
| 2 | `band_zoom`/`segment_zoom` shortcut actions | `MainWindow::registerShortcutActions` | no |
| 3 | MIDI `global.bandZoom` / `global.segmentZoom` | `MainWindow::registerMidiParams` (its `fireShortcut` lambda) | no |
| 4 | FlexControl button table | `MainWindow::handleFlexControlButton` | no |
| 5 | RC28 / Stream Deck / T-Mate2 buttons | `MainWindow::dispatchHidAction` | no |
| 6 | FlexControl / HID / Ulanzi wheel and dial | `MainWindow::applyFlexControlWheelAction` | no |
| 7 | Automation bridge `shortcut` verb | `AutomationServer::doShortcut` | no |

Row 5 is worth calling out because it is easy to fold into rows 4 or 6 and it is neither: `dispatchHidAction` carries its own `if/else` chain, wired from `HidEncoderManager` rather than `FlexControlManager` — and it is **live by default**. `tmate2KeyDefaultAction` returns `"BandZoom"` for key 3, so a stock T-Mate2 reaches this path with no configuration at all.

Six of the seven fan into just two methods — `MainWindow::togglePanZoomModeForPan` and `MainWindow::setPanZoomMode` — which checked `isConnected()` and a pan id and never the capability. Gating those two closes all six without an edit per surface.

The fix is a header-only predicate, `src/gui/PanZoomModeGate.h`, read by both command paths **and by both button-availability computations** (`MainWindow::onConnectionStateChanged` and the `PanadapterStack::panAdded` lambda in `MainWindow::createCentralWidget`), so the enable state and the write decision cannot drift apart. Its middle rung is a **declared capability** — `RadioCapabilities::panZoomModes`, a per-feature record `FlexBackend::capabilities()` engages and every other backend sets to `std::nullopt` explicitly — read off `backendCapabilities()` exactly as `hasDdcPanEdgeRolloff` is read one line away. No family string is involved, and this PR **removes** two `usesFlexCommandPlane()` call sites (including the pre-existing button one) rather than adding any.

## And the refusal says so

A capability gate refuses **before** the send, so `RadioModel::sendCmd` is never reached and `commandDropped()` never fires. `MainWindow::showUnsupportedControlNotice()` carries that rule in its own words: *"a control converted from 'drops silently' to 'refuses' would otherwise have taken the operator's only feedback away with it … A gate calls this so a refused control says exactly what a dropped one says."* The two gates already in the tree — `split_toggle` and the `VfoWidget::splitToggled` lambda — pair `qCWarning(lcDevices)` with that notice, and both gates here now do the same:

```
WRN aether.devices: band zoom ignored: this radio declares no band/segment zoom
```

plus the status bar: *"This radio doesn't support that control — nothing was sent to the radio. Further unsupported controls are logged."*

**Only the `NotDeclared` rung announces.** `NotConnected` and `NoPan` replace early returns that were silent on `main` and stay silent, so this restores exactly what the gate took away and nothing more. `PanZoomModeRefusal` exists so a caller can tell those apart, and now two production callers do.

## This is NOT a safety finding

The only payload either path can emit is `display pan set <panId> band_zoom=<0|1>` (or `segment_zoom=`), handed to `RadioModel::sendCommand`. It is display-domain text selecting what span the panadapter draws. It reaches no TX verb and no keying path, and `ShortcutManager::Action::keysTx` is `false` at both registration sites in `MainWindow::registerShortcutActions` — contrast the CW momentary registrations just above them, which pass `/*keysTx=*/true` explicitly. **A dead control, not a hole.**

## Correction to this lab's FIND-52

FIND-52 records these as ungated shortcut sites, and gets two things wrong:

1. It says the predicate exists **"for the right-click menu"**. It does not. `bandZoomRequested`/`segmentZoomRequested` are emitted from exactly one symbol — the `SpectrumWidget` constructor's two `QPushButton::clicked` lambdas. There is no right-click path; `SpectrumWidget`'s context menu contains no band/segment-zoom action.
2. It says **"only the shortcut path bypasses it"**. Six surfaces do.

Both errors have a traceable origin, and it is not the lab's: **two comments on `main` assert the right-click menu**, and the docstring on `togglePanZoomModeForPan` lists four surfaces where there are seven. FIND-52 copied a wrong comment faithfully. This PR corrects both comments, since a PR that leaves them standing would be reproducing the exact failure it is describing.

## The capability record, and a claim this PR first got wrong

An earlier revision of this body said a capability route *"trips the ratchet"* and used that to justify `RadioModel::usesFlexCommandPlane()` — a literal `family() == "flex"` test — as the predicate's middle rung. **That was false, and `AGENTS.md`'s standing aethersdr#5554 notice forbids adding one of those above the seam.**

Checked against `tools/check_capability_records.py` rather than assumed. Its docstring and its error text both say a new capability lands as a per-feature record (`std::optional<FeatureRecord>`: engaged = present, fields = shape), **not another bool** — and `direct_bool_fields()` counts only DIRECT `bool` members of `RadioCapabilities`. A nested struct's members never reach the field scan (the depth filter appends a line only at depth 1), and the `std::optional<...>` member itself fails the anchored `^\s*bool\s+` match on its type token. **A record cannot move the count**, and the gate reports 71 at the frozen 71 with the record in place.

What landed instead:

- `RadioCapabilities::PanZoomModes`, `std::optional`, engaged by `FlexBackend::capabilities()` with the verb that carries both modes, and explicitly `std::nullopt` in `hl2`/`icom`/`anan`/`sim`/`rtl` per the header's own ADDING A FIELD rule — then recorded in `docs/architecture/radio-capabilities-map.md`, as that rule also requires. The record carries `setCommand` so the declaration names the verb behind it, which is the other half of the same aethersdr#5554 notice (*"no new capability declared without a verb behind it"*).
- The predicate reads `panZoomModes.has_value()` off `backendCapabilities()`.
- A second family that gains the verb engages the record and needs no edit above the seam at all.

## Why `src/gui/` and not `src/models/`

`PanZoomModeGate.h` is UI policy — what the UI may send, and whether a button is grey. It has zero includes, no Qt type and no engine type, and its only consumers are `MainWindow` and one test. `src/gui/` already holds exactly this shape (`DStarAvailabilityGate.h`, `DvkAvailabilityGate.h`, `DaxRestorePolicy.h`, `BandRecallSliceSelectionPolicy.h`), and tests include those directly without linking the GUI.

So it adds **no row** to the aetherd engine/UI touchpoint manifest: the burndown stays at **229**, and there is nothing to convert later. An earlier commit on this branch tagged it `mixed(flex)` in `docs/architecture/aetherd-touchpoint-tags.json`; that commit is reverted here, because with the header on the UI side the row it was arguing about does not exist.

## Mutation testing — my own output, this tree

`hl2_pan_limits_declaration_test`, baseline green. Every mutant was written as **fresh bytes and then `touch`ed**, and the `Building CXX`/`Linking` lines were read to confirm the object really recompiled — this branch's earlier harness restored with `shutil.move`, which preserves the original mtime, so ninja judged the stale object current and the first "restored" run reported red on a binary still carrying the mutant.

**Mutant A — the predicate stops consulting the capability** (drop the `NotDeclared` rung):
```
RECOMPILE> [1/3] Building CXX object CMakeFiles/hl2_pan_limits_declaration_test.dir/tests/hl2_pan_limits_declaration_test.cpp.o
RECOMPILE> [2/3] Linking CXX executable hl2_pan_limits_declaration_test
TEST EXIT = 1  (RED - mutant caught)
 > [FAIL] REFUSED on this radio even connected with a pan: band/segment zoom is Flex wire text and this backend declares no band/segment zoom
 > [FAIL] and it is refused for the CAPABILITY, not for a missing pan or a missing connection -- the reason a caller would show the operator, and the ONLY rung the call sites announce with showUnsupportedControlNotice()
 > [FAIL] the B/S buttons are disabled on this radio for the same reason
 > 3 failure(s)
-- restored: recompiled, TEST EXIT = 0 (green)
```

**Mutant B — button availability drifts off the shared predicate** (`bandSegmentZoomAvailable` returns `connected`):
```
TEST EXIT = 1  (RED - mutant caught)
 > [FAIL] the B/S buttons are disabled on this radio for the same reason
 > [FAIL] button availability and write admissibility agree on every (connected, declared) pair
 > 2 failure(s)
-- restored: recompiled, TEST EXIT = 0 (green)
```

**Mutant C — ENGAGE the record in `Hl2Backend::capabilities()`.** The one that matters for the new route: it proves the test reads *this radio's declaration* rather than a retyped literal `false`.
```
RECOMPILE> [2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/Hl2Backend.cpp.o
RECOMPILE> [3/5] Linking CXX static library libaethercore.a
TEST EXIT = 1  (RED - mutant caught)
 > [FAIL] this radio declares NO band/segment zoom -- absent, not a false bool, so 'nobody set it' and 'considered no' are not the same record
 > [FAIL] REFUSED on this radio even connected with a pan: ...
 > [FAIL] and it is refused for the CAPABILITY, not for a missing pan or a missing connection ...
 > [FAIL] the B/S buttons are disabled on this radio for the same reason
 > 4 failure(s)
-- restored byte-identical, TEST EXIT = 0 (green)
```

**What the test still cannot see**, stated in the file itself: **no registered test target links `MainWindow*.cpp`**, so deleting the gate at either call site leaves every assertion green. That is why the call sites are demonstrated on a running app below rather than asserted here.

## Driven on a running backend, not reasoned

Built at this head and launched offscreen with a fresh `AETHER_SETTINGS_DIR`, `AETHER_AUTOMATION=1`, `AETHER_AUTOMATION_NO_TX=1`, then connected over the automation bridge to `DEMO-0001` — family **`sim`**, which is the case a bare `hasCommandPlane()` test gets wrong and which now declares `panZoomModes = std::nullopt` explicitly.

- `dumpTree`: `panZoomBandBtn` and `panZoomSegBtn` both `enabled: false` with their tooltip.
- Four presses (`shortcut band_zoom` / `shortcut segment_zoom`, `fired: true` each), and the app log carries **four** lines where the pre-fix head carried none:
```
[03:42:03.656] WRN aether.devices: band zoom ignored: this radio declares no band/segment zoom
[03:42:03.700] WRN aether.devices: segment zoom ignored: this radio declares no band/segment zoom
[03:42:03.744] WRN aether.devices: band zoom ignored: this radio declares no band/segment zoom
[03:42:03.792] WRN aether.devices: segment zoom ignored: this radio declares no band/segment zoom
```
- Reconnected (the notice latch resets on the connect edge), fired once, and `dumpTree`'s `QStatusBar` node reads:
```
"statusMessage": "This radio doesn't support that control — nothing was sent to the radio. Further unsupported controls are logged."
```

## Gates — all four, both sides

`main` = `e944ec49`, this branch, run in-tree:

| gate | `main` | this branch |
|---|---|---|
| `check_engine_boundary.py --strict` | **102** findings, 0 blocking, rc 0 | **102**, 0 blocking, rc 0 |
| `gen_touchpoint_manifest.py --check` | `manifest up to date`, **229** touchpoints | identical, **229** |
| `check_capability_records.py --strict` | `71 boolean(s), at the frozen 71 — ok (shrink only)` | identical, **71** |
| `check_command_plane.py --strict` | **413** raw Flex commands above the seam, baseline 413, 0 blocking | identical, **413** |

Nothing grew, including with the new capability record in place — which is the point of the record rather than a bool.

## Suite

`HAVE_HIDAPI` is **off** in this cache (no `HAVE_HIDAPI` define on the configure line) — this machine is the no-hidapi configuration.

`ctest -j4` on a full build of this head: **521/522 passed, 1 failed, 6 skipped**, 247.27 s.

- `backend_seam_affinity_test` — **pre-existing, not this PR, and already repaired on `main`**: `[FAIL] probe table covers every declared seam signal (51 probed, 52 declared; unprobed: autoRfGainArmSettled)`. Introduced by merged aethersdr#5825 (`03ddf480`); repaired by aethersdr#5865 (`9ed3e5d7`, `tests/SeamThreadAffinityProbe.h`), which merged to `main` **after** this branch's base `e944ec49` — so it is absent here by base, not by omission. This PR touches neither file.
- `vkamp_connection_test`, the `-j4` flake reported earlier, **passed** this run.

Base freshness: `origin/main` is now `a0e8509c`, six commits on from `e944ec49`, and `git diff --name-only e944ec4..origin/main` over this PR's paths is empty. Not rebased for that reason.

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

Squashed-from: aethersdr#5875

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…s the audio

WdspChannel can now set the local FM detector's assumed deviation. The default stays 5000 Hz, which is what create_rxa already builds the stage with, so current FM audio is unchanged.

The value is an inverse gain inside WDSP (again = rate / (deviation * 2π)), not a bandwidth. It is stored on Config and pushed again from open(), so a channel rebuild does not snap back to 5 kHz. Values outside 100 Hz–100 kHz are refused, because a tiny positive deviation sends that gain to infinity. Transmit configs are not failed for it. Nothing outside the test calls the setter yet.

Squashed-from: aethersdr#5872

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…nothing testing it

Cover the Hl2Spectrum FFTW planner lock that aethersdr#5424 landed with no test.

FFTW's planner is process-global and not thread-safe. Hl2Backend builds an
Hl2Spectrum on its worker while WdspChannel::open() plans, allocates, and
frees through FFTW on another thread. aethersdr#5424 takes WdspChannel::fftwSetupLock()
in both the constructor and the destructor. This PR does not change that
production behavior.

hl2_spectrum_test now holds the lock on one thread and checks that construction
and destruction wait. An unlocked construct+destroy is timed first and required
to finish well under the wait, so a slow constructor cannot look blocked.
The destructor has its own assertion: the edge TSan named was a free against
an allocation, and a constructor-only lock would leave that half open.

This is one new deterministic test toward aethersdr#5443. It does not restore
radiomodel_pan_id_mapping_test and does not close the issue. It shows the two
call sites take the shared lock. It does not reproduce the race.

Squashed-from: aethersdr#5876

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…n the air — and correct the comment claiming the radio's FIFO telemetry could see it (S3 3.3)

> **Revised again after @ten9876's review.** Nine items, all acted on. Two were real
> defects in the arm chain rather than prose nits, and **one of them re-introduced the very
> fault this PR exists to record**. A third showed the floor this body published was **half
> the floor** — the bigger half is at key-down, the header already named it, and the body did
> not carry it across. All are measured and pinned below, with the mutation runs that make
> them fail.
>
> *(Previous note, from the first revision, still stands: three corrections were folded in,
> one of them a fix to the tests rather than to the prose — a negative control had been set up
> to avoid the condition it existed to probe.)*

## What these counters are — stated narrowly, because the generous reading is wrong

They count the **host end of the client's queue** (`MetisClient::m_txIq`), incremented **before the socket write**. They are not a reading of the radio's DSIQ FIFO and they never observe it. "TX IQ FIFO" in the old title did not say whose; it does now.

## The gateware FIFO rows cannot see a host starvation

`Hl2Telemetry::txFifoFillMsbs` and `::txFifoRecovery` report the **gateware's** DSIQ FIFO, fed by **EP2 packet arrivals**. A host-side starvation drops no packet and shortens none:

* `MetisClient::onEp2PacerTick` emits a full-size EP2 frame off the wall clock and consults `m_txIq` nowhere —
  ```cpp
  const quint64 due = static_cast<quint64>(elapsedUs / m_ep2IntervalUs);
  while (m_ep2Sent < due && burst < kEp2MaxBurstPerTick) {
      sendControlPacket();
  ```
* `ep2WriteTxIq` zero-fills every slot the supplied span does not reach.

The radio is handed an unbroken 48 kHz sample stream whose *content* is partly silence, and **its fill reading is identical either way**. `Hl2Backend::publishTelemetry` asserted the opposite on `origin/main`:

> ```
> // TX IQ FIFO — a queue-fed transmission can starve the radio's buffer in a
> // way a per-packet generated tone never can, so this is what distinguishes
> // "the audio is wrong" from "the audio never arrived".
> ```

It cannot, and that comment is corrected here — a healthy reading there was being taken as evidence against a defect the reading is structurally blind to.

(While correcting it: the 0–127 figure is the **top seven bits of the fill level**, not the depth. The DSIQ FIFO is 16384 deep on this board; `MetisProtocol.h` declines to convert it to samples.)

### And the blindness is symmetric

This is the easier half to forget, and the old body only stated one direction. A datagram lost on the wire, a genuine overrun of the gateware's DSIQ FIFO, or anything else after this client hands bytes to the socket is invisible to **these counters** exactly as a host starvation is invisible to the gateware rows. The two instruments are **disjoint, not overlapping** — neither substitutes for the other, and a fault occurring *between* the socket write and the FIFO is invisible to **both**. **"The counters are clean" is not evidence that the transmission was.**

## The floor — these do NOT answer "was this over clean"

The error is a **false positive**, not a miss. **The floor has two halves, at the two ends
of the over, and the first version of this body named only the smaller one** — @ten9876
caught that, and the header had already written down the half the body omitted. Both are
now measured and pinned.

### The tail, at key-up

Real audio does not end on a packet boundary, so the last packet of a perfectly healthy
over is short and **is counted**: at the point the count is taken, *"the queue is short"*
and *"the over is over"* are indistinguishable. Measured:

```
PROBE  key-up tail: 500 samples (500 = 3*126 + 122), drained in 4 packets ->
       underflowPackets=1 underflowSamples=4
```

### The pre-roll, at key-down — and it is the bigger half

`MetisClient.h` named this shape and the body did not carry it into the floor: *"a burst of
whole-packet underflows at key-down is the missing pre-roll (the queue has not been primed
yet)"*.

`Hl2Backend::applyKeying` posts `setMox` immediately. The first block of transmit IQ cannot
arrive until `Hl2TxDsp::processAudioBlock` has accumulated a whole `Config::dspBlockSize`
block at `Config::inputSampleRateHz` — it returns early while `m_inBuffer` is short, and
`m_inBuffer` is empty at key-down twice over (`applyKeying` posts `reset()` on unkey, and a
new over carries a new `TxCoordinator` context which makes `processAudioBlock` reset again).
**Nothing primes `m_txIq` before MOX.** Every EP2 tick in that window takes the empty-queue
arm — now counted, by design — and counts a **whole** packet. Measured:

```
PROBE  key-down pre-roll: one Hl2TxDsp block is 512 samples at 24000 Hz = 21.33 ms;
       an EP2 packet is 126 samples at 48000 Hz = 2.625 ms ->
       8 whole-packet underflows, 1008 samples of substituted silence,
       21.00 ms of every clean over
```

**So every clean over OPENS with eight whole-packet underflows and CLOSES with the short
tail** — 1008 samples against at most 125. The pre-roll is worth an order of magnitude more
substituted silence than the tail the body used to quote as the whole floor, and the
`THE FLOOR` test measured only the tail because it pre-filled the queue. It is now
`THE FLOOR, TAIL HALF`, beside a new `THE FLOOR, PRE-ROLL HALF`.

**The pre-roll count is derived, not typed.** `dspBlockSize` and `inputSampleRateHz` are
read off the real `Hl2TxDsp::Config`, and the EP2 rate off `MetisClient::kEp2AudioRateHz`
through the existing `MetisClientTestAccess` friend — so changing the DSP block size or
either rate moves the expectation with the code instead of leaving a number that agrees
with itself.

**8 is a floor from configured constants, and the live figure is higher.** The pacer's phase
at key-down is free-running (±1 packet), the mic poll adds up to ~5 ms, and the 48→24 kHz
SRC in `TxVoiceProcessor` adds its own group delay. On hardware expect 9–12. The test pins
the deterministic part, because that is the part that can regress silently.

Neither half is a defect in this client: they are what an unprimed, wall-clock-paced FIFO
reads on a **healthy** transmission, and they are the reason these counters are a session
budget and not a per-over verdict. `txUnderflowPackets` is the more polluted of the two —
its floor is a fixed per-over cost that does not grow with the over's length, while a real
starvation adds 126 per packet for as long as it lasts.

Suppressing either half needs the count deferred and attributed against the key edges. That
is a design change, not a correction, and is **deliberately not made here**; the honest
floor is a perfectly reportable state meanwhile.

## The fault is observed, not theoretical

`FINDINGS.md` FIND-23 carries **EP2 wire captures** of live speech. Each leg holds exactly one starvation or none: **48, 18 and 64 trailing zeros**, all pure-trailing; the fourth leg had none. Windows with no starvation measure **78.6–78.8 dB** opposite-sideband suppression (0.15 dB spread across four legs and two ALC conditions); windows with one measure **33.7–35.2 dB**. Synthetic zeroing in the clean windows reproduces it: one sample takes 78.99 dB to 48.97 dB, the observed 48 take it to 30.89 dB.

**Provenance, because these will otherwise read as on-air figures.** That run (d77) was captured off the wire, but the peer was **`hpsdrsim` on loopback, not a radio** — the run record classes it `loopback`, tcpdump on `lo0`. What the captures establish is that the starvation **happens in ordinary speech** and what it costs **within one capture**; not the on-air magnitude. Separately, 78.7 dB is **not** the modulator's own suppression — d87 measures 87.15–87.19 dB on a steady tone, matching `Hl2TxDsp::designFilters`.

## What was unrecorded

```cpp
while (m_txIq.size() > kTxQueueMax)
    m_txIq.pop_front();          // kTxQueueMax = 12000, ~a quarter second at 48 kHz
```
```cpp
} else if (keyed && !m_txIq.empty()) {
    const std::size_t n = std::min<std::size_t>(kTxSamplesPerPacket, m_txIq.size());
    ...
    ep2WriteTxIq(pkt, block);   // a short block leaves the rest as silence
```

An **empty** queue did not even enter that branch — the chain fell off the end and a whole packet of silence went on the air recorded by nothing. `MetisClient::txQueueDepth()` has **no production caller**: a repo-wide grep finds its own definition and eight call sites, all in `tests/hl2_tx_gate_test.cpp`.

**The wire is unchanged byte for byte.** The empty-queue case now enters the branch to be counted and writes nothing — `ep2Packet` has already zero-filled the payload with exactly the bytes `ep2WriteTxIq` would produce for an empty span, and `ep2WriteTxIq` never touches the EADDR slot either way.

## The tests assert the wire, not only the counter

`check(txUnderflowSamples() == kTxSamplesPerPacket - kShort)` has the test and the production code computing the same difference from the same input — both sides agreeing proves only that the arithmetic ran twice. A new helper, `ep2IqShape()`, reads the *other* thing off the same packet: how many sample slots carry IQ and where the silence begins — the **trailing-zero signature** FIND-23 measured. It is asserted on the short packet, on the tail packet of the clean over, and negatively on every full packet.

## What @ten9876's review changed in the CODE, not only in the prose

### Two real defects in the arm chain

**A NaN tone amplitude routed queued audio into the UNKEYED arm, and transmitted silence
uncounted.** `setTxTestTone`'s clamp is

```cpp
m_toneAmp = amplitude < 0.0 ? 0.0 : (amplitude > 1.0 ? 1.0 : amplitude);
```

and **both comparisons are false for NaN**, so NaN reaches `m_toneAmp` intact. The queued-IQ
arm as filed read `keyed && !m_cwMode && m_toneAmp <= 0.0`, which NaN also fails — as does
the tone arm's `m_toneAmp > 0.0`. A keyed client with a full block of audio waiting therefore
fell past **both** into the unkeyed backstop: a whole packet of silence on the air, with the
audio still in the queue and **nothing counted**. That is the exact fault this PR exists to
record, reintroduced by the guard written to describe it. The arm is now just `keyed`, which
is exact because the two arms above already consume every keyed input with CW or a positive
tone amplitude. Taken as filed from the review's inline suggestion.

**The CW and test-tone arms never ended the run.** `MetisClient.h` claims every exit from the
queued-IQ path reports; those two did not, so an episode in progress when the operator hit
TUNE while keyed sat open until key-up and was then logged against the wrong moment.
`setCwKeyDown` likewise cleared the queue without touching it. All three report now.

### `reportTxUnderflowRun()` now has an assertion behind it

Correct, and it was the fifth "test that agrees with itself" in this file's history: the run
members are private, nothing read the log, and emptying the function left every new check
green. The seam is a `qInstallMessageHandler` capture, as in `tests/tci_server_review_test.cpp`,
with `aether.hl2.tx.debug=true` turned on explicitly and the sink filtered to the starvation
lines — that category is shared with `Hl2Backend`'s gateware telemetry on purpose, so the
filter has to be on the message and not on the category. **M8 below is the proof.**

### The run coalescing collapsed CONTIGUOUS starvation only

Every full packet flushed the run, so the alternating short/full pattern this header itself
names as the clock-drift shape logged once per **pair** — up to ~190 lines a second at the
pacer's ~381 frames/s, which is the per-frame flood the coalescing exists to prevent, and the
failure mode of aethersdr#5813 where a high-rate burst flushed the support-log tail it was meant to
explain.

An episode now ends when the queue has **kept up** for about 100 ms —
`kUnderflowRunQuietPackets`, **derived** from `kEp2AudioRateHz` rather than chosen as a packet
count — plus the unconditional flushes at every exit from the queued-IQ path. Within an over
that is one line per episode; across overs the unkey backstop still attributes the line to the
over it happened in.

### "Session totals" were process totals, and now say so

`MetisClient` is constructed once and nothing resets the three counters — not `start()`, not a
link edge, not `flushTxIq()`. They accumulate across reconnects and across radios.

**Relabelled rather than reset, and the reason is in the header.** The question they answer is
*"how much substituted silence has this PROCESS put on the air"*, and a reset on a link edge
would erase exactly the slow accumulation they exist to show. An operator who wants a per-link
figure subtracts two readings. A reset would also be a new state-machine edge needing its own
test, for a counter whose entire value is the long integral.

### The two other paths that drop queued TX samples

`queueTxIq`'s context-mismatch clear and `clearCwKeying()` both drop queued audio and neither
feeds the overflow counter. Agreed, and it now says so at both sites: the overflow counter
records samples the FIFO **threw away while trying to carry them** — a discontinuity in the
middle of an envelope that nothing else records. These two are **abandons**, audio the caller
has decided must not be transmitted at all; feeding them to the same counter would make an
operator's own mode change read as a fault. The *run* is ended at both, because a starvation in
progress really did stop there.

### `aether.hl2.tx` had three category objects; it has two

`lcHl2Tx` moved to `LogManager.h`/`.cpp` beside every other category in this codebase —
declared **verbatim, with no default-severity argument**, so it keeps exactly the default it
had in `Hl2Backend.cpp`. `MetisClient.cpp` already included `LogManager.h` for `lcHl2`, so it
now uses the shared object instead of a third file-static one on the same string.
`Hl2TxDsp.cpp`'s `lcTxMod` is still its own object; folding that one in is a separate change
and is not made here.

`LogManager`'s registry row described the category as *"TX IQ FIFO depth with
underflow/overflow flags"* — the gateware rows — while it now also carries the host-queue lines
whose whole point is that they are **not** that FIFO. The row now names both instruments and
says which is which. (This is a file the PR should have touched and did not.)

### The accessors' thread contract is stated at the accessors

**"THREAD CONTRACT: I/O THREAD ONLY"**, on the accessors themselves rather than only beside the
logging category, so the next reader does not wire them to `healthSnapshot()`.

## Proved by mutation — my own output, re-run on this tree

Each cycle rebuilds, **reads the `Building CXX` line back to confirm the mutated TU recompiled**, runs, then restores by *rewriting* the file (mtime moves, so ninja cannot skip the restore rebuild) and reruns.

**M1 — count the samples SUPPLIED instead of the silence SUBSTITUTED:**
```
FAIL: and the floor in samples is the packet remainder, here 4
FAIL: underflow counts the SILENCE substituted, not the samples supplied
FAIL: an empty queue counts a WHOLE packet of substituted silence
```

**M2 — count on FULL packets as well as short ones** (`n <=` where the code has `n <`):
```
FAIL: THE FLOOR: a CLEAN over that does not end on a packet boundary still counts one underflow -- this counter cannot report 'this over was clean'
FAIL: NEGATIVE CONTROL: a queue drained on an exact packet boundary counts no underflow
FAIL: the full packet ahead of the short one is not counted
FAIL: one short packet, one underflow packet
```

**M3 — double-count overflow:**
```
FAIL: overflow counts exactly the samples dropped past the bound
```

**M6 — WIRE-ONLY: drop the short block, so a starved packet goes out fully silent while every counter stays correct.** This is the one that shows `ep2IqShape()` earns its place — it is killed by **exactly and only the three wire assertions**, with no counter assertion firing:
```
FAIL: every packet of a fed over carries IQ
FAIL: the tail packet's WIRE shape is 122 IQ slots then trailing silence
FAIL: the short packet's WIRE shape is kShort IQ slots then trailing silence
```

**M7 — naively exempt the end-of-over tail** (count only a whole empty packet), which the pinned floor catches:
```
FAIL: THE FLOOR: a CLEAN over that does not end on a packet boundary still counts one underflow -- this counter cannot report 'this over was clean'
FAIL: and the floor in samples is the packet remainder, here 4
FAIL: one short packet, one underflow packet
FAIL: underflow counts the SILENCE substituted, not the samples supplied
```

**M8 — EMPTY `reportTxUnderflowRun()`'s body.** The mutation @ten9876 asked for, and the one
that had nothing to catch it before this revision. Six assertions go red:
```
FAIL: ONE log line for the whole key-down burst, not one per EP2 packet
FAIL: and the line names the length of the episode it is reporting
FAIL: the totals are labelled for what they are -- nothing resets them, not start(), not a link edge, not flushTxIq()
FAIL: ten short packets spread across twenty are ONE episode and ONE line
FAIL: TUNE while keyed ends the episode WHERE IT HAPPENED, not at key-up
FAIL: the flush that ends an over also ends the episode
```

**M9 — PUT THE NaN HOLE BACK**, i.e. the arm condition exactly as this PR was filed
(`keyed && !m_cwMode && m_toneAmp <= 0.0`). This is the one that shows the finding was a
defect and not a style point — under it, a keyed client with a full block queued transmits
silence and the counters stay at zero:
```
FAIL: a NaN tone amplitude must not send queued audio to the unkeyed arm -- that transmitted silence UNCOUNTED with a full block waiting
FAIL: the queued block was consumed by the queued-IQ arm, as for any other amplitude
```
Note which assertion does *not* fire: `txUnderflowPackets() == 0` still passes, because the
silence went out through the arm that counts nothing. That is the fault, exactly.

**M10 — FLUSH THE RUN ON THE NEXT FULL PACKET**, i.e. the coalescing as filed:
```
FAIL: a SINGLE full packet does not end an episode -- alternating short/full must not log once per pair
FAIL: ten short packets spread across twenty are ONE episode and ONE line
```

**M11 — DROP THE REPORTER FROM THE TONE ARM ONLY**, to show the two new call sites are
separately load-bearing rather than one belt-and-braces pair:
```
FAIL: TUNE while keyed ends the episode WHERE IT HAPPENED, not at key-up
```

All nine restored green, with the restore rebuild confirmed recompiling `MetisClient.cpp.o` in
both directions on every cycle. Restores are a **rewrite** of the original text, never a file
move, so the mtime moves forward and ninja cannot skip the restore rebuild. The overflow test reads `kTxQueueMax` through the existing `MetisClientTestAccess` friend rather than re-typing `12000`.

The coalesced log line, as it actually came out of the run — one line per starvation *episode*, not one per EP2 frame at ~380/s and, since this revision, not one per short/full *pair* either:

```
aether.hl2.tx: HL2 tx fifo: HOST queue starved for 1 EP2 packet(s), 126 sample(s) of
substituted silence on the air (totals SINCE PROCESS START, not since this link came up
and not per radio: underflow 1 packets / 126 samples, overflow 0 samples). This is the
CLIENT's queue, not the radio's DSIQ FIFO: the EP2 frame went out full size on schedule,
so the gateware's fill and pacingFault readings are unaffected by it and cannot be used
to rule it out.
```

## IT COUNTS; IT DOES NOT REPAIR

The truncation is exactly what it was. Giving the FIFO a floor — a pre-roll, a target depth, or holding and ramping the last sample instead of stepping to zero — **changes what goes on the air**, and is a separate decision from being able to see the fault at all. Deliberately not in this PR.

## Build and tests

* `-G Ninja`, `RelWithDebInfo`, `-DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local"`, `-DCMAKE_PREFIX_PATH=.../Qt/6.8.3/macos`, `-DENABLE_ASR=OFF`.
* `AETHER_HL2_TX_TXA=1`; **`HAVE_HIDAPI` absent from this build's `build.ninja`** — no hidapi on this host.
* `ctest -R '^(hl2_|control_receive_test)'` — **49/49 passed, 0 failed** (223.7 s), re-run on this revision. That is the subset built, not the whole suite.
* **`backend_seam_affinity_test` is no longer a caveat.** aethersdr#5865 merged at 00:12Z and the Full
  test suite on `main`'s head (`a0e8509c`) reports `400/532 Test aethersdr#400: backend_seam_affinity_test ... Passed`
  and `100% tests passed, 0 tests failed out of 532`. The earlier note in this body is
  withdrawn — it was true when filed and is not true now. (One check on main's head is red,
  `check-system-libs-linux`, and it is an apt dependency conflict on Debian sid, not a test.)
* This branch is `behind_by 6` on `main`; none of the six commits touch `MetisClient.cpp`,
  `MetisClient.h`, `Hl2Backend.cpp`, `LogManager.cpp`, `LogManager.h` or `hl2_tx_gate_test.cpp`.

## Collisions

**[aethersdr#5867](aethersdr#5867 (`jcmerg`) touches
`MetisClient.cpp`, `MetisClient.h` **and** `Hl2Backend.cpp` — a three-way overlap, and the only
one of these not authored from this station. Whichever of us lands second rebases.

**[aethersdr#5882](aethersdr#5882 — the two-stage EP6 watchdog
recovery, the sibling out of the same local branch — inserts at the same `MetisClient.h` anchor.
@ten9876 reviewed both and called it: keep both, second one rebases.

**aethersdr#5869** and **aethersdr#5880** touch `Hl2Backend.cpp`, where this PR's change is comment-only — textual
at worst.

`src/core/LogManager.cpp`, `src/core/LogManager.h` and `tests/hl2_tx_gate_test.cpp` are not
touched by any other open PR.

Nothing here was measured on a radio. **No transmit behaviour changes** — the NaN arm fix changes
which *branch* a keyed client with queued IQ takes, and the branch it now takes is the one that
transmits that audio instead of substituting silence for it; every other keyed input reaches the
same arm as before, and the wire is byte-identical on all of them. This counts, and it stops one
path that was silently not counting.

---

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

Squashed-from: aethersdr#5881

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…grate it; mark the two spectrum tokens nothing paints RESERVED

The waterfall gate paces when a row is emitted. It does not integrate the row. The emitted row is the single producer frame that landed on the gate.

The comment now says what that frame is per family. On HL2 a production frame is still one unaveraged window: `Hl2Spectrum::setAverageFrames` defaults to 1 and only `hl2_spectrum_test` calls it (aethersdr#5833), so `m_avgPower` is present and unwired. On ANAN every IQ block is fed to the WDSP analyzer and only taking a frame is paced, so the row is already time-averaged. Icom rows are converted scope sweeps, not FFT windows. Flex does not emit `spectrumFrameReady`.

The widget seeds its time axis from `line_duration` and then measures real row arrivals. `color.spectrum.peakHold` and `color.spectrum.average` are marked RESERVED because nothing paints them. The live trace uses the `SMOOTH_ALPHA` EMA unless `backendPanAveraging` is set, which today is ANAN, so the widget does not average a second time on top of the analyzer.

Comment and documentation corrections only. No executable line changes.

Squashed-from: aethersdr#5877

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
Closes aethersdr#5803.

Three HL2 health rows report a level that has stopped being measured, as though it were current. A fourth reports a clip count the same way.

`Hl2Backend::healthSnapshot()` gates `adcPeakDbfs`, `adcRmsDbfs`, `adcCrestDb` and `adcClippedPerBlock` on one condition:

```cpp
const bool haveBlock = m_bandscopeBlock.samples > 0;
```

That asks **has a block ever arrived**, never **is it still current**.

`m_bandscopeBlock` has exactly two writers: the `bandscopeBlockReady` handler in the constructor, and `resetBandscopeMirrors()`, whose only callers are the three link edges — `linkUp`, `linkDown` and `connectFailed`. The `bandscope.enable` verb clears neither; it forwards `setBandscopeEnabled` to `MetisClient` and nothing else. So **stop the EP4 stream inside a live session — closing the wideband bandscope does exactly that — and the four rows go on publishing one frozen block, as plain numbers, for the rest of the link.**

`resetBandscopeMirrors()` states the principle the rest of the code then fails to apply:

> *"Back to 'never seen', which is what makes the level rows go ABSENT again rather than keep showing the previous session's last reading."*

The author knew the failure mode and applied the cure at the one edge they were thinking about. A gate stopped mid-session is the same sentence with the link still up.

**The auto-gain path is handed the age and refuses on it.** `stepAutoGain()` calls

```cpp
bandscopeHeadroom(m_bandscopeBlock,
                  m_bandscopeBlockClock.isValid() ? m_bandscopeBlockClock.elapsed() : -1)
```

which returns `Absent` past `kHeadroomMaxAgeMs`. The health rows were handed nothing. Two readers of one block: the regulator refuses stale data, the meter publishes it as fresh. `adcObservedAgoMs` **is** published as its own row — nothing used it to withhold the values.

## Measured

Receive-only, nothing keyed, dummy load verified at the switch by port label. Binary built from `e944ec49` with the configure line below. The stream was stopped with `bandscope off` and the gate confirmed closed by a **rising** age before the sweep, so this is the gate being shut rather than a slow block.

| LNA | `adcRmsDbfs` | `adcPeakDbfs` | `adcCrestDb` | `adcObservedAgoMs` | `adcSlicePeakDbfs0` |
|---:|---:|---:|---:|---:|---:|
| −12 | −58.39 | −40.21 | 18.19 | 7 594 ms | −93.15 |
| 0 | −58.39 | −40.21 | 18.19 | 10 109 ms | −87.77 |
| +10 | −58.39 | −40.21 | 18.19 | 12 619 ms | −77.84 |
| +19 | −58.39 | −40.21 | 18.19 | 15 121 ms | −74.11 |

**0.00 dB of travel on the three wideband columns across 31 dB of commanded gain**, while `adcSlicePeakDbfs0` — a different subsystem, age 10–20 ms — moved **19.04 dB** over the same four rows. `lnaBaselineDb` and `lnaGainDb` reported every step faithfully.

An earlier run on the same radio caught a pair frozen at the +19 rung beside a radio sitting at LNA 0: **21.17 dB wrong, carrying no marker but the age.**

## The fix

`haveBlock` now applies the expiry `bandscopeHeadroom()` has always applied, through a predicate the two share:

```cpp
[[nodiscard]] inline constexpr bool bandscopeBlockIsCurrent(
    const Ep4Stats& block, std::int64_t ageMs,
    std::int64_t maxAgeMs = kHeadroomMaxAgeMs) noexcept
{
    return block.samples > 0 && ageMs >= 0 && ageMs <= maxAgeMs;
}
```

`bandscopeHeadroom()` now calls it rather than restating its three conditions, so the classifier and the rows cannot drift apart again — which is what this defect was. Pure, `constexpr`, clock-free, for the reason `Hl2BandscopeHeadroom.h` already gives for everything else in it: as arithmetic these branches are reachable, where driving a real gate to a real expiry is not something a test suite can arrange.

### Why that expiry, and not a separate one

`kHeadroomMaxAgeMs` is **3000 ms — three `MetisClient::kBandscopeSampleMs` gate periods** — and its own header already derives it as *"two gate periods … and anything beyond it means the gate stopped delivering. Three periods is that with one period of slack for a late I/O thread."* It also already names its scope: *"A DISPLAY AND CONTROL BOUNDARY, NOT A CALIBRATED ONE."* Display is half of what it was chosen for.

I considered choosing separately, on the argument that a health row and a control loop need not want the same window, and rejected it. **The rows and the loop read one block from one gate**, so the derivation is identical — it is a property of the producer, not of the consumer. A separately chosen number would not remove the disagreement, only shrink it: pick anything longer and there is a window in which the loop refuses a block these rows still publish, which is a smaller copy of the bug being fixed; pick anything shorter and the rows blank while the loop still acts on the block, which is the same confusion running the other way. `RadioHealthDialog::kRefreshIntervalMs` polls well inside 3000 ms, so a live gate never blinks.

### `adcObservedAgoMs` is deliberately NOT expired

It is gated on `haveObservation` (a block has ever arrived) where the values are gated on `haveBlock` (the newest one is still current). That split is the point: **four dashes and an age of 46 810 ms says the gate stopped; four dashes and a fifth dash says only that something is missing.** Expiring the age along with the values would delete the evidence for the expiry.

### No row is removed

`put()` keeps the key in `order` and `labels` and withholds only the value. Dropping rows outright was tried and reverted on aethersdr#5650 round 3, for the reason that file still records — *"the dialog's row list changed shape under a reader mid-refresh."*

## The four rows also go absent during TRANSMIT, and that is a change to every installation

**Disclosed because a maintainer should rule on it rather than inherit it**, and because the argument this body makes one row over says the opposite. Raised by @ten9876 as finding 1, carried from the automated pass; I verified the interlock and the hold-off in the source before writing this rather than taking the finding on trust.

`MetisClient::bandscopeInterlocked()` returns true for `m_mox`, for the radio's **own** `ptt` — `cw_on | ext_ptt`, so a foot switch, a PTT jack or the internal keyer counts, which `m_mox` never sees — and for `kBandscopeUnkeyHoldoffMs` (300 ms) after unkey. `bandscopeArm()` refuses on it *silently and per tick*, while `onBandscopeTick()` goes on ticking every `kBandscopeSampleMs`. **So no block arrives while keyed.** With the expiry in, the age therefore runs out mid-over.

What an operator sees on a 10 s SSB over with the wideband bandscope enabled:

| time after key-down | `adcPeakDbfs`, `adcRmsDbfs`, `adcCrestDb`, `adcClippedPerBlock` | `adcObservedAgoMs` |
|---|---|---|
| 0 – 3000 ms | the last pre-key block's numbers, as today | climbing |
| 3000 ms → unkey | **—** in the dialog, JSON `null` on the bridge | climbing, 3 000 → 10 000 |
| after unkey | back, at whatever the first new block reads | back under 1 000 |

The return is unkey **+** the 300 ms hold-off **+** up to one `kBandscopeSampleMs` tick **+** one capture cycle — under two seconds in practice, and not a latch: `hl2_ep4_ingest_test` §9 asserts the restoration directly, and M2 below is the mutant that would catch losing it.

Before this PR those four rows held their last pre-key numbers for the whole over and kept them afterwards for as long as the gate stayed shut. After it they read as dashes with a climbing age beside them.

### Why the slice-row argument does not rule this out — and where that argument was overstated

Reason 3 under *"Are the slice-side rows affected?"* declines to expire `adcSlicePeakDbfs0` partly because doing so would be *"a transmit-time display change for every installation, which does not belong in a bug fix."* **Read as a general rule, that sentence forbids this PR as well, and it should not have been written as one.** It fused a real distinction with a claim this change itself breaks. The distinction survives; the general rule does not, and I have narrowed the bullet accordingly.

* **The slice row's transmit hold is a decision; the wideband hold was an omission.** `Hl2RxDsp` deliberately holds its last receive value during transmit *because the receive chain is clocked with silence there* — expiring it would overturn a choice somebody made and recorded. Nothing chose to hold an EP4 block through an over, or through a gate closed by `bandscope off`: `healthSnapshot()` simply never asked how old the block was. Here the expiry does not overturn a decision, it **applies** the one `resetBandscopeMirrors()` already states in this file.
* **The slice hold ends with the over; the wideband hold did not end at all.** The slice peak resumes on the next DSP block after unkey, ~21 ms. The defect measured here is 46 810 ms old and 21.17 dB wrong **with the link up and the operator receiving** — a receive-time fabrication, which is what aethersdr#5803 filed and what this PR exists to remove. The transmit-time dashes are a consequence of the cure, not its object.
* **An age cannot tell the two silences apart, and this change should not pretend it can.** "The gate stopped because the operator closed the bandscope" and "the gate stopped because we are keyed" are one fact to the sensor: it is not sampling the band. Separating them means clearing the mirrors on the gate-off and keying **edges** instead of ageing them out — @ten9876's finding 2, which is a real design and explicitly not asked for in this PR.

**And it is defensible on its own terms,** which is the half I would least like a maintainer to inherit silently: while keyed, the converter is looking at our own PA at enormous strength rather than at the band — the interlock exists precisely because a block taken then *"is a picture of us"*. The last pre-key number is not a current measurement of anything, so publishing it as one is the same assertion-without-evidence the rest of this PR removes. Both reviewers reached that conclusion independently and so do I.

**The maintainer's call, stated as a question rather than assumed:** if you would rather the wideband rows hold through an over the way the slice row does, that is finding 2's edge-driven clear plus a deliberate keyed-hold — not a reason to leave the receive-time freeze in place. Say so and I will build it; I have not pre-empted it here.

## Are the slice-side rows affected? Verified: no, and the change does not touch them

`adcSlicePeakDbfs0` and `adcSliceObservedAgoMs0` come from `Hl2RxDsp::adcPeakDbfs()` / `adcPeakObservedAgoMs()` — WDSP's `RXA_ADC_PK`, stamped on every processed block on the `!m_audioMuted` path, at the DSP block rate (~21 ms at 48 kHz) rather than at a 1 Hz gate. Three reasons they are out of scope, and the third is the one that decides it:

1. **They were measured not to have it.** In the same runs that froze the wideband triple, that column tracked gain over 19.04 dB with its age at 10–20 ms.
2. **They already have a freshness gate where an assertion is made.** `Hl2AdcPairing.h`'s `kSliceStaleMs` (150 ms) is fed `adcPeakObservedAgoMs()` and withholds the pairing verdict, so a held slice peak is never combined with a live overload flag.
3. **The one case where the slice peak does hold is a documented decision, not an oversight.** It stops advancing during transmit because *"the receive chain is clocked with silence there, so `Hl2RxDsp` deliberately holds the last receive value rather than measuring our own mute."* Expiring that row would blank it on every key-down and **overturn a choice somebody made and recorded**, on a row nothing was measured to be wrong about. That distinction is the whole of this reason — it is **not** a general rule that a bug fix may not change what is shown during transmit, which is how this bullet read until review round 2 and which this PR's own expiry breaks. See *"The four rows also go absent during TRANSMIT"* above, where that is set out and disclosed.

Whether the slice display row should also carry an expiry is a real design question; it is not this one, and I have not pre-empted it.

## What breaks downstream when a row goes absent — checked, nothing

* **The dialog.** `RadioHealthDialog::formatValue` opens `if (!v.isValid()) return QStringLiteral("—");` — a dash, in a row that keeps its place and its label.
* **The bridge.** `AutomationServer`'s `bandscope` verb reads the rows through `QJsonValue::fromVariant(snap.values.value(key))`, so an invalid variant serialises as JSON `null` — the same value the pre-first-block case already produces there today. Its comment said *"absent until a block has arrived"*; that sentence is now incomplete, so the second commit here corrects it. No serialisation change.
* **Scripts.** Every reader of these keys I can find reaches them as `rows.get("adcRmsDbfs")` through a `None`-tolerant converter — never `rows["adcRmsDbfs"]` — and several already handle the null case explicitly by name, refusing with *"the EP4 stream stopped"* rather than computing on it. Zero direct-subscript accesses.
* **Tests.** `hl2_ep4_ingest_test` section 8 already asserts these rows are **absent before any block**; that contract is unchanged and section 9 extends it to the other edge.

Squashed-from: aethersdr#5880

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…rinciple VIII.

## What this changes

`WdspChannel::filterTaps` and `minimumPhase` could be chosen only in `open()`.
The only way to revisit either was `reconfigure()`, which closes and reopens the
channel and so **destroys the notch database** — the exact state a caller
changing the filter length is usually trying to keep.

This adds two verbs under the same `beginControlOperation()` + `g_setupMutex`
discipline `setAgc()` and `setNotchesEnabled()` already use:

```cpp
bool setFilterTaps(int taps) noexcept;
bool setMinimumPhase(bool on) noexcept;
```

**Additive. Nothing calls them, no default moves, and every backend behaves
exactly as it did before.** `Hl2RxDsp::kRxFilterTaps` is untouched.

## Changed in review — @ten9876, `CHANGES_REQUESTED`

Two blockers, both real, both verified against the source and then measured
here before and after. Thank you — the first one in particular was ours and had
been sitting in a comment claiming the opposite.

### 1. We held the process-global FFTW planner lock for ~165 ms per call

Confirmed exactly as described. `RXASetNC` (`RXA.c`) opens with
`SetChannelState(channel, 0, 1)`; `dmode` 1 spins `Sleep(1)` up to a 100-count
timeout waiting for `flushflag`, and `flushflag` is cleared only by the
down-ramp inside `fexchange2` — which `beginControlOperation()` has already made
unreachable, because `processIq()` now returns `Busy`. **Nothing can clock that
ramp, so the timeout always expires**, and the whole wait was inside
`g_setupMutex`.

Reproduced on this tree (macOS arm64, RelWithDebInfo, 48 kHz), instrumenting the
locked region and running `wdsp_channel_test`. Measured as an **A/B in one
binary**, one env switch selecting which arm runs, so both arms see the same
FFTW wisdom state and the same machine:

| | per call, 7 calls | sum |
|---|---|---|
| before (`RXASetNC` does its own stop, under the lock) | **155 – 227 ms** | 1145 ms |
| after (stop hoisted out, `dmode` 0) | **1.8 – 28.8 ms** | 85 ms |

**One correction to the review's arithmetic, and it makes the case stronger.**
The figure is not ~110 ms and the drain is not "most" of it — it is very nearly
*all* of it. The port routes `Sleep(1)` to `nanosleep()`
(`third_party/wdsp/port/wdsp_port.c`), which on this machine lands at roughly
1.6–2.3 ms rather than 1 ms, so the 100-count timeout costs 155–227 ms. The
re-planning the lock actually exists for is the 1.8–28.8 ms left over. **Summed
over the seven calls, 93 % of the hold was sleep rather than work, and the lock
is now held 13.4× less.**

The fix is the one suggested, and it is the shape `reconfigure()` already uses
at its own stop: `SetChannelState(m_channelId, 0, 0)` outside `g_setupMutex`
before the locked region, then restore outside the lock after. `RXASetNC`'s
internal stop and restore both no-op once the state already matches, so the
locked region shrinks to the six FIR re-plans.

Checked, because both are load-bearing and neither is obvious:

- **the stop does not wait** — `dmode` 0 takes no timeout path at all, which is
  what `setRunning()`'s comment legislates (*"the drain is `processIq()`'s job
  and this thread cannot wait for it"*). This is now the fifth
  `SetChannelState` site in the file and the fourth to follow that convention;
  `open()`'s remains the documented exception.
- **the restore does not wait either** — AetherSDR patch 8's wait in case 1 is
  predicated on `exchange` being **clear**, and a ramp that was never clocked
  leaves it **set**, so case 1 falls straight through to `flush_slews()`. That
  also cancels the armed down-ramp rather than stranding it, which is patch 7.

**And the header's "the operator hears the mute ramp" was wrong** — corrected.
The timeout path force-cleared `downflag` along with `exchange` and
`flushflag`, so only the up-slew ever played. It was wrong before this change
too, not newly wrong because of it.

**Context worth having, and not for fixing here.** `SpectralNR` plans under its
own `s_fftwMutex` rather than `fftwSetupLock()` — two mutexes over one
process-global planner, with its allocations outside its own guard as well. So
the lock we were holding for 165 ms is already contended by a path that does not
know this lock exists. That is a separate defect and is not touched here; it is
why the 165 ms mattered more than the number alone suggests.

### 2. The guard admitted tap counts that corrupt the filter silently

Confirmed, and **`firmin.h` states the contract on the field itself**:

```c
int nc;    // number of filter coefficients, power of two, >= size
```

`plan_fircore` sets `nfor = nc / size` and `idxmask = nfor - 1`, and `xfircore`
walks the overlap-save ring with that value as a **power-of-two mask**
(`k = (k + idxmask) & idxmask`, `buffidx = (buffidx + 1) & idxmask`). So
`nc >= size` is necessary and not sufficient, and our setter checked only that.

**Reproduced rather than reasoned.** Instrumented build, `dspBlockSize` 1024, a
0.1-amplitude tone in a 150–3000 Hz passband, steady-state peak in band
(1500 Hz) and out of band (6000 Hz):

| taps | nfor | mask | in-band | out-of-band | rejection | |
|---|---|---|---|---|---|---|
| 1024 | 1 | 0 | 0.39807 | 0.00000 | 139 dB | sound |
| 1536 | 1 | 0 | 0.39723 | 0.00032 | **62 dB** | impulse truncated |
| 2048 | 2 | 1 | 0.39807 | 0.00000 | 149 dB | sound |
| 2560 | 2 | 1 | 0.39807 | 0.00003 | **82 dB** | impulse truncated |
| 3072 | 3 | 2 | **0.00008** | 0.00044 | **−15 dB** | ring broken |
| 4096 | 4 | 3 | 0.39807 | 0.00000 | 150 dB | sound |
| 6144 | 6 | 5 | **0.19966** | 0.03989 | **14 dB** | ring broken |
| 8192 | 8 | 7 | 0.39807 | 0.00000 | 161 dB | sound |

**At 3072 the wanted signal comes out 74 dB down and the out-of-band tone comes
out 15 dB louder than it.** `buffidx` is pinned at 0 (`(0 + 1) & 2 == 0`), so
one partition is never written and never read. At 6144 the mask is 5 and half
the ring is skipped. Every one of those returned `true`, and **`open()` accepted
them identically** — which is the second door the review names.

The non-multiple cases are the quieter and probably worse failure: 1536 and 2560
keep their passband intact and lose 77 dB and 67 dB of stopband. Audio that
sounds correct and no longer filters.

**Fixed where the review said to put it.** `validateConfig()` never looked at
`filterTaps` at all; it does now, through one predicate the setter also calls,
so `create()`, `reconfigure()` and `setFilterTaps()` cannot disagree. The
predicate is a **power of two in [256, 16384] that divides the DSP block size
exactly** — every divisor of a power of two is a power of two, so `nc / size` is
then exact and itself a power of two and `idxmask` is a real mask.

The **256 floor is WDSP's, not ours**: `min_notch_width()` computes
`1600.0 / (a->nc / 256)` where `NBP::nc` is an `int`, so below 256 that is a
division by zero. It is scoped to receive channels — `filterTaps` reaches WDSP
only through `open()`'s `RXASetNC` and is read only by `minimumNotchWidthHz()`,
both RX-side.

### Nits

- **`minimumNotchWidthHz()` integer division** — fixed, and the review is right
  that fixing 2 closes it. WDSP does `a->nc / 256` in **integer** arithmetic
  (`NBP::nc` is an `int`, `nbp.h`); we did it in floating point. They agree at
  2048 and 8192 and diverge elsewhere, always **under-reporting** the floor — at
  1000 taps we would offer 409.6 Hz where WDSP enforces 533.3 Hz. The new guard
  makes the two identical by construction, so this is now a statement of intent
  rather than a behaviour change; it is written that way in the comment rather
  than left as a coincidence.
  **What it is still not**, and the review is right to flag it: those two notch
  assertions remain cached-state assertions, because the value is recomputed
  from `m_config.filterTaps`, which the setter updates either way. The fix does
  not change that and does not pretend to. The thing that would give them teeth
  is a cross-check against `RXANBPGetMinNotchWidth` (`nbp.c`), which exists but
  is **not on the `aether_wdsp.h` facade** — and aethersdr#5872 is already adding a
  declaration to that facade, so I have left it out rather than collide. Happy
  to do it as a follow-up, or here if you would rather.
- **The cost paragraph** now carries the lock hold, both arms, and says which
  one the code takes.
- **The `backend_seam_affinity_test` note was stale** — removed. aethersdr#5865 merged
  2026-09-21T00:12Z and `tests/SeamThreadAffinityProbe.h` on `origin/main`
  carries `AETHER_SEAM_PROBE(autoRfGainArmSettled)`.
- **`m_config.filterTaps` is now mutable on an open channel** and
  `minimumNotchWidthHz()` reads it lock-free. No cross-thread reader exists
  today — every caller of both is on the control thread — so it is sound as it
  stands, and it stops being sound the moment the gate publishes a tap count
  another thread reads. Recorded in the header, where whoever writes the gate
  will be looking.

### Sequencing against aethersdr#5872 — agreed, and neither should rebase

Verified with `git merge-tree`. aethersdr#5872 merges into `origin/main` with **zero
conflicts**; it conflicts only against *this* branch, in exactly two files, and
both hunks have an **empty base** — `WdspChannel.h` at the declaration after
`setAgc()`, and `wdsp_channel_test.cpp` in the registration list in `main()`,
where both sides obeyed the same "anything added later belongs ABOVE this line"
marker and therefore landed on the same anchor. Add/add, keep both, either
order. Whichever lands second resolves it in one commit.

### The no-caller question

Same question aethersdr#5872 carries, and the answer is the same: the follow-up that
gives these a caller is **aethersdr#5578**, which is open, reports the underlying defect
(the HL2 RX bandpass FIR runs at 8192 taps unconditionally, ~64 ms of group
delay for every receiver), and is where the gate — the part of item 5 that
actually changes RX latency behaviour — belongs.

## Why this is filable as a standalone PR

This is the primitive half of item 5 of @ten9876's scope-audit review on aethersdr#5462
([`CHANGES_REQUESTED`, 2026-09-06](aethersdr#5462 (review))).
That review's table puts this symbol in the contribute-back column:

| File / group | Explained by "Hermes Lite 2 with I/O board"? | Verdict |
|---|---|---|
| `src/core/dsp/AudioMetrics.*`, `WdspChannel::setFilterTaps` | Partly | **Contribute back**, own PRs |

— while putting new `IRadioBackend` virtuals in a different column entirely
(`Yes, but new public seam` → **`Needs an RFC` before the code PR**). This PR
adds no interface virtual, no capability field and no public protocol surface,
so it is on the near side of that line.

**The part of item 5 this deliberately does *not* do.** The review's wording is:

> 5. **Notch-gated FIR length.** `WdspChannel::setFilterTaps` / `RXASetNC`,
> `Hl2RxDsp::kRxFilterTapsLong`, the gate in `Hl2Backend`, the
> `wdsp_channel_test` additions. This changes RX latency behaviour, so open an
> issue first…

The thing that changes RX latency behaviour is the **gate** — the policy that
picks a length. That is not here. This PR ships only the two primitives and
leaves the policy, the `Hl2RxDsp` constants and the `Hl2Backend` gate alone, so
nothing about RX latency moves. The issue that item asked for is aethersdr#5578, and it
is open.

## Prior art, stated plainly

- **aethersdr#5578** (open) reports the underlying defect: the HL2 RX bandpass FIR runs
  at 8192 taps unconditionally, costing every receiver ~64 ms of group delay.
  It has had **no maintainer reply and no follow-up from its reporter**; the
  only substantive comments on it are our own measurements from 2026-09-17.
- **aethersdr#5579** wrote almost exactly this setter, together with the notch gate.
  **It was closed unmerged by its own author 2 h 18 min after opening, with
  zero issue comments, zero review comments, zero reviews, and CI that never
  ran** (`check-runs` `total_count: 0`; combined status `total_count: 0`).
  **There was no warning to heed and no feedback to incorporate** — I am not
  implying it was rejected, because nothing happened to it at all.

No open PR implements runtime filter tap setters.

## A hazard nobody has priced — read this before writing a caller

`RXASetNC` → `RXANBPSetNC` → `setNc_nbp` → `setNc_fircore`
(`third_party/wdsp/upstream/firmin.c` — there is no `fircore.c` in this tree)
calls `deplan_fircore()` and then `plan_fircore()`. `plan_fircore` re-allocates
the partitioned overlap-save state with `malloc0` and resets the write index:

```c
a->nfor    = a->nc / a->size;
a->cset    = 0;
a->buffidx = 0;
a->fftin     = (double *) malloc0 (2 * a->size * sizeof (complex));
a->fftout[i] = (double *) malloc0 (2 * a->size * sizeof (complex));   /* per partition */
a->accum     = (double *) malloc0 (2 * a->size * sizeof (complex));
```

`malloc0` (`upstream/utilities.c`) is `_aligned_malloc` followed by
`memset(p, 0, size)`. `fftout[]` is the frequency-domain delay line — **the
filter history — and it comes back zeroed**, with `buffidx` back at 0. That is
the same state `flush_fircore()` produces deliberately, reached here as a side
effect. So for one full filter fill after a runtime tap change, the channel
emits a filter convolving against zeros and the output ramps up from silence.

That matters because the S-meter integrates it. It is the same dive aethersdr#5821 fixed
— WDSP's `avg` is an EMA with a 0.100 s time constant, and suppressing the read
does not un-integrate silence already in it — arriving through a door aethersdr#5821 does
not cover. `Hl2RxDsp` arms its settle countdown on exactly two edges today: the
release edge of the transmit mute in `setAudioMuted()`, and a channel install in
`installChannel()`. **A runtime tap change is a third edge, and it is not
covered.**

So, concretely, for whoever writes the gate: `Hl2RxDsp::armMeterSettle()` is
private and DSP-thread-only, and `Hl2RxDsp` owns the `WdspChannel` — the
dependency runs that way round, so `WdspChannel` cannot and should not arm it.
The gate belongs in `Hl2RxDsp`, and it must call `armMeterSettle()` on the tap
change the way it already does on the other two edges.

`setMinimumPhase()` has no such problem. `RXASetMP` → `RXANBPSetMP` →
`setMp_nbp` → `setMp_fircore` sets `a->mp` and calls `calc_fircore()`, which
recomputes the masks and flips `cset`. There is no `deplan`/`plan`, no
reallocation, and `fftin`/`fftout[]`/`buffidx` are untouched, so the delay line
carries straight across the switch.

I have not wired the settle call in here because there is no caller to wire it
into. It is recorded so the next person does not rediscover it from a meter
dive.

*(`firmin.c` in this tree carries a local AetherSDR patch — the lazy
minimum-phase workspace build, per `AETHERSDR-PATCHES.md` — so the quote above
is from the vendored source rather than stock upstream. The `malloc0`
allocations and the index resets shown are upstream's.)*

## Not proposed: minimum phase as a default

`setMinimumPhase()` stays a primitive with no policy attached. Making minimum
phase the default would be a default-UX change for every installation, which is
a different kind of decision and is not what this PR is for.

Squashed-from: aethersdr#5878

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…and FM. Principle VIII.

The HL2 inherited `hasFmRepeaterOffset` (the struct default is true) and declared Legacy FM tone presentation, so the repeater-offset controls and CTCSS encode looked live with no verb behind them. Both are withdrawn. FM and NFM stay on `receiveOnlyModes`, so the radio still will not key in those modes.

`receiveModeControl` now includes DSB, CWL, and FM, which the mode menu already offers. CWU and NFM stay off that list. `setSliceMode` collapses those aliases onto CW and FM, so a control-plane request for the alias is refused instead of wedging `request.conflict`, and a direct caller cannot leave the slice holding a spelling the menu cannot show.

REV stays enabled when the button is the transmit-frequency check, and is disabled only when it is the duplex reverse and the radio declares no offset. The IC-7300MK2 profile keeps XFC on and duplex off, so that button stays live.

Squashed-from: aethersdr#5879

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…own — two-stage EP6 watchdog recovery

## The gap

An established link that went quiet had **no protocol-level recovery of its own**. `MetisClient::onWatchdogTick`, on `origin/main`, cleared `m_linkUp`, emitted `linkDown`, and could not then fire again for as long as the link stayed down — its own first line returns on `!m_linkUp`, and the only thing that sets `m_linkUp` back to `true` is an EP6 packet reaching `handleDatagram`. Which is exactly what does not happen once the radio has stopped streaming. (A link that comes back on its own *does* re-arm the watchdog; that was never the stuck case.)

`m_startRetryTimer` was armed by exactly two paths — `MetisClient::start()` and `MetisClient::setReceiverCount()` — and neither is this one. The only recovery in the product was a layer up: `RadioModel`'s reconnect timer (`m_reconnectTimer.setInterval(5000)`) re-driving `connectRadio` five seconds after `disconnected()`, which closes and reopens every WDSP channel. The protocol-level answer is one 64-byte datagram.

This matters most in the case the client cannot otherwise escape. The gateware has its own anti-wedge watchdog — `dsopenhpsdr1.v`, `watchdog_cnt`, tripping at `&watchdog_cnt` to `run <= 0` — cleared by **EP2 arrivals** (`watchdog_clr`, asserted in `SEQNO0`, the state an EP2 data packet enters). So: EP2 stops, EP6 keeps flowing so our silence timer never runs, the gateware eventually sets `run` to 0, EP6 stops — and only *then* does our watchdog fire, at which point **resuming EP2 cannot restart the radio**. Only a run command can.

### Correction from review — "nothing was sending one" needed a qualifier

The first version of this body said nothing was sending a run command during a silence. **That is true only with the bandscope off**, and @ten9876 is right to have caught it. `MetisClient::bandscopeArm` reaches `sendBandscopeRunByte(true)`, which is `metisRunCommand(true, m_watchdogEnabled)` — **run bit set** — and the guard's `bandscopeDisarm` sends `metisRunCommand(false, …)`, run bit set again. The gate cycles at `kBandscopeSampleMs` and, before this PR, was disarmed only when the watchdog declared link loss. So with the bandscope on, a wedged radio was already being told to run **twice per second**, by a sensor that exists for something else entirely.

That is an accident and not a recovery: it is absent in the default configuration (the gate is off per session and has to be asked for), it is invisible in every counter, and it stops the moment the operator turns the bandscope off. It also means the old test's "exactly one run command went out" was a property of the fixture, which does not run the gate.

**So stage 1 now stops the gate** rather than leaving it cycling until stage 2 — see below.

## What this does

**Stage 1** stops the bandscope gate, re-sends the run command once with `wide_spectrum` lowered, and arms the existing retry, keeping `m_linkUp` **true**. **Stage 2** — reached when the retry budget has run out and the stream is still gone — falls through to exactly the teardown that was always there.

`m_linkUp` stays true on purpose: clearing it makes a stream that comes back re-emit `linkUp()`, and `Hl2Backend` republishes its entire initial state on that, over the operator's live panes. A recovery that succeeded is invisible above the protocol layer by construction — which is why the two counters below are not optional.

### Stopping the gate at stage 1

`resetBandscopeGate()` rather than `bandscopeDisarm()`: disarm would put a run byte of its own on the wire first, which is the thing being stopped. Three things fall out and all three are wanted:

* **the attempt is attributable.** A recovery whose success might have been bought by a 1 Hz sensor tick is not evidence about this code.
* **no `bandscopeTimeouts` are manufactured.** `resetBandscopeGate()` stops the guard as well as the tick, so the extra time stage 1 buys cannot charge an operator-visible row for a radio that has gone quiet. (This is the "extra 1.5 s inflates `bandscopeTimeouts`" nit from both passes — subsumed rather than documented.)
* **`wide_spectrum` comes down on the wire**, in the recovery datagram itself, rather than being left up by whichever phase the gate happened to be in.

The operator's standing intent survives in `m_params.bandscope`, which `resetBandscopeGate()` does not touch. `handleDatagram` calls `applyBandscopeGate()` when the recovery completes — the same call `setReceiverCount()` makes after its own restart, and for the same reason: the run byte has been through a known state and the gate has to re-arm from scratch. An outstanding `requestBandscopeFrame()` is still answerable: the re-armed gate answers it if the recovery works, and stage 2's `failPendingBandscopeFrame()` fails it with the link if it does not.

**The cost of this:** the bandscope pauses for the length of the attempt, up to ~1.5 s, and the first block after a recovery is one gate cycle later than it would have been.

## The counters now reach somebody

Both passes flagged that `silenceRecoveryAttempts()` / `silenceRecoveriesCompleted()` had zero production callers while the header claimed they existed so a silent recovery "is not also invisible to whoever asks later why the audio had a hole in it". They were right, and a counter nothing reads records nothing.

They are now **two fields on `MetisClient::LinkCounters`**, not getters:

| key | label |
|---|---|
| `silenceRecoveryAttempts` | EP6 silence recoveries attempted |
| `silenceRecoveriesCompleted` | EP6 silence recoveries completed |

`LinkCounters` is this file's established path to the operator: `Hl2Backend` mirrors it from `linkCountersUpdated` onto the GUI thread, and `Hl2Backend::healthSnapshot()` publishes the rows next to "Dropped EP6 packets" — the row an operator is already looking at when they ask why the audio had a hole in it. `RadioHealthDialog::refresh()` and the bridge's `health` verb (`AutomationServer::doHealth`) both iterate `snap.order` generically, so the rows arrive in the dialog and over the bridge with no registration anywhere. The publish is also what makes the read thread-safe, so the standalone accessors and their missing "I/O-thread only" note are gone rather than annotated.

The failure path cannot wait for `onReadyRead`. That is the only place `linkCountersUpdated` used to be emitted, and it does not run when nothing arrives. Stage 1 now emits `linkCountersUpdated` in the same turn as the increment, so a recovery that never completes is on the health rows before `start()` replaces `m_link` on the reconnect. Session-life: zeroed with the rest of `m_link` at `start()`, left standing by `stop()`.

**One correction to the review here:** `LinkCounters` reaches the health dialog, not the support bundle. `SupportBundle::createBundle` writes redacted logs, `system-info.json`, `radio-info.json` and settings, and carries **no health rows at all** — I checked before repeating it. The dialog's own copy-to-clipboard is the path one of these numbers actually takes into a bug report.

Read together or not at all — attempts climbing while completions do not is the shape that says the re-send is not the right answer for whatever is actually failing.

## Fixed from review

* **A concurrent `setReceiverCount()` was credited as a completed recovery.** It restarts the stream by its own route (stop + start + priming burst), and with `m_silenceRecoveryArmed` left set, `handleDatagram` scored the EP6 that came back as the run command's success. `setReceiverCount()` now clears the flag. The reverse case needs nothing: the watchdog cannot arm a recovery while a restart is running, because `m_sinceLastEp6` has just been restarted.
* **The log line said "attempt budget 5" where four re-sends remain.** `armStartRetry()` now seeds the budget in one place, `kStartResendsAfterArm` names what is left, and the line reads "this one, then up to 4 re-sends at 300 ms". The helper also removes the third hand-rolled copy of `m_startAttempts = 1; m_startRetryTimer->start(…)` — the send stays at each call site, because the three put different bytes on the wire and that is the line a reader most needs to see.
* **`static_assert(kMaxStartAttempts * kStartRetryMs < kSilenceTimeoutMs)`.** Two live readings rest on that inequality — `setReceiverCount()`'s "the budget fits inside the window" and stage 2's use of `isActive()` as a bounded wait — and both were comments about constants declared in the same header. A comment does not fail a build.

## The costs, stated honestly

* **Link loss is declared ~1500 ms later when recovery fails** (`kMaxStartAttempts` 5 × `kStartRetryMs` 300), so worst case to a rebuilt link goes from ~7.0 s to ~8.5 s. A real regression in the failing case, bought against the succeeding case costing nothing.
* **An outstanding C&C request waits ~1.5 s longer for its verdict.** `dropControlRequest()` fires at stage 2 and stage 1 returns above it, so a request caught in a silence fails at ~3.5 s instead of ~2 s, and new requests are accepted throughout because the link reads up. **Left deliberately**, and the reasoning is in the code: the request's own deadline cannot expire meanwhile — `Hl2ControlRequest` counts `Awaiting` in EP6 **frames** and there are none — so nothing is racing it, and failing it at stage 1 would make a recovery that *succeeds* visible to the caller as a failure it never had. 1.5 s of extra wait on the path that was going to fail anyway is the price of a recovery that costs nothing above the protocol layer. Happy to drop it at stage 1 instead if a maintainer prefers the old timing.
* **A `setReceiverCount()` restart that fails now gets a second budget.** Its own retry exhausts at 1.5 s; the tick at 2 s sees `m_silenceRecoveryArmed == false` and stage 1 sends five more. That path is **nine run commands and ~3.5 s**, not "exactly the teardown that was always there" as the first body claimed. It is the same 1.5 s priced above, spent twice, on a radio that has already ignored five starts — cheap, but it was not stated and it is now.
* **The bandscope pauses for the attempt**, as above.

## What a second `metis-start` actually does — derived from the gateware, not from trying it

This is the obvious objection ("you are commanding a radio that thinks it is already streaming"), so it is settled from the RTL.

`dsopenhpsdr1.v`, state `RUNSTOP`, entered from `DECODE` on `eth_data == 8'h04` — the **entire** body:

```verilog
RUNSTOP: begin
  run_next = eth_data[0];
  wide_spectrum_next = eth_data[1];
  runstop_watchdog_valid = 1'b1;
end
```

A plain **level assignment**. No edge detection, no FIFO flush, no DDC reset, no counter clear; `state_next` is not assigned, so it falls through the combinational default. A second start while `run` is already 1 re-writes 1 over 1 and nothing downstream sees an edge — every consumer takes `run` as a level. **So a duplicate start is idempotent for `run`.**

What it is *not* idempotent for: it **re-latches** `wide_spectrum` from bit 1, and `watchdog_disable` from bit 7 (`runstop_watchdog_valid` gates `watchdog_disable <= eth_data[7];`). That is why this sends `metisRunCommand` with the live watchdog state and not `metisStart()`, and why the `wide_spectrum` bit it carries is now explicitly `false` — the gate is stopped one line above, so leaving the bit up would be asserting a sensor that is no longer running.

**Sequence tracking is deliberately left alone — `m_haveRxSeq` is NOT reset.** `setReceiverCount()` resets it because it sends a `metisStop` first, and the gateware zeroes `ep6_seq_no` on `~run`. Here there is no stop. Being exact about the premise, because the obvious phrasing is false: `~run` is **not** the only reset — `START` also zeroes `ep6_seq_no_next` on `stall_req`. The claim that holds is the narrower one: **no path a duplicate run command takes resets the sequence.** Both outcomes then want the same handling:

* the radio never halted (the silence was ours) — the stream kept counting, the gap across it is **real**, and resetting would erase a genuine loss of ~760 packets at 48 kHz / 1 RX;
* the radio halted and this restarts it — `ep6_seq_no` begins at zero, and the existing backward-gap guard (`if (gap < 0x80000000u)`) already declines to score that as loss.

**Both halves are now asserted** rather than argued — see the tests below. Thanks to @ten9876 for pressing on this: a sixty-line comment with no test behind it is a confident paragraph.

**No `metis-stop` and no `sendPrimingBurst()`.** `setReceiverCount()` uses both because it changes the EP6 payload layout and needs a hard edge; here the layout is unchanged and there is nothing to re-prime. It also matters that `sendPrimingBurst()` blocks: two `QThread::msleep(10)` calls per invocation, on the thread that paces EP2 — and stalling the pacer is a plausible cause of the very silence being recovered from.

## The test layer — a maintainer's call, made legible

**This grows a legacy socket fixture, knowingly, and I am not asking anyone to pretend otherwise.**

`AGENTS.md`'s test-layer table routes *"a refusal, a non-event, a dropped/malformed/disconnected input, a TX guard"* to **a socket-free test that injects the transport**, and lists `hl2_receiver_count_restart_test` (fake Metis radio) among four legacy exceptions **tracked for socket-free extraction in aethersdr#5254**. A silent radio is a disconnected input. This PR adds it to that exception.

The reason it lands there is mechanical rather than a preference: **stage 1's only observable is a datagram**, it goes out through `MetisClient`'s own `m_socket`, and there is no injectable datagram sink to watch it with. `feedDatagram()` injects the *receive* side; the send side has no counterpart. The socket-free shape aethersdr#5254 wants needs that sink first.

**What the socket-free version would need**, concretely:

* an injectable datagram sink beside `feedDatagram()` — a `std::function<void(std::span<const std::uint8_t>)>` or a small `IDatagramSink`, set through `MetisClientTestAccess`, that `countTx(sendTo(...))` routes through when present;
* a way to advance the silence clock without a real 2 s wall clock — today `m_sinceLastEp6` is a `QElapsedTimer` read directly, so this needs either an injectable clock or a test-only `onWatchdogTick()` entry point;
* then the three blocks below become synchronous: feed EP6, stop feeding, advance the clock, assert the run byte appeared on the sink, feed EP6 again, assert the counters. **No socket, no wall clock, ~0 s instead of ~11 s.**

I have deliberately **not** built that seam in this PR. It is a change to `MetisClient`'s test surface that belongs to aethersdr#5254 and affects the other three fixtures' extraction too, and bundling it here would make a bug fix carry an architecture decision. **Maintainer's call**: accept the growth as a stop-gap with a note on aethersdr#5254, or ask for the seam and a socket-free driver and I will do that instead. Either answer is cheap for me to act on; the header comment and the test's own header now say which of the two this is, so a later reader does not have to reconstruct it.

The honest coverage boundary, stated because @ten9876 did not run the target and does not count these assertions as delivered coverage: a fake radio proves the client agrees with **our model** of the gateware, not with the gateware.

## The tests

Three silences, each a distinct claim. The **negative control runs first**: everything before it is a healthy session plus two deliberate receiver-count restarts, and none of it may trip a recovery.

1. **A radio that kept counting.** The fake advances `ep6_seq_no` by 761 across the wedge and honours the run command. The recovery completes, and `droppedPackets()` advances by exactly 761 — *the forward gap is still scored as loss.* Reset `m_haveRxSeq` in stage 1 and this one reports none.
2. **A radio that really halted.** The fake restarts `ep6_seq_no` at zero on the run command. The recovery completes, and `droppedPackets()` does **not** move — *a restarted sequence is a reset, not four billion dropped packets.* Drop the `gap < 0x80000000u` guard and this one reports four billion.
3. **A radio that never comes back.** Every run command ignored. The retry budget is spent, `linkDown` is emitted exactly once — and, new this round, **five run commands are asserted to have gone out** (stage 1's, plus four from the retry), so the failure block cannot pass on a recovery that armed a counter and never touched the socket.

A recovered silence also asserts what must **not** happen: no `linkDown`, no `linkUp`, no `metis-stop`.

**What is NOT asserted, and I would rather say it than have it found.** Stage 1 stopping the bandscope gate has **no test behind it**. The mutant that would catch it is one line — delete `resetBandscopeGate()` from stage 1 — and nothing in the suite goes red. Two things stand in the way, and both are the same argument as the section above:

* the socket-free `hl2_ep4_gate_test` already drives `onWatchdogTick()` through `MetisClientTestAccess` and asserts the stage-2 teardown, including `wide_spectrum` lowered on the wire. It **cannot reach stage 1**, because stage 1 requires `m_socket` and that file deliberately has none. With the injectable sink described above it reaches it in three lines.
* covering it in the socket fixture means running the gate there, and the gate's own run bytes are indistinguishable on the wire from the recovery's (`bandscopeDisarm` sends `metisRunCommand(false, …)` — byte-identical to `0x01`). That makes the "exactly five run commands" count non-deterministic, so it would need a **fourth** silence block and another ~5 s of wall clock to keep both claims.

I did not spend either without a ruling on the section above. If the answer is "build the seam", this is the first thing it buys.

**Cost in wall clock:** the target grows by ~3 s over the previous round (three silences instead of two), ~11 s total for the silence blocks. No `TIMEOUT` property is set, so the 1500 s ctest default applies.

## Proved by mutation — my own output, re-run on this tree

Each cycle mutates, rebuilds, **reads the `Building CXX` line back to confirm the mutated TU recompiled**, runs, then restores by *rewriting* the file (mtime moves, so ninja cannot skip the restore rebuild) and reruns.

**M4 — disable stage 1 entirely; never re-send the run command:**

```
[2/7] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o
FAIL: the silence watchdog re-sent the run command instead of declaring link loss
FAIL: exactly one run command went out for the silence
FAIL: EP6 resumed: the radio was restarted by the run command
FAIL: and the recovery is recorded as completed, not merely attempted
FAIL: a real forward gap across the silence is still counted as loss -- the recovery does not reset sequence tracking
FAIL: a recovered silence emits no linkDown
FAIL: a second silence gets its own recovery
FAIL: and this one completes too
FAIL: EP6 resumed, counting from zero again
FAIL: a third silence gets its own recovery
FAIL: which does NOT complete, because the radio never came back
FAIL: five run commands went out for the unrecoverable silence -- one from stage 1 and four from the retry -- and then the budget was spent
FAIL: an unrecoverable silence still reports link loss
   => MUTANT KILLED (non-zero exit)
```

**M5 — remove stage 2's fall-through, so a silence never becomes link loss.** Exactly one assertion fails, and it is the one that exists to stop this recovery from becoming a way of never reporting loss:

```
[2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o
FAIL: an unrecoverable silence still reports link loss
   => MUTANT KILLED (non-zero exit)
```

**M6 — `m_haveRxSeq = false;` added to stage 1**, i.e. do what `setReceiverCount()` does. This is the mutant the sixty-line comment needed and did not have:

```
[2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o
FAIL: a real forward gap across the silence is still counted as loss -- the recovery does not reset sequence tracking
   => MUTANT KILLED (non-zero exit)
```

**M7 — remove the backward-gap guard (`if (gap < 0x80000000u)` → `if (true)`)**, the pre-existing code the comment's second bullet leans on:

```
[2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o
FAIL: a restarted sequence is a RESET, not four billion dropped packets
   => MUTANT KILLED (non-zero exit)
```

**M8 — remove `resetBandscopeGate()` from stage 1**, i.e. put the gate-cycling behaviour back:

```
[2/5] Building CXX object CMakeFiles/aethercore.dir/src/core/backends/hl2/MetisClient.cpp.o
hl2_receiver_count_restart_test: all checks passed
   => MUTANT SURVIVED
```

**M8 is reported because it survived**, not in spite of it. It is the experiment behind "the gate stop has no test behind it" above, rather than an assertion that it has none. Every other mutant was killed, and the restore rebuild was confirmed recompiling `MetisClient.cpp.o` in both directions each cycle.

## Gateware provenance

The RTL quoted above was read from a snapshot of the Hermes-Lite 2 gateware present on this machine, not from a checkout with a verifiable object. The lab notes record it as `883a338`, the build this radio reports; **that commit id is not independently verified here.** @ten9876 independently checked every quote against the public Hermes-Lite2 master and reports it agrees line for line, which is a stronger provenance than this PR could offer on its own.

Nothing in this PR was tried on a radio — no key-down, no antenna port. No transmit behaviour changes; `metisRunCommand` sets bits 0, 1 and 7 of an `EF FE 04` datagram and PTT lives in the EP2 C&C payload.

## On "S3 3.4"

Dropped from the title and from the code. It was a row in an internal lab plan, **not** this repository's `docs/architecture/hl2-multi-ddc-test-matrix.md`, whose row 3.4 is "Preamp change on one pane". Citing a document a reviewer cannot open is worse than citing nothing, and this is the third time it has happened in my PRs. The behaviour is described in the title and the first section instead.

## Build and tests

* `-G Ninja`, `RelWithDebInfo`, `-DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local"`, `-DCMAKE_PREFIX_PATH=.../Qt/6.8.3/macos`, `-DENABLE_ASR=OFF`. Full build clean, `EXIT=0`.
* `AETHER_HL2_TX_TXA=1`; **`HAVE_HIDAPI` absent from this build's `build.ninja`** (`grep -c` is 0) — no hidapi reachable from this configure line on this host.
* `ctest -R '^(hl2_|control_receive_test)'` — **49/49 passed, 0 failed** (235.8 s). That is the subset built, not the whole suite. `hl2_receiver_count_restart_test` passes in **12.7 s**, up ~3 s on the previous round for the third silence.
* Rebased onto `origin/main` at `a0e8509c` and re-run after the rebase, not before. `backend_seam_affinity_test` is no longer a standing red on `main` (aethersdr#5865 merged), so it is not an excuse here and was not needed as one.

## Collision with aethersdr#5867

**[aethersdr#5867](aethersdr#5867 (`jcmerg`, `feat/hl2-squaresdr2-hardware-options`) touches `MetisClient.cpp` (+172/−7) and `MetisClient.h` (+70/−0) substantially. Whichever of us lands second rebases; I have no preference about the order. The sibling TX IQ FIFO underflow/overflow counters PR touches the same two files in different places, and this round also touches `Hl2Backend.cpp`/`.h`, so a textual conflict in the mirror lambda is likely rather than possible.

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

Squashed-from: aethersdr#5882

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
## Problem

An IC-7300MK2 connected over RS-BA1 was still treated as an experimental
Icom radio. The title bar briefly showed the **EXPERIMENTAL** badge, the
experimental-support dialog opened, and connect-time Flex-only commands
triggered the status-bar warning that the radio did not support a
control.

## RFC approval and production status

[RFC aethersdr#5517 — Promote IC-7300MK2 to
Supported](aethersdr#5517) is
maintainer-approved for the **IC-7300MK2 over built-in Ethernet/RS-BA1
only**. The approval explicitly does not promote the original IC-7300,
IC-705, IC-9700, unknown Icom models, or the Icom family as a whole.

This PR implements the in-app portion of that model-specific promotion:
it moves the Ethernet/RS-BA1 IC-7300MK2 from **Experimental** treatment
to **production/supported** treatment by removing its experimental
title-bar badge and connection disclaimer. Other Icom models retain the
existing experimental UX.

## Change

- Classify only the CI-V-identified `IC-7300MK2` as non-experimental.
The canonical model comes from the backend's verified CI-V `19 00` model
ID (`0xB6`); other and unknown Icom models remain experimental.
- Hold experimental UI classification while Icom identity is in flight.
An ordinary connect falls back to the family warning after the backend's
five-attempt identity window, so an unidentified Icom is not promoted by
omission. A wake reconnect (Wake Icom on connect) retries identity for
far longer, so it holds on `RadioModel::radioWakeActive()` instead and
re-evaluates when the wake ends.
- Re-evaluate the title-bar badge and notice when canonical capabilities
publish the identified model. A deferred notice is dropped if a later
re-evaluation has superseded it.
- Gate connect-time Flex GUI-client, pan `xpixels`/`ypixels`, RF-gain
discovery, waterfall, WNB, and TX DAX writes on the Flex command plane
instead of suppressing the unsupported-command warning. These gates
apply to every backend without a Flex command plane — **Hermes-Lite 2
and ANAN as well as Icom**; Flex and the demo simulator are unchanged.
Only the pan-dimension *write* is skipped; the local widget rescale
still runs (aethersdr#4448).
- `TransmitModel::resetState()` clears `daxOn` on disconnect. A backend
without a Flex command plane never echoes `dax=`, so a Flex session's
DAX=on would otherwise carry into the next radio's session. On those
backends `daxOn()` no longer follows digital modes; `setDaxTxMode()`
still switches the local TX feed.

Maintainer ruling: the in-app promotion lands ahead of RFC aethersdr#5517's
three-host label gate; `README.md` keeps **early** until the aethersdr#5517 docs
PR.

## Validation

- `cmake --build build -j22 --target AetherSDR icom_family_test
hl2_pc_audio_lock_test`
- `QT_QPA_PLATFORM=offscreen ctest --test-dir build -R
'^(icom_family_test|hl2_pc_audio_lock_test)$' -j22 --no-tests=error
--output-on-failure` — 2/2 passed.
- `icom_family_test` pins the identity-hold decision (ordinary → bounded
window, wake → until the wake ends); forcing the wake branch to the
ordinary window fails it. `transmit_model_test` pins the `daxOn` reset;
deleting the reset fails it.
- Mutation checks:
- Breaking the IC-7300MK2 model exception failed the exact
classification assertion.
- Removing one Flex command-plane guard failed the zero-drop Icom
display-sync assertion.
- Static validation passed: engine boundary, command-plane freeze,
capability records, test registration, touchpoint manifest,
accessibility checker, and `git diff --check`. Existing tracked warnings
remain unchanged.

## Live automation-bridge proof

RX-only connection to the lab IC-7300MK2 over RS-BA1:

- `get radio`: connected model `IC-7300MK2`, one pan, one slice,
`transmitting:false`.
- Settled `dumpTree`: `experimentalRadioBadge` exists but reports
`visible:false`.
- No message-box root or experimental-support dialog was present after
the delayed identity window.
- The visible status bar reported an empty `statusMessage`.
- The marked connection log window contained zero `commandDropped` / `no
command plane` events.

No TX environment flag, keying action, or transmit authorization was
used. This proves live identity/connect and the three requested UI
surfaces; it is not TX evidence or cross-platform hardware evidence.

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>
## Summary

Add a standard Window menu between View and Help that discovers visible
top-level application windows whenever it opens. It provides Minimize,
Zoom/Maximize, Full Screen, Bring All to Front, and a checked window
list with minimized-state labels, deterministic duplicate numbering, and
guarded activation. Transient Qt surfaces are excluded. The existing
Canvas Windows submenu retains its open/close semantics.

Minimize and Full Screen remain configurable through Configure
Shortcuts. Their application-wide window-management policy works in
secondary windows, with operating shortcuts disabled, and during
text/slider focus. Other operating shortcuts retain their existing
context and guards; keying actions cannot acquire the exemption. Minimal
Mode uses the fixed Ctrl+Shift+M binding on every platform, freeing
Command-M for macOS Minimize.

Closes aethersdr#5889.

## Validation

- Full AetherSDR build on Linux, integrated with main `199e2612c` at
signed head `203e3b7c2`.
- Focused CTests: `window_show_state_test`, `shortcut_manager_test`,
`window_shortcut_test` — 3/3 passed.
- Six deliberate mutations fail: popup filter removal, sort removal,
window-only shortcut context, restored operating guard, slider-lease
disabling, and an attempted keying-policy exemption.
- Isolated offscreen demo-only app (`DEMO-0001`, TX disabled): actual
QTest F11 delivery changes both main and active AetherTX `normal →
fullscreen → normal`, with operating shortcuts off and on. Dynamic menu
restores minimized AetherTX, preserves maximized state, and discovers
AetherControl.
- Strict engine-boundary, capability-record, command-plane, and
touchpoint-manifest checks match main byte-for-byte, including tracked
warnings; test registration, frozen CI gate, and whitespace checks pass.
- The new key-event test is socket-free, registered in
`tests/tests.cmake`, and uses the isolated settings profile. It runs in
the existing unfiltered main/sanitizer suites; no PR workflow or frozen
gate was changed.

Maintainer decision for this PR: accept offscreen proof plus fresh CI
and waive the remaining native Wayland/X11/macOS/Windows activation
demonstrations. Native compositor activation is not claimed as verified.

AI-assisted implementation and remediation.

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Jeremy [KK7GWY] <105957118+ten9876@users.noreply.github.com>
…84 ms sooner after an unkey. Principle VIII. (aethersdr#5954)

Fixes aethersdr#5498.

## What aethersdr#5498 measured, and what it turned out to be

aethersdr#5498 reported about 0.17 s of receive audio lost after every unkey on a
Hermes-Lite 2, ending in a hard step. **The audio is not lost.** With a
signal left on the air across the whole over, it reappears after the
unmute exactly on the receive chain's own latency, and no later:

| | back to −6 dB after the unmute | same chain, never muted, signal
starts |
|---|---|---|
| USB, linear phase (`main`) | 128 ms | 128 ms |

Every input sample after the unmute is played. It is just delayed by the
same ~130 ms that every HL2 receive sample always is. (The never-muted
reference is primed with signal first, so WDSP's one-time startup
up-slew doesn't add ~23 ms to it.) aethersdr#5498's figure starts timing at the
unmute, so it counted that delay as a dropout. The "step" the band
returns on is the filter's normal onset. It has the same 5–16 ms rise as
any signal appearing on a chain that was never muted, not an artefact of
the mute.

So the lever this PR pulls is the latency itself. (on8st's "RXA hold"
leg on aethersdr#5498 is another lever: it removed the hole at `final`, but
replays ~130 ms of pre-transmit audio after the over. The ruling below
takes the latency instead.) That latency is dominated by the RX
bandpass: an 8192-tap **linear-phase** FIR, which delays everything by
half its length (4096 samples, ~85 ms at the 48 kHz DSP rate).

## The change (maintainer ruling, KK7GWY, 2026-09-26)

**Outside CW, the RX bandpass runs at minimum phase.** It's the same
filter length, so the magnitude response, notch depth and 50 Hz notch
floor (`kMinNotchWidthHz`) are unchanged. Only the group delay
collapses:

| | return after unmute, AGC off | AGC medium |
|---|---|---|
| linear phase (`main`, and CW after this PR) | 128 ms | 137 ms |
| **minimum phase (every other mode after this PR)** | **44 ms** | **53
ms** |

That is 84 ms less latency on all receive audio in SSB, AM, FM and
digital modes, not only after an unkey.

**CW keeps linear phase.** on8st measured the cost on aethersdr#5578: through a
300 Hz CW filter, minimum phase raises overshoot from 5.8 to 19.6 % and
strengthens the post-edge ring (−33 → −21 dB at +10 ms). Through an
SSB-wide passband keying edges are essentially unchanged, and data modes
see under two samples of differential delay.

How it's wired: `Hl2RxDsp::rxMinimumPhaseFor(mode)` picks the phase, and
the phase follows the mode on all three routes a mode reaches the
channel:
- `buildChannel()` opens the channel in the right phase;
- `setMode()` moves it on a live mode change
(`WdspChannel::setMinimumPhase()`, which returns early when nothing
changes and keeps the notch database);
- `installChannel()` re-applies it for a mode that changed while a
background rebuild was in flight.

**The fade aethersdr#5498 also asked for is not added.** The return is the
filter's own onset, the same rise a never-muted chain shows, so this PR
treats that half of the issue as answered by the measurement above
rather than by a ramp.

**WDSP patch 12: the minimum-phase design FFTs plan with
`FFTW_ESTIMATE`.** `create_minphase()` built four `FFTW_PATIENT` plans
at `nc × pfactor`, which is 131072 points for the bandpass cores at 8192
taps. HL2 on `main` never planned those sizes, so no wisdom file has
them. They run only when a filter is designed, never per sample.
Measured with an empty wisdom cache and no planner limit, one RX
`WdspChannel::create()` at 8192 taps:

| | before | patch 12 |
|---|---|---|
| linear open | 36.4 s | 10.9 s |
| minimum-phase open | 103.9 s | 10.9 s |
| cold `setMinimumPhase(true)` on `hl2-io` | 69.3 s | 27 ms |

Without it, every upgraded HL2 install would pay about 70 s once on its
next connect, and a first-ever connect on a slow host would approach the
600 s DSP-setup limit. The patch applies to every caller, so it also
covers the equaliser core that upstream builds minimum-phase on every RX
and TX channel (all backends). The designed taps differ only at
FFT-rounding level. Documented in
`third_party/wdsp/AETHERSDR-PATCHES.md`.

**Not changed:** the tap count, CW behaviour, aethersdr#5850's 70 ms unkey hold
(that covers the radio's T/R turnaround and is independent of this), and
every other backend's filter phase.

## Evidence

**New `hl2_rxdsp_unmute_return_test`.** Socket-free: a real `Hl2RxDsp`,
EP6-shaped blocks, AGC off so it times the filter rather than the AGC.
The tone stays on the air before, during and after a 512 ms mute.

```
MEASURED: USB return after unmute 44.0 ms; fresh-onset latency 44.0 ms
MEASURED: CWU return after unmute 128.0 ms; fresh-onset latency 128.0 ms
MEASURED: USB then setMode(CWU): return 128.0 ms
MEASURED: CWU then setMode(USB): return 44.0 ms
MEASURED: rebuilt for USB, CWU set mid-build: return 128.0 ms
```

19/19 checks pass. Mutations, each applied, rebuilt, run and reverted,
with the source hash-checked afterwards:

| mutation | FAILs |
|---|---|
| `setMode()` no longer moves the phase | 2 (both live-switch legs) |
| `installChannel()` no longer re-applies it | 1 (the mid-rebuild leg) |
| minimum phase in CW too (ruling ignored) | 2 |
| linear phase everywhere (the old behaviour) | 3 |
| `buildChannel()` opens linear | 0 |

The last row is expected, and it's why that line's comment calls itself
an optimisation. `configure()` also ends in `installChannel()`, so the
re-apply decides the phase. Opening in the right phase only saves
re-planning six masks under the process-wide FFTW lock on every build.

**The property aethersdr#5498 is actually about is pinned too.** "The return is
no slower than the chain's own latency" passes on both phases, and on
`main`, because it has always been true.

**`hl2_noise_blanker_test` now measures both phases.** Its "the blanker
at least halves the impulse peak" was calibrated against linear phase.
The peak is read after the bandpass, and minimum phase peaks an
**unblanked** impulse 2.1 dB lower (0.627 → 0.490), while the blanked
residue moves only 0.271 → 0.293. So the USB ratio went 0.43 → 0.60
without the blanker changing at all. The test now runs the same pair of
passes on a CWU (linear-phase) chain and keeps "at least halves" there
(measured 0.432). On USB it asserts "at least a third off" (0.598). A
no-op blanker reads ~1.0 and fails both.

**Local suite:** full build; `ctest -R 'hl2|wdsp|spectrum|anan|rtl'`
passes 75/75 with patch 12 in (including `wdsp_channel_test` and
`hl2_txdsp_test`). The four decoupling gates exit 0. No production
`#include` changed.

## What was not verified

- **Not driven through the agent automation bridge.** The HL2 receive
chain can't be reached from the demo simulator, and a before/after unkey
on hardware needs a transmit key, which this run had no authorization
for. The evidence is offline: the real `Hl2RxDsp` and WDSP, no radio.
- **No listening test.** Whether minimum phase is audible on SSB voice
is not measured. on8st's aethersdr#5578 data covers keying edges and group-delay
spread, not voice.
- **The AGC-medium figures come from the same offline chain.** On-air
AGC behaviour after a real over was not re-measured.

## Relation to aethersdr#5578

aethersdr#5578 asks to cut the same 64–85 ms, by switching tap counts on notch
presence. This PR removes it outside CW without touching the tap count,
so notches keep their 50 Hz floor. CW still carries the full delay, so
aethersdr#5578's question remains open for CW.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hand-maintaining it. Principle XI. (aethersdr#5897)

Closes aethersdr#5868.

aethersdr#5868 (@G6PWY-Chris) asks for a `Static checks` step that fails the PR
when a
declared seam signal has no probe-table entry. This is that step — and
the
argument that the table should not be a hand-maintained list at all.

**A checker keeps two lists honest. A generator leaves one.**
`tools/gen_seam_probe_table.py` derives the table from
`IRadioBackend.h`'s `signals:` section and writes
`tests/SeamSignalProbeTable.inc`; `--check` is the CI gate aethersdr#5868 asks
for, and
falls out of generation for free. The divergence stops being *reported*
and
starts being *unrepresentable*.

### Why the PR could not see it — narrowly

Detection was **late, not absent** — and noisier than "a sticky issue"
suggests. `full-suite.yml` runs unfiltered `ctest` on every push to
`main` and
opens or updates a sticky issue keyed by a fingerprint over the sorted
set of
failing test names. `sha256("backend_seam_affinity_test")[:12]` is
`9aedc16d4457`, which is the tag already carried by aethersdr#5661 — open since
2026-09-13 for an unrelated *intermittent* `SimBackend` rule-6 race in
that
same test. So the five red runs of 2026-09-20 arrived as comments on an
issue
titled for a different defect, no `[full-suite]` issue was filed for the
day
at all, and separating the two was @G6PWY-Chris's work rather than the
workflow's. That is how aethersdr#5825 surfaced.

Nor is the per-PR lane blind to whole-tree invariants —
`static-checks.yml`
already runs nine build-free ones.

The true statement is the narrower one aethersdr#5868 makes: **this invariant is
expressed as a ctest, and no PR lane runs `ctest` over it.**
`.github/ci-test-gate.txt` is frozen so `backend_seam_affinity_test`
cannot
join the per-PR allow-list, and `ci.yml`'s Linux job runs no `ctest` at
all.
So the assertion first executes post-merge. aethersdr#5825 added
`autoRfGainArmSettled` to the header and not to the table: every check
on that
PR was green and none of them could have been otherwise; `main` went red
on
the merge with `51 probed, 52 declared`. @G6PWY-Chris bisected that to
`03ddf480` and had the one-line repair open in aethersdr#5865 thirty-eight
minutes
after the red `Full Suite` run finished, then filed aethersdr#5868 for the lane
that
let it through rather than stopping at the repair. The breakage was
ours;
the diagnosis, the repair and the issue this PR closes are his.

### What changed

| file | |
|---|---|
| `tools/gen_seam_probe_table.py` | **new.** Parses the `signals:`
section; writes the table in declaration order. `--check` exits 1 naming
the missing or extra signal. stdlib only. |
| `tools/test_gen_seam_probe_table.py` | **new.** 21 scanner tests: the
two false positives the triage comment found by running the naive
version, plus every construct that would otherwise shorten the table
silently. |
| `tests/SeamSignalProbeTable.inc` | **new, generated.** 52
`AETHER_SEAM_PROBE(...)` lines. |
| `tests/SeamThreadAffinityProbe.h` | `attachAllSeamSignals()`'s 52
hand-written lines become one `#include`. Same 52 signals, same set —
only the order changes, to the header's. |
| `.github/workflows/static-checks.yml` | two `run:` steps beside
`gen_touchpoint_manifest.py --check` — the table check, and the scanner
tests that hold the generator to refusing rather than under-reading.
Steps, not new files, per the workflow's own header. |
| `tests/tests.cmake` | the same `--check` plus the scanner tests as two
`add_test()` entries, so `ctest` says so before a push does. Neither
joins the frozen per-PR gate. |

Named `gen_…` rather than the `check_seam_probe_coverage.py` the issue
suggests, to sit with `gen_touchpoint_manifest.py`, `gen_theme_seed.py`
and
`gen_bridge_docs.py` — all of which are generators whose `--check` is
the CI
gate. The workflow step is unchanged in shape.

### The parse details from the triage comment

All three are handled and pinned by
`tools/test_gen_seam_probe_table.py`:

1. **The signals block terminates at the next access specifier.**
Running to
   the class's closing brace also harvests `publishLegacyAudio`,
`publishLegacySliceAudio` and `warnAudioDropped`, which are `protected:`
and `private:` helpers.
`test_signals_section_ends_at_the_next_access_label`
   asserts each one stays out, and
`test_live_header_matches_the_committed_table` asserts it against the
real
   header. Nested types' own labels cannot reach the outer class
(`test_nested_scopes_cannot_close_or_open_the_section`), and a
commented-out
   or quoted label cannot forge one
   (`test_comments_and_strings_cannot_forge_structure`).
2. **The `#define` line is no longer harvestable.** The spurious probe
named
`sig` came from `#define AETHER_SEAM_PROBE(sig)` living in the same file
as
the table. It no longer does: the generated `.inc` contains nothing but
the
table, so "probed" is exactly the set it looks like — and a signal
genuinely
   named `sig` could not be masked.
3. **Fail loudly rather than fall through to "0 missing".** A
preprocessor
conditional inside the signals section raises with the directive named,
   rather than emitting whichever branch it happened to read
   (`test_conditional_compilation_in_the_signals_section_is_refused`); a
guard elsewhere in the header does not trip it. A missing class raises
rather than rendering an empty table that `--check` would then defend.

One deliberate divergence: the triage comment suggests refusing *more
than
one* `signals:` label. Several sections are ordinary C++ and their union
is
unambiguous, so the scanner merges them and a test pins that. The
refusal is
reserved for what genuinely cannot be represented in a flat list — a
signal
that exists in only some build configurations.

### On the alternative the triage comment flagged for a maintainer

That comment raises driving `attachAllSeamSignals()` from the
meta-object at
runtime (`qt_metacall` + `QMetaObject::connect`), noting it is strictly
more
robust but trades a typed compile-time `connect` for untyped metacall
machinery in a test helper.

Generation is a third option that takes the robustness without the
trade. The
hand-maintained list stops existing either way, but the probe lines
remain
ordinary `&IRadioBackend::<sig>` connects: still typed, still checked by
the
compiler, and still readable in a diff. It also keeps the reverse
direction
free — a probe for a deleted signal is a compile error, exactly as the
issue
says — while `--check` reports it earlier anyway, in seconds, without a
build.

### Demonstrations

Four mutants on this tree. `backend_seam_affinity_test` runs before and
after
the table is regenerated to show the generated table is behaviourally
the
committed one.

**1. The tree as committed — green.**

```
$ python tools/gen_seam_probe_table.py --check
seam probe table up to date (52 signals)                                    # exit 0
```

**2. A probe line deleted — red, naming the signal.**

```
$ python tools/gen_seam_probe_table.py --check
::error::tests/SeamSignalProbeTable.inc is missing a probe for: autoRfGainArmSettled
  — declared by IRadioBackend and never covered by backend_seam_affinity_test
::error::run `python tools/gen_seam_probe_table.py` and commit tests/SeamSignalProbeTable.inc
                                                                            # exit 1
```

**3. A signal added to `IRadioBackend.h` with no probe — the aethersdr#5825
scenario.**

```
$ # void droopCompensationChanged(double db);  added to the signals: section
$ python tools/gen_seam_probe_table.py --check
::error::tests/SeamSignalProbeTable.inc is missing a probe for: droopCompensationChanged
  — declared by IRadioBackend and never covered by backend_seam_affinity_test
::error::run `python tools/gen_seam_probe_table.py` and commit tests/SeamSignalProbeTable.inc
                                                                            # exit 1
```

**4. A stale probe for a signal that no longer exists.** aethersdr#5868 rightly
says
this direction needs no check, because it is already a compile error.
`--check`
reports it anyway, earlier and without a build:

```
::error::tests/SeamSignalProbeTable.inc probes a signal IRadioBackend no longer
  declares: signalThatWasRemoved                                            # exit 1
```

**5. A signal the scanner cannot read — refused, not silently dropped.**
This
is the one that matters for a *generated* table. `--check` compares the
committed table against the same parser that writes it, so a parser that
under-reads produces a table short in exactly the matching way and both
agree.
Under-reading therefore has to be loud:

```
$ # auto droopCompensationChanged() -> void;  added to the signals: section
$ python tools/gen_seam_probe_table.py --check
::error::gen_seam_probe_table: IRadioBackend's signals section contains declarations
  this scanner cannot turn into probe lines, so the table would be silently SHORT —
  the exact failure this tool exists to prevent:
::error::  - a trailing return type (`auto f() -> void`) — this scanner anchors on a
  leading `void`
::error::  - `auto droopCompensationChanged() -> void` is a declaration this scanner
  could not read
::error::Teach tools/gen_seam_probe_table.py the construct (and pin it in
  tools/test_gen_seam_probe_table.py). Refusing rather than emitting an incomplete table.
                                                                            # exit 1
```

Each mutated file was restored by rewriting it, and `--check` is green
again
after each. `tools/test_gen_seam_probe_table.py`: 21 tests, 0 failures.

**6. `backend_seam_affinity_test` before and after.** The committed
table is
the hand-written one; the generated table replaces it. Built and run
both ways
on the same tree, to show the swap is behaviourally inert:

Both legs took the machine's build slot with an `flock`
(`tools/build-slot` in
the lab), not a `pgrep` poll — the before leg queued 1355 s behind
another
agent's build, the after leg 625 s. The `Building CXX` line was read
back each
cycle to confirm the header swap actually recompiled rather than reusing
a
stale object:

```
                     before (hand-written table)      after (generated table)
[PASS] probe table covers every declared seam signal (52 probed, 52 declared)
0 failure(s)                                          0 failure(s)
exit 0                                                exit 0

$ diff <(before assertions) <(after assertions)
  37 lines each, identical — md5 04e000da43d30d54ca919c9b2d12ad9d both
```

The swap is behaviourally inert: same 36 `[PASS]` lines, same coverage
figure,
same exit. Note what the figure is now sourced from — `52 probed` comes
from a
file no human maintains, checked against moc's `52 declared` at runtime.

To be precise about what that comparison does and does not establish: it
is an
output-equality check, not a timing one, so core contention could not
have
changed the result. The slot was taken because the machine was already
running
two full builds, not because the measurement was fragile.



### Not claimed

macOS only — the build and the `backend_seam_affinity_test` runs below
are
from this machine. The CI behaviour of the new step is asserted from the
workflow's structure and from the neighbouring steps, not measured on a
runner. `--check` is stdlib Python with no Qt and no build, so the cost
claim
is a shape argument, not a timing.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…heme tokens (aethersdr#5896). Principle XI. (aethersdr#5898)

Closes aethersdr#5896.

`src/gui/RadioSetupDialog.cpp` styled its caption labels and its line
edits from
colour **literals**, painted with a plain `QWidget::setStyleSheet`:

```cpp
static const QString kLabelStyle =
    "QLabel { color: #c8d8e8; font-size: 12px; }";

static const QString kEditStyle =
    "QLineEdit { background: #1a2a3a; border: 1px solid #304050; "
    "border-radius: 3px; color: #c8d8e8; font-size: 12px; padding: 2px 4px; }";
```

Only `ThemeManager::applyStyleSheet` records a widget in
`m_trackedWidgets` for
re-resolution on `themeChanged`; a plain `setStyleSheet` does not. So
both
constants survived **View ▸ Theme** unchanged. Read out of the shipped
theme
files rather than quoted:

| token | Default Dark | Default Light |
|---|---|---|
| `color.text.primary` | `#c8d8e8` (`{color.gray.200}`) | `#1a2a3a`
(`{color.gray.50}`) |
| `color.background.1` | `#1a2a3a` (`{color.gray.800}`) | `#dde5ed`
(`{color.gray.800}`) |
| `color.background.2` | `#304050` (`{color.gray.700}`) | `#c8d2dc`
(`{color.gray.750}`) |
| `color.background.0` — the dialog's own ground | `#0f0f1a` | `#f5f5f8`
|

Under Default Light the captions painted `#c8d8e8` onto `#f5f5f8` —
near-white
on near-white — and the `QLineEdit`s kept a `#1a2a3a` fill, which is
simultaneously Light's `text.primary`, so the text landed black on
black.

`tools/migrate_colours.py` already maps both, and its module docstring
uses this
exact pair as its worked example.

## Where this sits, precisely

- **aethersdr#5848** (merged) introduced `kValueStyleTemplate` and
`makeValueLabel()` in
  this file, for the four Radio Information **value** fields.
- **aethersdr#5857** (open issue) / **aethersdr#5863** (open PR) cover the remaining
*value*
labels. Neither has merged. aethersdr#5857 scopes itself explicitly — "**Scope:
14 + 2
sites**", all of them `kValueStyle` — so the captions and the line edits
were
  never in it.

So this is not "the rest of aethersdr#5857". It is the sibling class in the same
file,
and the two changes are disjoint in which widgets they convert.

## The scope, and why it stops where it does

**The cut is "every literal that is exactly its token's Default Dark
value".**
That is not a convenience line — it is the property that makes this
change
provably invisible under the shipped default (`ThemeManager.cpp` reads
`ActiveTheme` with `"Default Dark"` as its fallback, and falls back to
it again
if the stored theme is unavailable):

| literal removed | token | Default Dark value |
|---|---|---|
| `#c8d8e8` | `color.text.primary` | `#c8d8e8` |
| `#1a2a3a` | `color.background.1` | `#1a2a3a` |
| `#304050` | `color.background.2` | `#304050` |

Each substitution is exact, so Radio Setup's dark appearance does not
move; only
Light does. The test pins that equivalence rather than asserting it.

**Two things in the same file are deliberately NOT converted, because
both would
have to change the dark appearance:**

- **`buildSerialTab`'s function-local `kLabelStyle`** — `#8898a8` at
11px, 14
sites, inside `#ifdef HAVE_SERIALPORT` (present in this build;
Qt6::SerialPort
resolves). `#8898a8` is **no** token's Default Dark value, has no
mapping in
`migrate_colours.py` and no row in `docs/theming/canonical-tokens.md`.
aethersdr#5896
proposes `color.text.secondary` (`#8ea8c0` under Dark) as *defensible
rather
than established* — that is a judgement about the tab's hierarchy and a
visible dark-theme change, so it wants its own PR and its own
screenshot. It
is marked as deferred in the source, and it no longer shadows anything.
- **The Firmware Update group's `m_fwStatusLabel`** — coloured in **13**
places,
  of which exactly **one** (the `FirmwareUploader::finished` switch's
`Outcome::Unconfirmed` branch) uses `{{color.accent.warning}}`; its two
siblings in the same `switch` are raw `#80e080` / `#e08080`, and there
is no
`{{color.accent.success}}` in that block at all. Converting one of
thirteen
  is worse than converting none, and `#6888a0` (5 of the 13) is likewise
  unmapped. Also its own change.

Nothing else in the file was touched: `kGroupStyle`, the Kiwi styles and
the
button styles keep their literals.

## What changed — one production file

- **82 plain `setStyleSheet` sites → `applyStyleSheet`**, via two
file-local
helpers `applyLabelStyle()` / `applyEditStyle()` so each site is a
one-line
swap: 53 `setStyleSheet(kLabelStyle)`, 20 `setStyleSheet(kEditStyle)`,
and the
**9** derived sites
`setStyleSheet(QString(kEditStyle).replace("QLineEdit",
"QComboBox"))` (×7) / `"QSpinBox"` (×2), which `migrate_colours.py`
lists as
  explicitly out of its scope.
- **6 `applyStyleSheet(…, <raw-hex constant>)` sites take the templates
instead.** These are the shape aethersdr#5896 names: registered for
re-resolution, and
given nothing to resolve, so they satisfied `audit_colours.py`'s
call-site
metric while staying dark. (aethersdr#5896's prose says "five" and then lists
six;
  six is right — 3 `kLabelStyle`, 3 `kEditStyle`.)
- **`kLabelStyle` and `kEditStyle` are renamed to `kLabelStyleTemplate`
/
`kEditStyleTemplate`.** A template still holding `{{tokens}}` must never
reach
`setStyleSheet`, which does not expand them; the rename makes a
regressing
call site a compile error rather than a label that paints the literal
text of
  a token name.
- **`kLpLabelStyle` / `kLpEditStyle` deleted.** The LP-100A row already
carried
byte-identical token forms of both — its comment called them
"token-based
equivalents of the file-level `kLabelStyle`/`kEditStyle`" — so that row
now
uses the file-level templates. Its reasoning is kept verbatim, because
it is
now the reasoning for the whole file. `kLpBtnStyle` stays: `kBtnStyle`
has no
  file-level token form, and its hover-tier note still applies.

The nine sweeps of the form `if (lbl->styleSheet().isEmpty())
lbl->setStyleSheet(kLabelStyle);` keep their guard and their behaviour:
`applyStyleSheet` ends in `widget->setStyleSheet(resolved)`, so a
converted
label's `styleSheet()` is non-empty from the moment it exists.

### Reach, before and after

Counted the way `tools/audit_colours.py` counts, over this file with
comments
stripped:

| | base `7c8fdf84` | head |
|---|---|---|
| `setStyleSheet(` — untracked | 261 | **179** |
| `applyStyleSheet(` — tracked | 152 | **163** |
| total | 413 (36.8 % tracked) | 342 (47.7 % tracked) |

The totals differ because 73 of the 82 converted sites now share the two
helper
call sites.

## Static gates, both sides of the merge base

Merge base is `7c8fdf84`, current `origin/main`. Both trees were scanned
by the
same tool in the same run, against an extracted copy of that base rather
than a
quoted number.

| Gate | Base `7c8fdf84` | Head | Verdict |
|---|---|---|---|
| Colour ratchet, `--src src --compare-src <base>/src --summary-only
--strict` | 606 / 2622 / 1040 | 606 / 2618 / **958** | PASS, exit 0 |

```
  OK   unique_colours       606  (base 606, +0)
  OK   total_references    2618  (base 2622, -4)
  OK   setstylesheet        958  (base 1040, -82)
```

`-82` is the converted sites. `-4` is the four literals in the two
deleted
constant bodies (`#c8d8e8` ×2, `#1a2a3a`, `#304050`). `unique_colours`
does not
move because all three colours still occur elsewhere in `src/` —
including
elsewhere in this same file, in button styles this PR does not touch.

The other nine `tools/check_*.py --strict` are unmoved: all nine produce
**byte-identical output** on both trees once `line=` numbers are
normalised
(`check_a11y`, `check_capability_records`, `check_ci_test_gate`,
`check_command_plane`, `check_engine_boundary`,
`check_network_timeouts`,
`check_shader_dialects`, `check_test_registration`, `check_theme_seed` —
all
exit 0). `check_a11y.py src/gui/RadioSetupDialog.cpp` reports **9
findings on
both sides**: the same nine `a11y-disabled-reason-not-announced`
warnings on
`mcpBtn`, `txCheck`×2, `roCheck`, `dhcpBtn`, `staticBtn`, `applyBtn`,
`cancelBtn` and `resumeDelayCheck`, each shifted by the 45 lines this
diff adds
above it. A line shift is not a new finding.

## Test

New file `tests/radio_setup_label_theme_token_test.cpp`, registered in
`tests/tests.cmake` with `${THEME_TEST_RESOURCES}`.

**The theme switch is the only discriminator, and that is the whole
shape of the
test.** Because every literal removed IS its token's Default Dark value,
a
freshly built dialog under Default Dark is byte-identical before and
after — so
a construction-time assertion passes against the broken tree. The colour
is
therefore read back **across** a switch, dark → light → dark, so a
one-way latch
cannot pass either.

Widgets are collected by **style shape with the colour left open**
(`^QLabel \{ color: #[0-9a-fA-F]{6}; font-size: 12px; \}$`), so the same
collector runs against both trees; the anchors keep value labels
(`font-weight: bold`) and the 11px serial-tab captions out of the set. A
**named floor** then pins that the collection is neither empty nor
narrowed to
the labels that happen to be right: `Nickname:`, `Callsign:`, `Station
Name:`
and ≥ 8 captions; the `Radio nickname` and `Station callsign` edits and
≥ 3 line
edits. All of them are on the eagerly built Radio page, so no deferred
page is
opened and no hardware probe runs (aethersdr#1776).

Three controls guard the oracle itself:

1. `availableThemes()` must contain both themes — without `:/themes/`
compiled
into the target, `ThemeSeedGenerated.cpp` still resolves every token, so
a
construction-time reading succeeds while `setActiveTheme()` has nothing
to
switch to and every "the colour moved" assertion compares a value with
   itself.
2. The two themes must **disagree** about all three tokens, or "it
followed the
   switch" is unobservable.
3. The three tokens must resolve under Default Dark to exactly the three
literals this PR removed — the equivalence claim above, pinned. It
cannot
agree with itself: those literals are no longer in the production file
for
   these constants.

### Red, then green

Built and run three times against the same configure, with only the
named thing
changed each time. `QT_QPA_PLATFORM=offscreen`.

| Tree | Result |
|---|---|
| **Base `7c8fdf84`**, new test present | **5 passed, 2 failed** —
`captionLabelColoursFollowAThemeSwitch` on `not registered against the
token: Serial: \| qss=QLabel { color: #c8d8e8; font-size: 12px; }`, and
`lineEditColoursFollowAThemeSwitch` on `color.background.1 not
registered on Radio nickname`. The five controls and the floor pass, so
the failures are the defect and not the harness. |
| **Base**, with the two registration loops temporarily removed | **5
passed, 2 failed** — and now the failure is the **switch**: `expected
#1a2a3a under Default Light: Serial:` and `expected #dde5ed under
Default Light: Radio nickname`. The Default **Dark** pass immediately
before it **passes**, because the literal IS the dark value. That is the
measurement behind the claim that a construction-time check cannot see
this. |
| **Head** | **7 passed, 0 failed** (283 ms). |

The second row is the one worth reading: it is the same evidence aethersdr#5863
gathered
by mutation, reached from the other side — remove the assertion that
fires
first, and what is left is a dialog that is provably correct under Dark
and
provably wrong under Light.

### Full build and suite

Clean full build of the tree from this configure: **0 errors**,
`AetherSDR.app/Contents/MacOS/AetherSDR` links. Full `ctest -j4`:
**525 of 525 passed, 0 failed**, 6 skipped, 225 s.
`radio_setup_region_field_test` and
`radio_setup_ip_config_presentation_test`
— the other two tests that build this dialog — both pass.

Configure line (the one this lab mandates):

```
cmake -B build -G Ninja \
  -DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local" \
  -DCMAKE_PREFIX_PATH=/Users/stanclaes/Qt/6.8.3/macos \
  -DENABLE_ASR=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo
```

## Landing order — two open PRs touch this file

Neither is rebased onto, and this branch is cut from `origin/main` at
`7c8fdf84`.

- **aethersdr#5867** (`jcmerg`, **+483 / −1** in `src/gui/RadioSetupDialog.cpp`)
adds
`buildHl2HardwareTab()`. Its hunks are the include block, the
constructor's
  `addPage` registrations, `showEvent`, and a large insertion after
  `buildCalibrationTab`. It references **neither** `kLabelStyle` nor
`kEditStyle`, so the conflict with this PR is positional only — whoever
lands
second re-applies the other's hunks; no call site has to be reconciled.
If
  aethersdr#5867 lands first, its new tab's labels and edits should use
  `applyLabelStyle()` / `applyEditStyle()`.
- **aethersdr#5863** (ours, open) is the closer one: it edits the **same constant
block**
— `kValueStyle` sits between `kLabelStyle` and `kEditStyle` — and
converts 16
value-label sites, some on lines adjacent to the `kLabelStyle` sweeps.
The
widget sets are disjoint; the textual overlap is the declaration block
and a
  handful of neighbouring lines.

Regions this PR touches: the file-scope constant block; `makeInfoField`
/
`makeCopyableInfoField`; every `build*Tab` that styles a caption or a
line edit;
the `buildSerialTab` shadow declaration (comment only); and the LP-100A
row in
`buildPeripheralsTab`.

## Not claimed

- **No screenshot.** Everything above is read from source and from
`resources/themes/default-{dark,light}.json`; the operator-visible claim
is
  carried by the test's theme switch, not by an image.
- The two deferred items above are **not** fixed. aethersdr#5896 asks for them;
this PR
  says why they are not here rather than leaving the gap silent.
- **`HAVE_HIDAPI` was OFF for this configure** — read from this tree's
own
configure log (`hidapi not found — USB HID encoder support disabled`; no
`HAVE_HIDAPI` entry in `CMakeCache.txt`), so the hidapi-gated targets
are not
  part of the 525 below. Nothing in this diff is hidapi-conditional.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le XI.

Capture and restore the compressor pre-drive and phase-rotator stage count in TX preset JSON. Clamp imported values to the supported ranges and preserve current values for older presets.

Add export, round-trip, legacy-compatibility and range-clamping regression tests.

Squashed-from: aethersdr#5963

Co-authored-by: Randy Nash <42493402+rnash2@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
aethersdr#5734). Principle VIII.

Fixes aethersdr#5734. Refs aethersdr#5952.

dexchange() released Sem_OutReady and only then copied its input slot out
of r1. With blockForOutput set, that release is what lets the host's
fexchange2 return, and the host's next call writes the next block into r1
at once. With inputBlockSize == dspBlockSize the ring holds two slots, so
host call w+2 writes the slot worker iteration w has not read yet. A worker
preempted between the release and its memcpy processes block w+2, or a torn
mix, in place of block w, and nothing reports it. That is how two identical
channels in runVector diverged under CPU load.

AetherSDR WDSP patch 13 moves the r1 copy ahead of the release. Nothing
else moves: out and in are separate allocations in RXA and TXA, and
r1_outidx has no other writer outside csDSP. Only bfo channels release
there, and every production channel opens non-blocking, so live audio is
unchanged. What changes is every offline blocking-mode test.

Evidence:
- Instrumented fexchange2/dexchange, first diverging block: the diverging
  channel's worker read slot 0 while the host's r1 write count went 1 -> 3.
  exchange, exec_bypass, flushflag, up/downflag and the SetChannelState
  sequence were identical on both channels, so patches 7 and 8 are not
  involved.
- Widening that window reproduces aethersdr#5734's first DIAG line exactly (RX block
  3, dL=0.00205633 dR=0.00139192, rmsB=0). rmsB=0 is the correct channel.
  The block is inside the 10 ms mute delay-up and is exact zeros.
- Standalone harness of the vendored library, Linux container, 16 nice-5
  spinners: 33/600 trials diverged before, 0/1300 after (bounded planner
  in both, so the planner theory does not explain it).

New runWorkerHandoffTest pins the ordering deterministically, with no load
needed. A test-only port pause (wdspPortSetHandoffPauseForTest, 0 in
shipping) sleeps the worker right after the release. With the reorder
reverted it fails on every run: RX block 3 by 0.00205633, TX block 6 by
1.0e-5. runVector and its 1e-6 bound are unchanged.

Squashed-from: aethersdr#5960

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…es 3-4. Principle XI.

Both parsers assumed the button bitmask lived in bytes 2-3. A raw capture
from a ShuttleXpress shows buttons 1-4 in byte 3 bits 4-7 and button 5 in
byte 4 bit 0 (the ShuttlePro v2 button 5-9 positions), so only physical
button 1 was seen, reported as button 5. ShuttlePro v2 read byte 2 (always
0) as the low byte, shifting buttons 1-8 to 9-16 and never reading 9-15.

- ShuttleXpress: pack (buf[3] >> 4) | (buf[4] & 1) << 4 into bits 0-4.
- ShuttlePro v2: buf[3] | buf[4] << 8.
- Require len >= 5 for both: m_buf is reused across reads, so a short
  report would otherwise decode a stale buf[4].
- Fix the layout comments.
- Add hid_device_parser_test with the captured reports; it fails 3
  checks on the old parser and passes on the fix.

Fixes aethersdr#5927

Squashed-from: aethersdr#5931

Co-authored-by: sq9fk <12176404+sq9fk@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…#5920)

## Summary

The ANAN panadapter returns a fixed 1024 points, stretched across the
panel.
On a ~2000-pixel panel every point covers two pixels, so the trace and
the
waterfall look soft at every sample rate. A Flex avoids this because it
is
told the panel width (`xpixels`) and returns one bin per pixel. On the
ANAN
that `xpixels` command is dropped, because there is no command plane to
carry it.

This PR passes the width to the backend, and WDSP's display analyzer
returns
one point per pixel. deskHPSDR sizes its analyzer from the panel width
in the
same way.

- **New seam `IRadioBackend::setPanPixelWidth()`**, a no-op by default.
It is
reached through `RadioModel::requestLocalPanPixelWidth()`, from the same
place in `MainWindow_Wiring.cpp` that sends the Flex `xpixels` command.
Only local-shaping backends (`shapesDisplayRatesLocally()`) take the
seam.
  A Flex still gets `display pan set … xpixels=` on the wire, unchanged.
Other local-spectrum backends used to have that text dropped. They now
get
  the call and ignore it.
- **The count is widened for the display edge crop**
(`panPointsForPixelWidth()` in `SpectrumPreviewLogic.h`), so the span
that
  stays on screen still has one point per pixel.
  `SpectrumWidget::panEdgeCropActive()` becomes public for this, and the
crop's margin moves into `panEdgeCropMarginBins()`, which computes the
same
  value as before.
- **`AnanPanAnalyzer::setNumPoints()`** changes the count without
re-planning. Every count up to WDSP's 16384-point limit fits the
16384-point
FFT. The per-point average re-seeds from the next frame, so a width
change
  never blends two point grids and never fades in from −160 dB. During a
rebuild the change is deferred and re-applied at install, like the frame
  rate.
- **The droop tables stay at 1024 points**, so stored calibrations and
the
derived defaults stay valid. They are read onto the current count by
linear
interpolation (`applyDroopCorrectionDbResampled()`). The calibrator
reads
  each frame back onto the 1024-point grid (`resampleToDroopGrid()`).

Rebased onto `main` after aethersdr#5814 and aethersdr#5824. The only conflict was a clash
of
test-group names: aethersdr#5814's follow-up added its own Group 1c, the
seed-after-switch test, at the same spot. The width test is now Group
1d.
A width change cannot hand back a frame computed on the old grid:
`SetAnalyzer()` clears every ready frame (`analyzer.c`, the `pb_ready`
reset), and nothing on this path skips it.

## Constitution principle honored

Principle IV: clean-room. This uses the in-tree WDSP analyzer. Only the
idea
of sizing the analyzer from the panel width comes from deskHPSDR. No
code
was copied.
Principle VIII: the point count, the tone's position on the wider grid,
the
re-seed and the droop resampling are pinned by tests, listed below.

## Test plan

- [x] Local build passes (`cmake --build build`)
- [x] Behaviour verified on a real radio (ANAN-G2), on the rebased
branch
      (09-22):
  - trace and waterfall sharp at 48, 192 and 1536 ksps;
  - resizing the window narrow and wide keeps every width sharp, with
signals at their frequency and no bad frame, with FFT AVG off and on;
  - with FFT AVG on, the trace returns at its level straight after a
resize, with no fade-in; the same after switching log/linear averaging;
  - a zoom after a resize comes up sharp at the current width;
  - NB still toggles.
- [x] Existing tests pass locally: full `ctest` on the rebased branch,
544 tests, 543 passed, 4 skipped. The one failure was an extra check I
had added and then removed, because it could not fail. `ctest -R anan`
      on the review follow-up `efebd7b5`: 9/9.
- [x] Static checks run locally (`tools/check_*.py --strict`): all pass.
- [ ] CI
- `anan_droop_correction_test`: resampling in both directions. The table
is read at the same fraction of the span at 1840 and 3000 points, and a
  2500-point frame is read back onto the 1024-point grid. At 1024 points
both directions are exact. The span edges are pinned to the table's
ends,
  and a one-point frame is refused without touching the output. A frame
wider than the table is averaged over each table cell rather than
sampled:
an alternating ±1 frame at 2048 points reads at most 0.0005 (0.998 with
  point sampling).
- `spectrum_preview_logic_test`: with the crop,
`panPointsForPixelWidth()`
  returns the smallest count that covers the panel, for every width from
  100 to 8000 pixels. Without the crop it returns the width itself.
- `anan_rxdsp_handedness_test` Group 1d: at 2083 points a wire tone
lands at
its frequency (peak 1301, expected 1301), both when that count is set at
configure time and when it changes mid-stream with averaging on. After
the
  change the tone reads −13.47 dB against −13.40 dB before it, so the
  average re-seeds rather than fading in. A count below 2 is ignored.

## Checklist

- [x] Commits are signed
- [x] No new flat-key `AppSettings` calls. None added.
- [x] Code is clean-room (Principle IV)
- [x] All meter UI uses `MeterSmoother`. No meter UI touched.
- [x] Documentation updated. Not applicable: no capability flag or
bridge
      verb changes.
- [x] Security-sensitive changes reference a GHSA. Not applicable.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ethersdr#5852). Principle II.

Fix the HL2 S-meter reading for the second and later receivers. The backend
published `SLC1:LEVEL` but defined only `SLC:LEVEL`, so MeterModel discarded
each reading. Decode the receiver index from the meter ID, declare a matching
per-receiver definition, and withdraw it when a receiver build or connection
is rolled back. Ignore withdrawals for meter indices that were never defined.

The first receiver and indexless meters retain their existing lookup behavior.
The source-token suffix is now a documented meter-update convention; an
explicit `sourceIndex` signal field remains a possible follow-up.

Validation: meter_model_test and hl2_slice_meter_lifecycle_test exercise the
routing and definition lifetime, including refused and superseded connects.
No dual-receiver HL2 hardware was available for a live check.

Closes aethersdr#5852.
Squashed-from: aethersdr#5866

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
…, filter board, ATU. Principle V. (aethersdr#5867)

## Summary

A bare Hermes-Lite 2, an HL2 with the AK4951 companion board ("HL2+")
and a
SquareSDR 2 are **indistinguishable over Protocol 1**: same discovery
reply, same
gateware version, no board ID anywhere on the wire. They are not the
same
hardware, and the differences are not cosmetic. The config register's
dither bit
at `0x00[11]` drives a band-voltage output on the first and switches the
local
codec's loudspeaker on the other two — one bit, and the operator cannot
know what
their checkbox does unless the application knows which board it is
talking to.

The operator is the only party who knows which board is on the bench, so
this adds
`Hl2HardwareOptions` — a per-radio feature document in the same scope,
and for the
same reason, as `Hl2FreqCal`: that one describes a crystal, this one
describes a
board.

| Setting | What it reaches |
|---|---|
| **Codec** — none / AK4951 / SquareSDR 2 | Gates the EP2 audio slot (on
a bare HL2 that slot is **not audio** — its first word per frame is
`EADDR`) and feeds the mixed speaker output to the radio's own codec, 24
→ 48 kHz |
| **Dither bit** | `C3` bit 3, surfaced with the label and meaning of
the *selected* board. The operator's on every variant — never forced.
Declaring an AK4951 seeds it on, because the gateware's own init already
turned that speaker on |
| **Random bit** | `C3` bit 4; offered for parity, no HL2 variant is
known to use it |
| **Filter board** — none / N2ADR RX+TX / N2ADR TX-only |
`applyBandFilter()` now asks for a **receive** or a **transmit** byte
instead of one byte for both. TX-only is the SquareSDR 2's wiring, plus
its optional 3 MHz receive high-pass |
| **ATU (gateware)** | `0x09[20]` while TUNE runs, for an AH-4 tuner the
gateware drives — off by default so an I2C tuner on the N2ADR IO board
is never asked to start alongside it |
| **Speaker level** | The radio's own loudspeaker, 0–100 linear |

### Changed since review — read this first if you looked at an earlier
diff

This PR **no longer contains CL1**. It was lifted onto its own branch
(`feat/hl2-cl1-external-reference`), because a GPSDO on the CL1 jack is
an
external accessory any of the three variants can have, not a statement
about
which board is on the bench — and
`docs/architecture/hl2-frequency-calibration.md` §4 had already scoped
it as a
separate change with requirements this PR did not meet.

That branch did **not** wait for this one to merge, as this paragraph
said
earlier: it is open as aethersdr#5923, stacked directly on this PR's head. GitHub
cannot
base a pull request on a branch that lives only on a fork, so aethersdr#5923
shows these
commits too — only its own top commit is the change to review there. It
has since
been exercised on hardware (a Hermes-Lite 2 with a GPSDO's 10 MHz sine
on CL1,
fourteen live switches in both directions with the converter never
losing its
clock), which is what made waiting pointless.

Four further fixes came out of the review, and two of them were defects
this PR
introduced rather than review preferences:

- **The `Hardware` document could be written to the family-wide row.**
`applyHardwareOptions()` persisted without checking `m_radioSerial`, and
`RadioSettingsScope::isValid()` only requires a non-empty *family* — so
one
`hw.set` before connect would have declared one radio's codec for
**every** HL2
the operator owns. That is the exact inverse of the per-radio argument
this
  document is built on. Guarded, mirroring `applyFreqCalPpb()`.
- **The page was gated on a family string.** `isHl2Family()` is now
`declaresHl2Extension()`, asking `backendDeclaresExtension("hl2")` — the
mechanism AGENTS.md's aethersdr#5554 notice requires and the one this page's own
reseed
  already used.
- The "Reference Clock and Tuner" group is now "Antenna Tuner", and the
last two
  CL1 mentions in `CMakeLists.txt` and the touchpoint note are gone.
- The capability map lists `hw.get` / `hw.set`, and records that —
unlike
  `freqcal` — they have **no bridge verb**.

### What does not change

**Defaults reproduce today's behaviour exactly.** `filterBoard` defaults
to N2ADR
RX+TX — the pattern this backend has driven unconditionally since it
could tune —
and every other field defaults to the bare board. The new `ccConfig()`
and
`ccTxDrive()` parameters are defaulted off, so existing callers emit the
bytes
they emitted before.

**PA enable is deliberately untouched.** `setTxDriveLevel()` still
derives it from
`drive > 0`; the ATU bit is a third, defaulted-off argument alongside
it. No PA
switch, no per-band `disablePA`, no PA trim table.

### Why the speaker level is its own fader

`AudioEngine` applies the operator's volume as a device attenuation on
the host's
`QAudioSink` (`AudioEngine.cpp:4285`), so it is **not in the samples**
at any point
upstream of the sound card and no tap could find it. deskHPSDR ties the
two
together by scaling in WDSP before the buffer that feeds both the sound
card and
the codec, but that follows from having one knob rather than from a
decision — the
speaker in the radio and the speakers on the desk are different
transducers in
different places. It is not coupled to the application's *mute* either:
same
`QAudioSink` attenuation, equally invisible here, and following it would
mean this
backend reaching into the audio engine for a fader.

## ⚠️ Not in this PR: audio routing

**The audio path is still being worked on.** Two things are deliberately
absent:

1. **Output routing.** With a codec declared, the radio's loudspeaker is
fed *in
parallel* with the computer's — there is no PC / radio / both choice
yet, and
the "PC Audio" button does not gate it. That button is a host-side
switch
(`AudioEngine::applyBackendAudioCapabilities`) controlling whether the
PC's
capture and playback streams run; for Icom it additionally reaches the
radio
through the `icom` namespace, but for HL2 it says nothing about the
codec.
2. **The radio's microphone as a TX source.** Not implemented at all —
the EP6 mic
word is still only a stride (`kRoundMicBytes` has no reader), so
transmit audio
always comes from the PC. The comment at `Hl2Backend.cpp` saying "no
on-radio
mic jacks" was true for a bare HL2 and is now false for the other two
variants;
   it is left for that change to correct.

**Groundwork already measured**, receive-only against a SquareSDR 2
(gateware 7.5),
never keyed — every `C0` byte even so MOX stays clear:

> The EP6 mic word updates at a **fixed 12 kHz** and is repeated across
rounds,
> independent of the DDC rate. Dominant run lengths were 4 at 48 kHz, 8
at 96 kHz
> and 16 at 192 kHz — 12000 Hz in every case.

That contradicts deskHPSDR's `mic_sample_divisor = rate / 48000` and its
"reduce to
48000" comment, which assume the stream runs at the DDC rate. Harmless
there, since
a zero-order hold preserves the fundamental and the pitch comes out
right anyway —
but it means a future radio-mic path needs **no anti-alias filter**: the
source is
bandlimited to 6 kHz, so decimating by `ddcRate / 24000` to reach
`submitTxAudio()`'s required 24 kHz is safe, with the hold's images at
≥12 kHz
where the transmit filter removes them.

Not yet confirmed to be an actual microphone — the level test that would
settle it
was not run, so "it is the codec's mic input" is the likely reading and
not a
measured fact. Written up in `docs/HERMES.md` §4, which recorded nothing
about
this slot before.

### Why both halves land together

The codec selection and the dither bit **cannot be split**: the bit's
meaning is
*defined by* which board is selected. Shipping the dither control
without the
codec selector would leave a control that cannot know whether it says
"band
volts" or "loudspeaker" — worse than today, not better.

The three-way enum survives that change of policy, but for a different
reason
than it was introduced with. It records **which board is fitted**, not
which
policy applies: `Ak4951` and `SquareSdr2` are behaviourally identical
today
(both only answer `hasLocalCodec()`), and they are kept apart because
the
document is already persisted as 0/1/2 at schema 1, because the UI names
a real
board to the operator, and because the boards do differ elsewhere — the
EP6
microphone word runs at a different rate on the SquareSDR 2.

The HL2 family is still `ExperimentalRadioSupport`-gated (`hl2` →
`ShowExperimentalRadioNoticeHl2V1`), so landing the variant settings and
the
speaker level together, with the routing gap stated plainly above, is
the honest
shape for this stage rather than holding half of it back.

## Constitution principle honored

**Principle V** — per-feature config ownership. One nested-JSON feature
document
per radio (`Hardware`), read-modify-written whole so a field added later
is not
dropped by an older field's write. No new flat `AppSettings` keys.

**Principle VII** — boundary input validation. Both enums clamp on read,
with
deliberately *different* fallbacks: an unrecognised codec falls back to
`None`
(the answer that cannot write into `EADDR`), an unrecognised filter
board falls
back to N2ADR RX+TX, because `None` would **release the relays** on a
radio that
has a board.

## Test plan

- [x] Local build passes (`cmake --build build`) — Qt 6.12.0, 4246
targets, no errors
- [x] Existing tests pass — **524 / 525**. The single failure,
`weather_radar_source_test`, is pre-existing and environmental (local
clock / DST); its target compiles only `weather_radar_source_test.cpp` +
`WeatherRadarSource.cpp` against `Qt6::Core`, so it is provably
independent
      of this change.
- [x] New test: `tests/hl2_hardware_document_test.cpp` — the `Hardware`
document
where it meets the settings store: the save/load round trip with both
      enums off their defaults, the read-modify-write that keeps a newer
build's field alive, and the empty-serial case that would otherwise
write the family-wide default row every other HL2 inherits. Socket-free.
- [x] `tests/hl2_band_filter_frame_test.cpp` gained the EP2 audio gate:
with no
codec declared not one audio byte reaches the slot that is `EADDR` on a
      bare HL2, and withdrawing a codec drains what was already queued.
- [x] New test: `tests/hl2_hardware_options_test.cpp`, Qt-free, pins the
policy and
all three wire encodings **verbatim against deskHPSDR's tables** — the
N2ADR
band groupings (160 m = 1, 80 m = 66, 60/40 = 68, 30/20 = 72, 17/15 =
80,
12/10 = 96), the `C3` bits, the ATU bit and the EP2 audio slot. None of
this is readable back over Protocol
      1, so that test is the only evidence there is.
- [x] Radio reachable and answering (SquareSDR 2, gateware 7.5); the 12
kHz mic
      measurement above was taken on it, receive-only
- [ ] **Behaviour not yet verified on the radio for the settings
themselves** —
the codec, dither and ATU paths are write-only on this protocol and
      were not exercised against hardware in this PR

### Merge conflicts checked

Simulated against every open PR touching `backends/hl2/` or
`RadioSetupDialog`:
**aethersdr#5832, aethersdr#5825, aethersdr#5833, aethersdr#5821, aethersdr#5848, aethersdr#5855 merge clean.** aethersdr#5850 has
exactly one
trivial conflict — both insert a line after `m_tuning = false;` in the
transport
reset (`applyAtuTuneRequest(false)` vs `applyRxAudioMute(false)`); keep
both, they
are semantically independent. aethersdr#5850's move of the mixer gate to
`m_rxAudioMuted`
is *complementary*: the codec taps sit behind that gate, so the radio's
speaker
inherits the T/R hold, which is what it should do.

## Checklist

- [x] Commits are signed
- [x] No new flat-key `AppSettings` calls — nested JSON under one key
(Principle V)
- [x] Code is clean-room — the HL2 tables come from deskHPSDR/piHPSDR
source and
      Quisk, all open source; nothing decompiled
- [x] All meter UI uses `MeterSmoother` — no meter UI in this change
- [x] Documentation updated — the 12 kHz mic measurement is written up
as a field
note in `docs/HERMES.md` §4, including what it is *not* evidence of
- [x] No `CHANGELOG.md` edits
- [x] No security-sensitive changes

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: jensenpat <patjensen@gmail.com>
…ersdr#5955)

Bumps
[jurplel/install-qt-action](https://github.com/jurplel/install-qt-action)
from 4.4.0 to 4.4.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jurplel/install-qt-action/releases">jurplel/install-qt-action's
releases</a>.</em></p>
<blockquote>
<h2>install-qt-action v4.4.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Provide default host &quot;all_os&quot; for Android and WASM targets
in Qt 6.7.0+ by <a
href="https://github.com/pzhlkj6612"><code>@​pzhlkj6612</code></a> in <a
href="https://redirect.github.com/jurplel/install-qt-action/pull/375">jurplel/install-qt-action#375</a></li>
<li>Check &quot;python&quot; or &quot;python3&quot; before running
scripts; use bash if possible by <a
href="https://github.com/pzhlkj6612"><code>@​pzhlkj6612</code></a> in <a
href="https://redirect.github.com/jurplel/install-qt-action/pull/376">jurplel/install-qt-action#376</a></li>
<li>Split aqt stdout and stderr to prevent unexpected string
concatenation by <a
href="https://github.com/pzhlkj6612"><code>@​pzhlkj6612</code></a> in <a
href="https://redirect.github.com/jurplel/install-qt-action/pull/377">jurplel/install-qt-action#377</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/jurplel/install-qt-action/compare/v4.4.0...v4.4.1">https://github.com/jurplel/install-qt-action/compare/v4.4.0...v4.4.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/jurplel/install-qt-action/commit/a9c63c7c123f3069cff414e7e482d95dfa9d8125"><code>a9c63c7</code></a>
Build v4.4.1 on branch v4</li>
<li><a
href="https://github.com/jurplel/install-qt-action/commit/4b1de714df305cbacbcf82bce58e963871c21f80"><code>4b1de71</code></a>
Merge pull request <a
href="https://redirect.github.com/jurplel/install-qt-action/issues/377">#377</a></li>
<li><a
href="https://github.com/jurplel/install-qt-action/commit/ab0179b49971f1f306ab1a1759d8322275a00d9e"><code>ab0179b</code></a>
Split aqt stdout and stderr to prevent unexpected string
concatenation</li>
<li><a
href="https://github.com/jurplel/install-qt-action/commit/65312ef606a0962fb2372197b37393792e88c2ce"><code>65312ef</code></a>
Merge pull request <a
href="https://redirect.github.com/jurplel/install-qt-action/issues/376">#376</a></li>
<li><a
href="https://github.com/jurplel/install-qt-action/commit/53ffb82d713f2bb815e121faa02746918b5da41a"><code>53ffb82</code></a>
Check &quot;python&quot; or &quot;python3&quot; before running scripts;
use bash if possible</li>
<li><a
href="https://github.com/jurplel/install-qt-action/commit/bd9d62bbe4dbf9767696b1899bf14ac1cc6c9957"><code>bd9d62b</code></a>
Merge pull request <a
href="https://redirect.github.com/jurplel/install-qt-action/issues/375">#375</a></li>
<li><a
href="https://github.com/jurplel/install-qt-action/commit/4ce84e44b849e0506d541d852792b2bf1132e2cc"><code>4ce84e4</code></a>
Provide default host &quot;all_os&quot; for Android and WASM targets in
Qt 6.7.0+</li>
<li>See full diff in <a
href="https://github.com/jurplel/install-qt-action/compare/bcb88e3bed2e992f5f9e24c0f9e364a231d278eb...a9c63c7c123f3069cff414e7e482d95dfa9d8125">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=jurplel/install-qt-action&package-manager=github_actions&previous-version=4.4.0&new-version=4.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…so pan is instant and diversity keeps one antenna per ear. Principle XI. (aethersdr#5971)

## Summary

Every client RX noise-reduction method now denoises L and R
independently, the way RN2 already did. That covers NR2, NR4, DFNR, NNR,
BNR and macOS MNR. Refs aethersdr#4182; this removes the adapter its items 1 and
3 describe, and items 2 and 4 remain.

**What was wrong**

- **NR4, DFNR, NNR, BNR.** These averaged L and R to mono, denoised the
mono, and rebuilt stereo through `MonoDspStereoAdapter`. Its balance
estimate is a power envelope with about a 1 s time constant, about 2 s
once square-rooted.
- A hard L/R pan took about **5 seconds** to land. With NR2 or RN2 it
was instant.
  - With diversity receive, both ears got the same blended antenna.
- **NR2, MNR.** These kept each channel's waveform but ran **one** noise
estimate on the L+R mix. Two antennas, or two slices panned apart for
split or SO2R, shared one compromise mask.

**What changed**

- **NR2:** `SpectralNR` owns a right-channel instance, and every setter
and reset reaches both. `processStereoSharedMask()` and its per-hop
post2 follower guard are removed.
- **NR4, DFNR, NNR:** two library instances, each with its own SRC pair,
advanced through the same frame count per block so the image cannot
drift.
- **BNR:** two NVIDIA AFX denoiser effects. I tried batching both
channels through one effect (`num_streams=2`): passing one pointer per
stream crashed inside the SDK, and a single stream-major buffer leaked
between streams. So batching is not used.
- **MNR:** one noise estimator per channel.
- **`MonoDspStereoAdapter`** and its test are deleted.
- **Lockstep:** DFNR, NNR and BNR emit the shorter of the two channels'
outputs. Identical SRC pairs keep the two equal. If they ever differ, a
`Q_ASSERT` beside each `min` aborts a debug build, and a release build
logs it once per filter instance.
- **`probeDspStereo`** (the non-RN2 modes) now asserts the new contract:
each side's output is bit-identical whatever the other side carries. It
adds `leftIndependent`, `rightIndependent`, `channelsIndependent` and
`*IndependenceMaxError` (`-1` when a run's output is empty or differs in
length, so a filter that produced nothing never reads as independent).
The L/R ratio is still reported but no longer judged. **`preserved` is
removed for these modes** (RN2 keeps it). Nothing in the repo read it;
`docs/automation-bridge.md` states the removal. The NR2 run disables
post2, whose per-instance random comfort noise would otherwise make the
three runs differ, and its result says so with `post2Disabled: true`.
- **Docs:** `docs/architecture/audio-pipeline.md`,
`docs/noise-filter-rate-domains.md` and `docs/automation-bridge.md` no
longer describe NR as a mono collapse.

**Tradeoffs, accepted by the maintainer**

- **L/R balance is not held.** A signal present in both channels is
suppressed separately on each side, and the quieter copy has the lower
SNR, so it is usually suppressed harder. `main`'s shared mask and mono
envelope held the balance by construction. Measured on synthetic tones:
- With a strong signal (steady tones, +6 dB left, light noise), the
shift is under about 1.5 dB for NR2, NR4 and BNR, up to 2.2 dB for NNR,
and 3.6–5.2 dB for DFNR.
- It grows as the quieter side nears the noise floor. On gated tones in
heavier noise, NR2 and NR4 shift +2 to +5 dB (NR2 up to about +10 dB at
48 kHz), and NNR shifts −3 to −8 dB.
- DFNR and BNR can remove a quiet steady carrier on one side entirely:
DFNR −12 dB at a 12 dB offset, and BNR tens of dB.
- The in-app probe's signal shows 0.3 dB (NR2) to 1.1 dB (DFNR). RN2 has
the same property.
- No test bounds this shift. Any fixed bound would be fitted to one
synthetic signal.
- Each method does about twice the work, and BNR holds a second GPU
engine.

**Found along the way: NVIDIA AFX stale state, fixed here, also present
on `main`.** An effect created while another keeps the SDK loaded, after
a third was destroyed, starts from the destroyed effect's leftover
recurrent state. Its first ~0.5 s then differs from a fresh start by up
to 0.27, the full signal level. Two filters running at the same time
never leak into each other: I checked idle, busy and destroyed
neighbours and concurrent different audio, and every difference was
exactly 0. `NvidiaAfxFilter` now calls `NvAFX_Reset` on every effect
after `Load` and in `reset()`. (An earlier revision of this description
called this cross-effect coupling. That was a misreading.)

## Constitution principle honored

Principle XI — Fixes Are Demonstrated. The new checks fail against
`main` and pass here; numbers below.

## Test plan

- [x] **Local build passes.** Linux, RelWithDebInfo, `ENABLE_ASR=OFF`.
- [x] **New `nr_stereo_independence_test`**, via the shared
`tests/nr_stereo_independence.h`, at 24 and 48 kHz. Checks: left output
ignores right input, right ignores left (bit-exact), and after a hard
pan step the vacated channel is more than 40 dB down within 300 ms.
  - Real NR2, NR4 and NNR pass.
  - The real DFNR model passes.
  - **Real BNR on an RTX 4090** (`AETHER_NVAFX_DIR`) passes.
  - Measured residual 165–222 dB down.
- [x] **Proven failing on `main`.** The same checks added to
`nr_rate_domain_test` (DFNR and BNR through the deterministic stand-in C
APIs) were built in a worktree at `7dcf0cd7b`:
  - every independence check fails there;
- the pan-step residual is **−6.8 to −7.0 dB after 300 ms**, against
−216 dB with this PR.
- [x] **`nr_rate_domain_test`:** the per-channel sample-count
expectation is now `2 × 96000`.
- [x] **`mac_nr_filter_test`:** the SNR check used L−R to cancel noise
through a shared mask, which is invalid with per-channel masks. It now
splits signal from noise in frequency, using tone-band power on exact
DFT bins.
- The rewritten measurement reproduces the old one: 1.98 vs 1.93 dB
input SNR, and 18.6 vs 18.8 dB improvement on `main`'s filter.
  - Independence and pan-step checks were added.
- I first ran it on Linux against a local vDSP stand-in, not committed,
which reproduces all 17 of `main`'s results exactly. macOS CI has since
run it against real Accelerate, and it passes.
- [x] **`nvidia_afx_filter_test`** passes on real hardware.
- [x] **AFX reset regression:**
- `nr_stereo_independence_test` checks that a BNR filter created while
another is alive, after a third was destroyed, matches a fresh one
bit-for-bit. It runs on the real GPU.
- `nr_rate_domain_test` checks through the stand-in API that every
effect is reset before its first `Run`, and that `reset()` resets both
effects. It runs on every CI platform.
  - Mutation: with the reset disabled, both fail. Restored, both pass.
- [x] **Passthrough can't pass:** every method must attenuate broadband
noise on both channels by at least 3 dB. Measured: NR2 −28 to −31, NR4
−5.6 to −5.8, NNR −18 to −27, DFNR −42 to −49, BNR −82 or more.
- Mutation: making `SpectralNR::processStereo` a passthrough fails this
check at both rates. The independence and pan checks alone did not catch
it.
- [x] **Windows:** `NOMINMAX` is defined before `windows.h` in
`NvidiaAfxFilter.cpp`.
- [x] **Full suite:** 546/550 pass and 3 skip.
`connection_panel_size_test` fails identically on `main`'s own build and
is unrelated.
- [x] **In-app, offscreen, no radio:** `audioCapture probeDspStereo all`
shows NR2, NR4, DFNR and NNR `ok` with **zero** independence error. BNR
also has zero independence error, now with its availability-check filter
alive alongside the three probe filters, but reports `audible: false`,
because it removes the probe's steady tones entirely, as on `main`. RN2
is unchanged and `ok`.
- [x] **Where CI runs these:** `nr_stereo_independence_test` and
`nr_rate_domain_test` are registered in `tests/tests.cmake`. They are
not in the PR merge gate's frozen `-R` list; they run in
`full-suite.yml`, the unfiltered `ctest` on every push to `main`. The PR
gate runs only `mac_nr_filter_test`, on `check-macos`. Real-model and
real-GPU results come from local runs, since CI has no GPU or AFX pack.
- [x] **Gates:** `check_ci_test_gate.py` and
`check_test_registration.py` are both OK.
- [ ] Behavior verified on a real radio: the maintainer is testing
diversity on a FLEX-8600.

## Checklist

- [x] Commits are signed (`docs/COMMIT-SIGNING.md`)
- [x] No new flat-key `AppSettings` calls
- [x] Code is clean-room
- [x] All meter UI uses `MeterSmoother` (no meter changes)
- [x] Documentation updated (`docs/`); no `CHANGELOG.md` entry
- [x] Security-sensitive changes reference a GHSA if applicable (n/a)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d, and command BYPASS→STANDBY operate-first so it no longer flashes OPERATE. Principle II.

The TGXL status carries no per-port trigger mode on either path, so the
port strips stopped guessing one:

- Direct path: a port's source cell is shown only while the tuner has a
  live reading on it, and left out otherwise.
- Relay path: port A names the connected radio while there is one, and
  port B's source cell is left out.

Port A no longer reads "NO RADIO" when disconnected; its cell is empty.
Port B no longer reads "RF SENSE".

AccessoryPortRow::setSourceVisible() hides and shows the cell. The row
still shows the cell by default, because the PGXL amplifier's strips
carry the source radio as configuration and never hide it. A hidden cell
is also left out of the spoken description, the same way the frequency
cell already is.

BYPASS→STANDBY (the STBY key and the docked rail's cycle) now sends
mode=0 before bypass=0. The radio's status between the two commands
then already reads STANDBY (operate=0). The old order had it report
operate=1 bypass=0, which flashed OPERATE. The model still applies every
status exactly as the radio reports it; only the order of the requests
changed.

Tests:
- tgxl_applet_ports_test (new) pins the relay-path source cells and the
  command order through both the STBY key and the rail.
- tgxl_panel_widgets_test pins setSourceVisible and the spoken sentence.
- pgxl_panel_test now requires the amplifier's source cell to be visible
  and spoken.

Co-authored-by: rah501xx <bob@bobholzer.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876
ten9876 force-pushed the fix/tgxl-standby-bypass-flicker branch from 0dbd4d4 to 82bf67e Compare September 27, 2026 17:07
@github-actions

Copy link
Copy Markdown

Accessibility check findings

This PR touches src/gui/ files with 49 accessibility issue(s).
Full details appear as inline annotations on the diff above.

Files scanned : 255
Files with findings: 16
Total findings : 49

See docs/a11y.md for the canonical
Qt patterns (setAccessibleName, QAccessibleValueChangeEvent,
QAccessibleInterface subclasses, etc.). Structural remediation
(keyboard navigation, custom QAccessibleInterface subclasses)
is tracked in aethersdr#3288.

Findings are warnings only — the build is not blocked.

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.