Skip to content

fix(hl2): drop spectrum and meter output from a closed receiver's DSP. Principle VIII. - #6113

Merged
ten9876 merged 3 commits into
mainfrom
fix/hl2-spectrum-producer-check
Oct 3, 2026
Merged

ten9876 merged 3 commits into
mainfrom
fix/hl2-spectrum-producer-check

Conversation

@ten9876

@ten9876 ten9876 commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #6096

What was wrong

Hl2Backend forwards three signals from each receiver's DSP to the seam under that receiver's UI number: spectrumReady → spectrumFrameReady, audioReady → slice audio, and meterUpdate → the slice S-meter. Only the audio forward checked which DSP sent the signal. The spectrum and meter forwards checked only m_ids.byUi(ui), which tells you the UI number exists but not who produced the value.

The DSP emits on the I/O thread and the forwards run on the backend's thread, so every frame and meter sample is a queued call. Closing a receiver calls disconnect(this) and deleteLater() on its DSP. Neither cancels a call that is already queued. createPanadapter() gives out the lowest free UI number, so a receiver reopened before delivery gets the closed one's number. The closed DSP's output then passed the byUi check:

  • spectrum: its frame was published as the new receiver's spectrum. fix(rtl): gate worker spectrum and read errors after disconnect. Principle VIII. #6107 noted this gap in a comment and left it open.
  • meter: its sample went into the new receiver's SMeterSmoother. A fresh smoother publishes its first reading immediately, so the stale value showed on the new receiver's S-meter. It also started the smoother's 100 ms throttle and became part of the smoothed value, so the receiver's own first reading was held back and blended with it.

Rule 6 is not involved: the DSP emits on the thread that emits linkDown, so nothing is delivered after disconnected(). That ordering argument is unchanged and the comment still states it.

Fix

src/core/backends/hl2/Hl2Backend.cpp: the spectrum and meter forwards now capture producer = QPointer<Hl2RxDsp>(dsp) and use the same guard as audioReady:
if (!producer || !receiver || receiver->dsp != producer.data()) return;
The comment at the spectrum forward states the rule for all three forwards: a value is published only while its producer is still the DSP behind its UI number.

Validation

  • hl2_pan_create_async_test (socket-free; it already closes and reopens UI 1 without a radio) gets a new section that runs twice. It attaches SeamThreadAffinityProbe to every seam signal, then:

    1. Positive control: one spectrum frame and one meter sample from UI 1's DSP both publish, so the test can fail.
    2. Queues a frame and a meter sample from that DSP, closes UI 1, reopens it, checks it got UI 1 back, then delivers the queued calls. Neither may publish.
    3. The reopened receiver's own frame publishes, and so does its own meter sample, at exactly its own value. That shows the stale sample neither started the smoother's throttle nor was blended in.
    4. Every seam signal was emitted on the backend's thread.
    • In run 1 the closed DSP is already deleted when the queued calls arrive. In run 2 a test event filter drops its DeferredDelete, so it is still alive. Run 2 tests the dsp != producer term: in run 1 the QPointer alone would catch the stale values.
  • Red before the fix: on unfixed main the stale frame was published.

  • Mutation checks, one forward at a time (each restored, sha256 compared to the fixed file, rebuilt green):

    Forward Mutation Result
    spectrum guard → !ids (old code) fails both runs
    spectrum identity term dropped fails run 2
    meter guard → !r (old code) fails both runs (stale sample published; reuser's own reading throttled/blended)
    meter identity term dropped fails run 2
    either !producer dropped passes, as expected: when the receiver has a DSP, a null producer already fails the identity test. The term is kept so all three forwards have the same shape.
  • hl2_pan_create_async_test 138/138 checks, stable over 5 runs. backend_seam_affinity_test passes. CTest: 2/2 selected, 2/2 passed. No socket-binding HL2 test was run.

  • Static gates, merge base c7513e12 vs head, identical results on both: check_engine_boundary --strict (102 findings, 0 new), gen_touchpoint_manifest --check, check_capability_records --strict (71 at the frozen 71), check_command_plane --strict (401 = baseline), gen_seam_probe_table --check.

  • No proof through the agent automation bridge. The demo sim cannot build an HL2 backend, so the unit test is the proof. This was not tested on HL2 hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g

…e VIII.

A spectrum frame queued by a receiver's DSP outlives disconnect() and
deleteLater(), and a receiver reopened before delivery takes the same UI
number, so the frame published as the new receiver's. The forward now
checks its producer is still the receiver's DSP, as the audioReady
forward beside it already does.

hl2_pan_create_async_test queues a frame from the old DSP, closes and
reopens UI 1, then delivers it.

Refs #6096

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
@ten9876
ten9876 requested a review from a team as a code owner October 3, 2026 16:11
…VIII.

The meter forward had the same gap as the spectrum one: a sample queued
by a closed receiver's DSP reached the reopened receiver's S-meter
smoother under the recycled UI number. It now checks its producer, as
the spectrum and audio forwards do.

Refs #6096

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
@ten9876 ten9876 changed the title fix(hl2): drop spectrum frames from a closed receiver's DSP. Principle VIII. fix(hl2): drop spectrum and meter output from a closed receiver's DSP. Principle VIII. Oct 3, 2026

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1. Issue fit

Partially — and the body is honest about which part. #6096 asks for two things: a structural publishSpectrumFrame() choke point on IRadioBackend mirroring publishLegacyAudio(), and verification of the RtlSdr and HL2 spectrum forwards as live Rule-6 holes. This PR does neither. What it fixes is the adjacent defect that #6107's comment explicitly flagged and left open — a queued frame/meter sample from a retired DSP publishing under a recycled UI number — and the body says "Refs #6096", not "fixes", and states plainly that "Rule 6 is not involved." That framing survives scrutiny: the DSP emits on the thread that emits linkDown, so connectedness genuinely is not the failure mode here; UI-number recycling is. #6096 stays open with the base-class gate and the RtlSdr survey unaddressed (the RTL side landed separately in c7513e1).

The recycle bug has no issue of its own, but GOVERNANCE.md does not require one for a bug fix with a stated root cause, and the root cause here is stated precisely.

2. Scope

File / group What it changes Claimed? Verdict
src/core/backends/hl2/Hl2Backend.cpp spectrumReady and meterUpdate forwards capture producer = QPointer<Hl2RxDsp>(dsp) and gain the producer-identity guard audioReady already had; the block comment is rewritten to state the rule for all three Yes In scope
tests/hl2_pan_create_async_test.cpp New aStaleSpectrumFrameDoesNotPublishOnTheReuser(bool) section run twice, plus Access::sliceMeterName, a KeepAlive event filter, and four includes Yes In scope

Everything in the diff is explained by the stated fix. Two files, two commits, one per forward. No CHANGELOG entry, no build-config churn, no UI or default changed — nothing for §6 to classify.

The one - line worth calling out, since §3 asks for it: the diff deletes a comment that names a symptom —

"Unlike audioReady below, there is no producer check: a frame queued by a closed receiver's DSP publishes if its UI number was reopened before delivery."

That removal is correct. The comment described the defect, the defect is now fixed, and the new test pins it. Not a regression.

Completeness. grep -n "connect(dsp, &Hl2RxDsp::" on the head checkout returns exactly three sites (Hl2Backend.cpp:843, :865, :887) and Hl2RxDsp.h:508-511 declares exactly three signals. All three now carry the identical guard. No sibling forward left behind.

Socket audit. No socket test added or modified. bringUp() asserts Access::transportIdle(backend), the new section drives createPanadapter/removePanadapter directly, and tests.cmake:1460-1463 links only Qt6::Core Qt6::Network Qt6::Test against an already-registered target that joins AETHER_SETTINGS_CONSUMERS (tests.cmake:7202) and isolates via TestSettingsProfile. Nothing to flag.

3. Blockers

None.

4. Nits (non-blocking)

  • The !producer term is load-bearing in a way the body undersells. The mutation table says dropping it "passes, as expected… when the receiver has a DSP, a null producer already fails the identity test. The term is kept so all three forwards have the same shape." It does more than that: it closes ABA. If the retired Hl2RxDsp is destroyed and a replacement lands at the same address and is stored as receiver->dsp, the raw pointer compare passes wrongly and only the cleared QPointer rejects. A mutation test can't reach that state deterministically, which is exactly why the term deserves a better justification than "same shape." (There is also the receiver->dsp == nullptr + concurrently-cleared-producer case, but I traced removePanadapter, buildReceivers and the mid-build failure path at Hl2Backend.cpp:2596 and found no event-loop spin between dsp = nullptr and reassignment, so I don't believe that one is reachable.) Inline comment on the spectrum guard.
  • hl2_pan_create_async_test does not gate a merge. grep -n ctest .github/workflows/ci.yml shows the only -R filters are the digital-voice set, cross_needle_meter_test, mac_nr_filter_test and asr_gpu_probe_test. This target compiles in the Linux build job and is never executed in CI. The body's "CTest: 2/2 selected, 2/2 passed" is a local run, which is fine and disclosed — just don't read the green badge as evidence the new section passed. Nothing for the author to change; noting it so the maintainer weights the evidence correctly.

5. What I tried to break

  • Does the bug exist at all, i.e. does disconnect(this) + deleteLater() cancel an already-posted call? removePanadapter (Hl2Backend.cpp:1283-1289) does both, but Qt's QMetaCallEvent::placeMetaCall invokes the stored slot object with no connection-liveness re-check, and the receiver (this, the backend) is still alive so nothing calls removePostedEvents. The premise holds. Reasoned from Qt semantics, not observed — I have no build or test run.
  • Would the test pass against unfixed main? No. The old guard is if (!m_ids.byUi(ui)) return;, and after removePanadapter(1) + createPanadapter() byUi(1) resolves to the reuser, so the stale frame publishes → framesOnUi1 == 2 → the check fails. Run 2 (KeepAlive swallowing DeferredDelete) is the one that actually exercises receiver->dsp != producer.data() rather than the QPointer, and the filter is correctly moveToThread'd onto the chain's thread before installEventFilter. The test is not asserting the implementation back to itself.
  • Is the meter assertion's exactness real or decorative? Real. WdspSMeter.h:119-142: a reset() smoother has !m_have, so feedAt stores the raw value and publishes immediately. Had the stale sample reached it, m_have would be true, the next value would be blended at kAttackAlpha/kDecayAlpha and throttled for 100 ms, so < 1e-6 against oldDbm + (kReuserDbfs - kOldDbfs) would fail. The reuser's Receiver seed; in createPanadapter is default-constructed, so its smoother is genuinely fresh.
  • Does the stricter guard drop a legitimate frame? I walked DDC renumbering: m_rx.erase + m_ids.remove shift indices while leaving UI numbers alone, and the lambda captures ui (not ddc), so a survivor's queued frame still resolves through byUi → rx(ddcIndex) → the same dsp. createPanadapter sets r->dsp synchronously via openReceiverDsp before the async WDSP build, so the reuser is never transiently dsp == nullptr in a way that would also reject its own frames.
  • Is the cross-thread QPointer read a race? The lambda runs on the backend thread while the chain is destroyed on m_ioThread. The null check reads an atomically-refcounted weak-pointer field, and producer.data() is only compared, never dereferenced, so there is no UAF even on an unlucky interleaving — and worst case it fails closed (drops a frame). This is also byte-for-byte the guard audioReady has carried on main, so the PR introduces no new pattern.
  • Collateral on existing tests. hl2_backend_seam_test.cpp:412's meterUpdate spy drives the TX telemetry path (forwardPowerWindowPeakWhileKeyed), not a receiver DSP; the other spectrumReady/meterUpdate connects in hl2_rxdsp_test, hl2_pan_averaging_test, hl2_adc_sampling_seam_test and spectrum_sequence_gap_test attach to standalone Hl2RxDsp objects with no backend in the loop. Unaffected.
  • Could not check: I could not reproduce the mutation table, the "138/138 checks, stable over 5 runs", or the static-gate counts. All findings and clearances above are reasoned from the head checkout at /tmp/aetherclaude/pr-6113, not from a build or a radio.

6. Recommendation

Approve with nits. The defect is real and precisely diagnosed, the fix is the pattern audioReady already established rather than a new invention, all three forwards are now identical so there is no sibling left broken, and the test fails against the unfixed guard in both runs — including the ABA-adjacent path that only run 2 reaches. The PR body is unusually honest: it marks the one uncovered guard term, says plainly there is no hardware or bridge proof, and does not claim to close #6096. My only substantive ask is a comment tweak explaining that !producer closes ABA rather than merely matching shape; the CI-gating note needs no action. Next step: a maintainer confirms #6096 stays open for the publishSpectrumFrame() choke point, and merges this on the unit test as the stated proof.

Thanks for the mutation table — it made the test's claims checkable without a build, which is exactly what a headless reviewer needs.


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

Comment thread src/core/backends/hl2/Hl2Backend.cpp
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
@ten9876
ten9876 merged commit 5747057 into main Oct 3, 2026
7 checks passed
@ten9876
ten9876 deleted the fix/hl2-spectrum-producer-check branch October 3, 2026 18:29
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Admin-merged on the maintainer's approval (self-authored PR, so a GitHub approval isn't possible). Head 55b777159:

Main's Full Suite on this squash commit is expected to show those four known failures until the #6122 fix lands.

ten9876 added a commit that referenced this pull request Oct 3, 2026
Picks up #6113 (HL2 closed-receiver DSP gate) and #6112 (AppImage). Clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
@on8st

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

The new section of hl2_pan_create_async_test fails on macOS, every time: 60 of 60 runs on clean main d2e85fa3 (Apple silicon, Qt 6.12.0), idle and under load.

[FAIL] the reuser has its own chain

Cause: tests/hl2_pan_create_async_test.cpp:766 compares newDsp != oldDsp by raw address. In the "deleted" run the I/O thread has already freed the old chain when createPanadapter() allocates the new one, and macOS returns the same block. A diagnostic print showed old == new in 30 of 30 runs; in the "still alive" run they differ.

The guard itself is fine: the stale frame and meter checks pass in all 60. Comparing against the QPointer (oldGuard) would hold on both platforms.

Not checked: Linux (CI's Full Suite is green), Windows, any commit after d2e85fa3.

73, ON8ST

on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 3, 2026
Brings the branch onto main at d9de044 so CI runs against it. The merge
is textually clean. Main's HL2 changes since the branch point (aethersdr#6104,
aethersdr#6113, aethersdr#6126) are in TX keying and tune, openReceiverDsp, capabilities
and the TX reset in pushInitialState(); none writes Receiver::mode,
agcMode or the held AGC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 3, 2026
…le VIII. (#6134)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Request

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

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

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

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

https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 4, 2026
…6119)

**Maintainer ruling (2026-10-03):** the default is accepted; the AGC-off
level persists per receiver in this PR, capability-shaped, in the
radio's settings document, with no schema bump; stale DIGU/DIGL
band-stack bookmarks are follow-up #6142.

Refs #5629

DIGU and DIGL open with AGC off on the Hermes-Lite 2, and the AGC-off
level that then sets their gain is remembered per receiver.

**Limits first.**

1. **The default changes for every Hermes-Lite 2.** Ruled: accepted.
Selecting DIGU or DIGL turns AGC off on that receiver, and a receiver
that comes up in DIGU or DIGL does too. Only the `hl2` family is
touched. ANAN-G2 runs the same AGC and keeps the operator's mode;
RTL-SDR offers no data mode. Neither was measured or changed.
2. **DIGU/DIGL are still up to 29 dB quieter at the default level; what
is new is that the operator's correction now sticks.** With AGC off the
gain is the AGC-off level (#6050): 10 dB at the default level of 10,
against an AGC ceiling of 39 dB at the default threshold. The 29 dB is
the difference of the two settings, not a measurement. The default level
is unchanged. An operator who raises the level (the AGC-T slider while
AGC is off) gets it back on the next launch, on that receiver. What is
not covered:
- **No schema bump** (ruled). The field is optional and `kSchemaVersion`
stays 2, as it did when the AGC pair was added (#4911). An older build
that captures over the document drops the array, and the level is 10
again after a downgrade.
- **A receiver is identified by its index.** Closing the first of two
receivers moves the second to index 0, and the next capture stores its
level there.
- **The gate reuses two existing declarations.** #6050 added no
capability of its own for the off level; `hasAgcThreshold` is documented
as "threshold/off level". The field is read and written when the backend
declares the `Agc` settings domain and `hasAgcThreshold`. No new boolean
(the capability-record count is frozen at 70).
3. **Follow-up #6142, not in this PR: a band-stack bookmark still
carries its own AGC.** A bookmark saved in DIGU/DIGL before this change
stores the AGC of that time, medium on a stock profile, and recalling it
puts medium back (`RadioModel::recallBandStackReceiveDsp`). A bookmark
saved after it stores off. Read from the source, not run.
4. **The reason for the default is an offline measurement, and this PR
adds no new one.** The decode figures are #5629's: WSJT-X's FT8 decoder
fed through the shipped WDSP chain, no radio, no real band, no static
crashes. Nothing was received on hardware for this PR.
5. **§18.5 is corrected in place.**

## What changed

**The default** (`src/core/backends/hl2/Hl2Backend.cpp`):

| event | AGC on that receiver |
|---|---|
| mode changes from a non-data mode to DIGU or DIGL | the current mode
is held, AGC goes to off |
| connect restores the receiver into DIGU or DIGL | the remembered mode
is held, AGC goes to off; a later link-up after EP6 silence does not
repeat it |
| the remembered AGC is seeded on a receiver already in DIGU or DIGL |
the seeded mode is held, AGC stays off |
| a receiver is created from the first one while that is in DIGU or DIGL
| it copies the mode, the off and the held mode |
| DIGU ↔ DIGL | unchanged |
| the same data mode is selected again | unchanged, so an operator's own
AGC choice survives |
| mode changes from DIGU/DIGL to a non-data mode | the held mode is
restored |
| the operator changes the AGC mode while in DIGU/DIGL | that is their
choice: it is kept on leaving, and nothing is restored |
| a threshold change while in DIGU/DIGL | AGC stays off, and the held
mode is still what gets remembered |

One function, `Hl2Backend::followModeAgc`, holds the rule; every site
that writes a receiver's mode or seeds its AGC calls it.

**The remembered AGC pair stays the operator's.** It is one flat setting
for the radio. The data mode's "off" never reaches it: `setSliceAgc()`,
the seed and the capture all record the held mode instead.

**The AGC-off level, per receiver** (the ruling):

- **Where it is stored.** In the radio's `OperatingState` document in
`radio_settings`, scope `(hl2, <MAC>)`, as one array: `"agcOffLevels":
[37, 52]`, one 0..100 entry per receiver index, `-1` for a receiver
without one. No flat `AppSettings` key.
- **The pattern.** `docs/agents/settings.md` names one pipeline for
state the radio cannot hold: a typed field in `RestoredRadioState`,
written and read only by `RadioStateMemory`, gated by the backend's
declaration. The AGC pair uses it, so the level sits beside the pair as
`RestoredRadioState::agcOffLevels`. The other shape in that document,
the per-domain `ext` sub-objects, holds the per-band maps of `RfGain`
and `TxSetpoints` only.
- **Capability-shaped.** `RadioStateMemory` loads and stores the array
only for a backend that declares the `Agc` domain and `hasAgcThreshold`.
There is no family check.
- **Capture.** `Hl2Backend::requestSliceAgc()` records the level for
that receiver and asks for a capture; `currentOperatingState()` reports
the list.
- **Restore.** `applyRestoredState()` validates each entry (outside
0..100 is dropped, not clamped) and `seedReceiverAgc()` gives each
receiver its own entry at connect, where the AGC pair is seeded.
`pushInitialState()` and `finishReceiverDspBuild()` already push the
receiver's level to WDSP (#6050).
- **A receiver opened later** (`createPanadapter()`) takes the entry for
its index, else the default. A receiver that is closed keeps its entry.
- **No entry, or no array:** the default of 10. A document written
before this change loads as before.
- **No migration.** `settings.md` asks for one when a key is renamed or
restructured; this is a new optional field.
- **Other families do nothing with it.** Flex, Sim and Icom declare no
settings domains. ANAN-G2 declares `hasAgcThreshold` but not the `Agc`
domain, and RTL-SDR declares neither, so `RadioStateMemory` hands them
an empty list and writes no array for them. No backend other than
`Hl2Backend` reads the field.

**Docs and comments:**

- `docs/HERMES.md` §13: item 30 is marked done with the measured result;
item 26 no longer lists the `Wideband` tap as open; item 28 records that
the HL2's tap-kinds answer is `Demod` only.
- `docs/HERMES.md` §18.5: the `tap` paragraph states the measured
mechanism, the level step of limit 2, that the level is remembered per
receiver, and that the default is HL2-only.
- `docs/HERMES.md` §5 (AGC): a paragraph on the per-receiver AGC-off
level: field, document, gate, seed, validation.
- `docs/architecture/radio-capabilities-map.md`: the
`clientSettingsDomains` row names the field, its gate and which families
ignore it.
- `src/core/backends/hl2/Hl2RxDsp.h` and `src/core/dsp/WdspChannel.h`:
the `blockForOutput` comments no longer claim a burst feed is
deterministic.

## Tests

All without a radio.

| test | what it pins |
|---|---|
| `hl2_tci_signaling_test`, `testDigitalModeDefaultAgc` | the
mode-change rows of the table, "the data mode's off is not captured",
and the capture fallback before any AGC was set |
| `hl2_tci_signaling_test`, `testAgcOffLevelCapture` | without a socket:
a restored `{20, 61}`, receiver 0 set to 37 → one capture request and
the capture `{37, 61}` |
| `hl2_state_restore_test` (default) | restore into DIGU; a second
link-up after an operator AGC choice; the seed on a receiver already in
DIGL |
| `hl2_state_restore_test` (off level) | level 37 on receiver 0 and 52
on receiver 1 → `currentOperatingState()` → `RadioStateMemory::store` →
a new backend → `RadioStateMemory::load` → `applyRestoredState()` →
connect: each receiver publishes its own level, **each WDSP channel
reports the matching fixed gain** (`dspChains()`, what the channel
accepted), and the next capture is unchanged. Also: a change asks for a
capture; a radio swap does not carry the levels across; a document
without the array gives 10 on both receivers and 10 dB in the channel;
an out-of-range entry falls to the default and its neighbour holds |
| `radio_state_memory_test` | the array round-trips with a hole; it is
one array in the `OperatingState` document; a backend without
`hasAgcThreshold` is handed none and still gets the AGC pair; a backend
without the `Agc` domain is handed none; a store without the capability
writes no array; a document without the field loads; a state carrying
only levels is not "nothing stored" |
| `hl2_pan_create_async_test` | a receiver created beside a DIGU one
opens on off and returns to the first receiver's held AGC; a receiver
opened later takes the level remembered for its index |

**Fail-before, the off level.** The new tests on `bbddff97`'s source,
plus the one struct field they need to compile (a throwaway commit, full
suite): ten checks red.

```
[FAIL] an AGC-off level change asks for a capture
[FAIL] the capture carries each receiver's AGC-off level
[FAIL] a capture before the connect keeps the remembered AGC-off levels
[FAIL] a new backend restores each receiver's own AGC-off level
[FAIL] and each WDSP channel accepted the restored fixed gain
[FAIL] the restored AGC-off levels are captured again unchanged
[FAIL] an out-of-range AGC-off level falls to the default; its neighbour holds
[FAIL] the per-receiver AGC-off levels round-trip, holes included
[FAIL] they are one array in the radio's OperatingState document
[FAIL] a new receiver opens on the AGC-off level remembered for its index
```

Two mutations of the head, each in a full-suite run:

| mutation | red checks |
|---|---|
| `seedReceiverAgc()` does not write the receiver's level (the restore)
| 4, all in `hl2_state_restore_test`: the restore, the WDSP gain, the
second capture, the out-of-range case |
| `currentOperatingState()` does not report the levels (the capture) |
5, all in `hl2_state_restore_test`: the capture, the capture before
connect, and the three restore checks that depend on it |

**Fail-before, the default** (earlier heads): the mode-change checks
were red on main when the PR was opened (5 of 12); restore into DIGU,
the seed in DIGL and the pan seed were red on `540e149e`'s source.

**Full suite at the head** (macOS, Apple Silicon, Qt 6.12.0,
RelWithDebInfo): 612 of 615 at `-j8`, the same three on the serial
rerun. None is from this PR:

- `hl2_pan_create_async_test`, one check, "the reuser has its own chain"
(#6113). It fails on clean main at this branch's merge base `d9de044c`,
and passes in the tree merged with main at `5fae2f86`.
- `peripheral_auth_dialog_test` (#6008). It fails on clean main at
`d9de044c` and in the merged tree.
- `vkamp_connection_test`, one check, "out-of-range antenna ports never
reach the wire": the #5254 race in the test. It passed in the run of the
same sources one commit earlier (the last commit is docs only).

The tree merged with main at `5fae2f86` (a throwaway merge, not pushed):
616 of 617, `peripheral_auth_dialog_test` only. `git merge-tree` against
main at `52b6f7ad` is clean; that tree was not built.

**Static checks,** every step of the "Static checks" job of
`static-checks.yml` run locally at the head: all pass, including the
capability-record convention (70 booleans, unchanged) and the QSettings
ratchet. No `src/gui` file is touched, so the a11y step has nothing to
scan. The "Sanitizer option configures" job was not re-run for this
head; no CMake file changed.

## Not verified

- **On a radio.** No HL2 was connected and nothing was decoded.
- **Across a real restart of the app.** The tests go through
`RadioStateMemory` and the settings store in one process, with a new
backend object. `RadioModel`'s debounced capture and its flush on
disconnect and on quit are the existing path for every field and were
not driven for this one.
- **The level drop.** The 29 dB in limit 2 is arithmetic on two
settings.
- **The AGC mode push.** The tests pin the AGC mode the backend holds
and publishes; that WDSP receives the mode is not asserted. The fixed
gain is asserted from the channel.
- **More than two receivers, and closing a receiver,** for the off
level. The index rule in limit 2 is read from the source.
- **Band-stack recall** (limit 3) is read from the source, not run.
- **Static crashes and other asynchronous interferers**, which #5629
named as the one regime where AGC off could behave differently.
- **Windows** locally. macOS by the author; Linux in the maintainer pass
(merged with main at `a8cfd9901`: the socket-free suite, 507 of 508, the
one failure the known local `connection_panel_size_test`, #5811).
Windows only through CI.

73, ON8ST

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants