Skip to content

fix(hl2): open DIGU/DIGL with AGC off, correct §18. Principle VIII. - #6119

Merged
ten9876 merged 8 commits into
aethersdr:mainfrom
on8st:fix/digital-mode-default-agc
Oct 4, 2026
Merged

ten9876 merged 8 commits into
aethersdr:mainfrom
on8st:fix/digital-mode-default-agc

Conversation

@on8st

@on8st on8st commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

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 (feat(hl2): route APF and the AGC-off level to WDSP. Principle VIII. #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 (fix(hl2): persist the operator's AGC mode and threshold across restarts — Principle III. #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. feat(hl2): route APF and the AGC-off level to WDSP. Principle VIII. #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 HL2: an old DIGU/DIGL band-stack bookmark re-applies medium AGC over the data-mode AGC off #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 §13 item 30 measured: TCI's post-AGC feed costs decodes only beside a +30 dB neighbour, and the Wideband tap is not the fix #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 (feat(hl2): route APF and the AGC-off level to WDSP. Principle VIII. #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:

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 §13 item 30 measured: TCI's post-AGC feed costs decodes only beside a +30 dB neighbour, and the Wideband tap is not the fix #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, Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811). Windows only through CI.

73, ON8ST

🤖 Generated with Claude Code

Entering DIGU or DIGL on a Hermes-Lite 2 now turns the receiver's AGC
off, and leaving them restores the AGC mode held before. aethersdr#5629 measured
the reason offline against WSJT-X's FT8 decoder: medium AGC raises the
noise between transmissions, inflates the decoder's noise reference and
costs 32 of 82 weak decodes beside a +30 dB signal; AGC off recovers
them.

The default is adopted on a mode change only, like the per-mode
passband. An AGC mode the operator sets inside a data mode is theirs and
is kept on leaving it. The data mode's own "off" is never written to the
remembered AGC, so it cannot follow the operator into SSB on the next
launch.

With AGC off the level comes from the AGC-off level (default 10 dB of
fixed gain) instead of up to 39 dB of AGC gain, so DIGU/DIGL are quieter
on the speaker and on the TCI level meter.

Docs and comments named in the aethersdr#5629 triage: HERMES.md §13 rows 26, 28
and 30, §18.5 and §18.7 now state the measured mechanism and that the
Wideband tap is withdrawn for HL2; the blockForOutput comments in
Hl2RxDsp.h and WdspChannel.h no longer claim a deterministic burst feed.

Refs aethersdr#5629

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

@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. #5629 asked for exactly this (agcMode = "off" on entering DIGU/DIGL, plus the §13/§18 doc corrections), and the triage comment invited it explicitly — "Do the DIGU/DIGL default AGC, subject to the level question above… send the XS PR" — so the shape is already ruled on and this is not an unratified default change. The doc rewrite is faithful to the measurement: §18.5 now states the real mechanism (noise reference inflated 18.25 dB, signal window within 0.042 dB) rather than the hand-wave it replaces, and withdrawing the Wideband tap rather than deferring it matches the recommendation.

What it does not cover is the path an FT8 operator actually takes most often: the AGC default is adopted only on a mode change through setSliceMode(), and connect-time mode restore does not go through setSliceMode(). Detail in Blocker 1. The title's claim "open DIGU/DIGL with AGC off" is true for a mode click and false for relaunching into DIGU.

The triage's one open question — the ~29 dB level step from a 39 dB AGC ceiling to a 10 dB fixed gain — is answered in the commit message ("DIGU/DIGL are quieter on the speaker and on the TCI level meter"), which satisfies the "be explicit" half. It is not in HERMES.md, where the next reader will look; see nits.

2 · Scope

File / group What it changes Claimed? Verdict
Hl2Backend.cpp / .h isDigitalDataMode(), the AGC-off adoption in setSliceMode, the agcModeBeforeDigital member, the capture/restore guards in setSliceAgc, seedReceiverAgc, currentOperatingState Yes — the fix In scope
tests/hl2_tci_signaling_test.cpp testDigitalModeDefaultAgc(), registered in main() Yes In scope
docs/HERMES.md §13 rows 26/28/30, §18.5, §18.7 item 5, the capability table row Yes In scope — all four named in the #5629 triage
Hl2RxDsp.h, WdspChannel.h blockForOutput comments corrected: the flag blocks but does not make a burst feed reproducible Not in the title; in the commit body and in #5629 §"One harness fault" In scope — comment-only, two lines each, traceable to a named triage item

No new public surface, no settings keys, no capability fields, no CHANGELOG.md entry (correct). No removed guards — the only - lines of substance are the if/else brace conversion in setSliceAgc, which preserves the original branch logic exactly. The §18 edits delete no claim that was true: §18.5's old "it decodes, but a modem on AGC'd audio is a known-marginal arrangement" is replaced by the measurement that supersedes it. Nothing in the diff is unexplained.

3 · Blockers

1 · Restoring into DIGU/DIGL leaves medium AGC — the primary WSJT-X workflow doesn't get the fix. (inline on Hl2Backend.cpp:2906)

Reasoned from code, not reproduced at runtime:

  • pushInitialState() writes the restored mode directly: txRx->mode = m_restoredState.mode; (Hl2Backend.cpp:7101-7102), then derives the passband from it and pushes setAgc(wdspAgcMode(r.agcMode), …) at :7155. It never calls setSliceMode(), so the new block never runs.
  • seedReceiverAgc() has already set r.agcMode from the remembered flat pair and cleared agcModeBeforeDigital (:6530-6533).
  • The capture side of this PR deliberately never stores the data mode's off, so the remembered pair is always the non-digital one — by design.

Net: operator runs FT8 in DIGU, quits, relaunches, reconnects → DIGU with slow/med AGC, agcModeBeforeDigital empty, and nothing pending. It self-heals only after a mode round-trip out of and back into a data mode. DIGU → DIGL doesn't help either (wasDigital && isDigital takes neither branch).

The PR's own analogy is what makes this a gap rather than a judgement call: the comment says "Adopted on entering them, like the passband", and the passband is handled at that exact restore site (defaultPassbandForMode(txRx->mode), :7104-7106). The AGC has no counterpart there. Fix looks like a few lines in the same restore block: if isDigitalDataMode(txRx->mode), stash agcModeBeforeDigital = agcMode and set off before the setAgc push at :7155, under the existing m_passbandDerivedThisConnect guard so an EP6 re-linkUp can't replay it over a live edit.

I'd call this a blocker because the PR reads as "fixed" and the most common entry into DIGU is the one left on medium.

4 · Nits (non-blocking)

  • createPanadapter()'s seed can strand off. (inline on Hl2Backend.h:511) The seed at Hl2Backend.cpp:962-972 copies first.mode and first.agcMode but not agcModeBeforeDigital (correctly — it's per-receiver). So a pan created while receiver 0 sits in DIGU starts at mode=DIGU, agcMode=off, before=""; moving that receiver to USB hits wasDigital && !isDigital && before.isEmpty() and leaves it on off for the rest of the session. Smaller blast radius than Blocker 1 and the same root shape (a path that sets mode without going through setSliceMode); worth one line either way.
  • The currentOperatingState() hunk is unreachable in practice. (inline on Hl2Backend.cpp:6565) m_agcMode is assigned unconditionally in applyRestoredState() (:6503, which runs before every connect) and again in seedReceiverAgc() (:6539), and is never cleared — so the else if fallback only fires pre-connect, when rx(m_txDdc) is null anyway. The test's currentOperatingState().agcMode == "slow" checks pass through the m_agcMode branch, not this one. The change is correct and defensive; it just isn't load-bearing and isn't covered.
  • The level step isn't in the docs. The triage flagged the ~29 dB drop (39 dB AGC ceiling → agcFixedGainDb 10.0) as "a plausible next issue". The commit message says it; HERMES.md §13 row 30 and §18.5 don't. One sentence next to the row would stop the next reader filing it. Related question, not a finding: did #5629's AGC-off arm run at the same agcOffLevel default (10) this PR will produce, or at unity?
  • !previous.isEmpty() is dead in both the passband and the new AGC block — Receiver::mode defaults to "USB" (Hl2Backend.h:498), so it's never empty. Harmless, and consistent with the sibling above it.

5 · What I tried to break

  • Mode-transition matrix. DIGU→DIGL (no change, correct), DIGL→DIGU, re-selecting the same data mode, a refused mode string (early return before r->mode is written, so previous can't be corrupted), and USB→CWU with a pending before — all behave. agcModeBeforeDigital can only be non-empty while the mode is digital, so there's no path where the restore branch fires outside a data mode.
  • The capture seam. Walked setSliceAgc → m_agcMode → currentOperatingState → applyRestoredState → seedReceiverAgc for leakage of the data mode's off into the persisted document. It doesn't leak: entering DIGU never touches m_agcMode, and the threshold-only call (m empty) keeps the before value. One edge the PR accepts rather than handles: an operator who explicitly re-selects off while in DIGU gets m == r->agcMode, so before is not cleared and their choice is overwritten on leaving — the test pins this deliberately, and I agree it's the lesser surprise.
  • Multi-receiver. agcModeBeforeDigital is per-Receiver and resolved through ddcForSlice/rx(), every access null-checked, and the flat m_agcMode capture keeps the "last receiver the operator touched" rule from #4909 intact. Receiver 1 in DIGU does not drag receiver 0's AGC. buildReceivers() carries state wholesale; seedReceiverAgc() clears the member. The only hole found is the createPanadapter seed above.
  • Threading and lifetime. The new setAgc push is Qt::QueuedConnection to r->dsp behind an r->dsp null check — byte-identical shape to the setAgc call in setSliceAgc four hundred lines down, and on the same (GUI) thread. No new ordering: the AGC push lands before the setMode/setFilter pair, and applyRxAgc is mode-independent, so the SetRXAMode rebuild below it can't discard the AGC the way it discards filter edges.
  • The test. It would fail on main — without the new block the first check(agc == "off") sees "slow". Mutating if (m != r->agcMode) to clear unconditionally reddens the last assertion; dropping the wasDigital term reddens the DIGU→DIGL check. It runs with r->dsp == nullptr, so it covers the state machine and the published delta but not the WDSP push — an honest layer boundary, not a gap. Socket-free, no fake peer, registered at tests/tests.cmake:5660; one sticky agc capture variable means "a non-data mode change leaves AGC alone" would also pass if emitSliceState omitted agcMode, which is weak but not wrong.
  • Could not check: anything at runtime — no build, no HL2, no WSJT-X. Every finding above is read from the head checkout at 540e149. CI is green on all five checks (build, check-macos, check-windows, Static checks, Sanitizer configures) against base 732b99a9, which is current main; note the per-PR ctest is -R-filtered, so hl2_tci_signaling_test passing in CI is not something I can confirm from the check list.

6 · Recommendation

Request changes, on Blocker 1 only. The measurement behind this is unusually well done and the doc rewrite is the kind that saves the next person a week — but the fix is wired into the one mode path that the connect-time restore bypasses, and the operator who benefits most from AGC-off in DIGU is precisely the one who left the radio there overnight. Concrete next step: extend the restore block in pushInitialState() (Hl2Backend.cpp:7099-7113) to adopt the data-mode AGC default alongside the passband it already derives, add the mirror-image case to testDigitalModeDefaultAgc() (restore into DIGU, check off, leave to USB, check the remembered mode returns), and decide the createPanadapter seed either way. The nits can ride along or follow.


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

Comment thread src/core/backends/hl2/Hl2Backend.cpp Outdated
Comment thread src/core/backends/hl2/Hl2Backend.h
Comment thread src/core/backends/hl2/Hl2Backend.cpp
Comment thread tests/hl2_tci_signaling_test.cpp
Review of aethersdr#6119: the data modes' AGC default was adopted only in
setSliceMode(), so three other ways a receiver comes to be in DIGU or
DIGL kept the remembered AGC.

followModeAgc() now holds the rule and is called wherever a receiver's
mode is written or its AGC is seeded:

- setSliceMode(): mode click, TCI, band-stack recall (unchanged
  behaviour, the code moved into the helper).
- pushInitialState(): the connect-time restore writes the restored mode
  directly. Under the once-per-connect guard, so a link-up after EP6
  silence does not replay it over an operator's AGC choice.
- seedReceiverAgc(): a receiver already in a data mode when the
  remembered AGC is seeded keeps AGC off and holds the seeded mode.
- createPanadapter(): the seed copies agcModeBeforeDigital with the
  mode and AGC it already copied, so the new receiver can leave "off".

The remembered AGC stays the operator's. seedReceiverAgc() primes the
capture member from the held mode, never from a data mode's "off", and
setSliceAgc() and the pre-connect fallback in currentOperatingState()
already did.

The dead "!previous.isEmpty()" term is dropped from the AGC rule.

docs/HERMES.md section 18.5 and section 13 item 30 state the level
step: 10 dB fixed gain at the default AGC-off level against a 39 dB AGC
ceiling, up to 29 dB quieter, arithmetic and not a measurement.

Tests: hl2_state_restore_test (restore into DIGU, later link-up, seed
in DIGL), hl2_pan_create_async_test (receiver seeded from a DIGU one),
hl2_tci_signaling_test (capture fallback before any AGC is set, and the
non-data mode check no longer passes on an earlier publish).

Refs aethersdr#5629

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

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first: Blocker 1 stands. I had it in the body as limit 3 and called it out of scope; that was the wrong call. Fixed in 6d09a779, a new commit on 540e149e, no rebase.

One limit remains. A band-stack bookmark applies its own stored AGC after the mode (RadioModel::recallBandStackReceiveDsp). A bookmark saved in DIGU before this change recalls the AGC of that time. Read from the source, not run, and not changed: it is every family's bookmark contract.

  • Blocker 1 and the pan seed. One function, Hl2Backend::followModeAgc(), called from setSliceMode(), the restore block of pushInitialState() (inside the once-per-connect guard) and seedReceiverAgc(). createPanadapter() copies the held mode.
  • Remembered AGC. The off never reaches it; the seed primes the capture from the held mode.
  • currentOperatingState() nit. Reachable: the constructor builds one receiver (Hl2Backend.cpp:295). Kept, and now tested.
  • Level step. In HERMES.md §18.5 and item 30. §13 item 30 measured: TCI's post-AGC feed costs decodes only beside a +30 dB neighbour, and the Wideband tap is not the fix #5629's AGC-off arm ran at SetRXAAGCFixed 10 dB, the same as the default level.
  • !previous.isEmpty(). Dropped from the AGC rule. The passband one is not this PR's line.

Fail-before (the new tests on 540e149e's source): red on restore into DIGU, the seed in DIGL, and the pan seed. Mutations: the capture fallback and the seed priming reading agcMode each turn one check red.

Suite at 6d09a779: 610 of 610 (macOS, Qt 6.12.0). Static checks pass locally.

Not verified: anything on a radio, and that WDSP receives the pushed AGC.

73, ON8ST

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Issue fit

The mechanism is correct, and the head now covers every way into DIGU/DIGL. Whether HL2 should change this default is still a maintainer ruling, and nobody has made it yet. #5629 measured offline that WDSP's AGC costs FT8 decodes beside a +30/+40 dB neighbour, and that AGC off on the slice recovers them. The triage comment on #5629 said "send the XS PR". That comment came from aethersdr-agent, though, and the issue still carries maintainer-review. Under Principle XIII peer-agent suggestions "are hints", and GOVERNANCE.md lists "default values affecting all users" as something an agent "may not autonomously change". It also says "any change to default UX behavior" needs an RFC. This PR turns AGC off for every HL2 operator who selects DIGU/DIGL, and on a quiet band the receiver gets up to 29 dB quieter on the speaker and on the TCI meter. Limit 1 of the PR body already says this is the maintainer's call, which is right. The review agrees: the code is ready for that decision, and the decision itself is not made here.

Fix or preference (the skill's step 6): it is not a bare preference. It cites a measurement with a stated denominator and registered falsifiers. That measurement is offline only, though: no radio, no band, no static crashes. The HL2 gateware has nothing to say here, because AGC is host-side WDSP. The authority is #5629's harness, and nothing else backs it. FlexLib (reference/FlexLib_API_v4.1.5.39794/FlexLib/Slice.cs) couples DIGU/DIGL to nothing on the client side: AGC is a per-slice radio property, set and echoed independently of the mode. So no reference behaviour either supports or contradicts the default. With this PR, HL2 DIGU will open with AGC off and a Flex DIGU slice will keep whatever AGC the radio holds.

Scope

File / group Change Claimed? Verdict
src/core/backends/hl2/Hl2Backend.cpp / .h isDigitalDataMode(), followModeAgc() and its 3 callers, the pan-seed copy, capture/seed/fallback read the held mode, the setSliceAgc clear Yes In scope (the default change itself needs the maintainer, see above)
docs/HERMES.md §13 rows 26/28/30, §18.5, capability table, §18.7 item 5 Measured mechanism, Wideband withdrawn, level step stated Yes, as the #5629 triage asked In scope. §18.5 rewritten in place, limit 5 flags it for the maintainer
Hl2RxDsp.h, WdspChannel.h blockForOutput comments corrected (comments only) Yes In scope (triage item)
tests/hl2_tci_signaling_test.cpp, hl2_state_restore_test.cpp, hl2_pan_create_async_test.cpp New cases Yes In scope. See the socket note below

Commits are 540e149e and 6d09a779, both authored 2026-10-03, and both about the issue. The diff adds no new settings key, verb, capability field or CHANGELOG.md entry. Nothing on the - side removes a guard: the only removed logic is the brace conversion in setSliceAgc, and it keeps the same branches. Everything in the diff is explained by the issue.

Blockers

None in the code. The merge depends on the maintainer ruling on the default (above), which is not a defect the author can fix.

Nits / maintainer-decision inputs (non-blocking)

  1. The PR's answer to the level drop does not survive a restart. Limit 2 says "the operator can raise the AGC-off level". On HL2 that level lives only in Receiver::agcOffLevel (Hl2Backend.cpp:3090, pushed at :3288). RestoredRadioState has no field for it (src/core/backends/RestoredRadioState.h:22-31 persists only agcMode/agcThreshold), and nothing in src/ writes it to settings. So an FT8 operator who raises it to get the 29 dB back has to raise it again every launch, on every receiver. Before this PR, AGC off was an explicit operator choice, so this was a corner. After it, the AGC-off level is the operating gain of every HL2 DIGU session. Not this PR's bug, but the maintainer should weigh it with the default. Either persist the off level next to the AGC pair, or rule on a data-mode fixed gain, which was the other option the triage offered.
  2. Siblings left behind, disclosed in limit 1. ANAN-G2 (AnanBackend::setSliceAgc, AnanBackend.cpp:818) and RTL-SDR (RtlSdrBackend.cpp:572) run the same WDSP wcpAGC. The mechanism #5629 measured is a WDSP property, not an HL2 one. After this PR, the same mode on two host-DSP radios in one app behaves differently. That is fine as an HL2-first step, but it should be on record (an issue or a §13 row) so it does not look like a deliberate divergence.
  3. Pre-PR band-stack bookmarks re-impose medium (limit 3). I read RadioModel::recallBandStackReceiveDsp (RadioModel.cpp:4183-4198). Mode, sliceChanged and AGC are all DirectConnection, so the backend's off reaches SliceModel before the bookmark comparison runs. A post-PR bookmark (stored off) therefore round-trips cleanly. A pre-PR DIGU bookmark (stored med) sends setSliceAgc("med") after slice->setMode (MainWindow.cpp:5317 and then :5351). Hl2Backend.cpp:3006 treats that as an operator choice, clears the held mode, and leaves medium AGC on in DIGU. Saving the entry again stores med again, so the old value never ages out unless the operator picks off by hand. The PR body discloses this as limit 3. The automated pass flagged the same path independently. If the maintainer accepts the default, a follow-up could ignore a stored AGC on DIGU/DIGL recall, or migrate those entries once.
  4. One claim cannot be checked from the issue. "#5629's AGC-off arm ran at that same 10 dB" (docs/HERMES.md §18.5) is not in #5629's body. Only the author's PR comment says it. If the level matters to the decode result, put the figure in the issue thread, where the measurement lives.
  5. Stale CI. The earliest run on 6d09a779 was created at 18:38Z, and main has since moved 7 commits (19:25Z). Three of those touch HL2 (#6104, #6113, #6126), including Hl2Backend.cpp and hl2_pan_create_async_test.cpp. git merge-tree against current main is clean. I checked main's Hl2Backend.cpp hunks: they are in TX keying/tune, openReceiverDsp, capabilities and the TX reset at the top of pushInitialState(), and none touches Receiver::mode/agcMode, the pan seed, seedReceiverAgc or the restore block. So I found no functional overlap, but I did not build the merged tree. A CI re-run on current main would close this.

Socket-test notice (step 0)

tests/hl2_state_restore_test.cpp gains three connectRadio() cases, and that path starts MetisClient. MetisClient binds a QUdpSocket on AnyIPv4:0 (MetisClient.cpp:330-331, via Hl2Backend.cpp:2658) and sends to TEST-NET-1. The existing cases in that test already do the same, so this is not new socket ownership. It does make the test a modified socket-owning one, so I did not run it, and its three checks are reasoned from code, not executed. The other two targets are socket-free: hl2_pan_create_async_test asserts transportIdle, and the new hl2_tci_signaling_test case never connects.

What was verified vs. read, and what held

  • Built the head 6d09a779 (Linux, Qt 6, RelWithDebInfo) and ran hl2_tci_signaling_test ("all checks passed") and hl2_pan_create_async_test ("0 failure(s)").
  • Mutations, both caught. Dropping seed.agcModeBeforeDigital = first.agcModeBeforeDigital; (Hl2Backend.cpp:971) gives [FAIL] and returns to the first receiver's held AGC on leaving DIGU. Short-circuiting the followModeAgc call in setSliceMode (:2926) gives 5 FAILs ("entering DIGU turns AGC off", "DIGU to DIGL keeps AGC off", "entering DIGL turns AGC off", "a threshold change in a data mode does not capture off", "and the held AGC still returns afterwards").
  • GUI echo loop. If the GUI re-sent a stale AGC after the mode change, the default would be undone. The GUI does not: SliceModel::setAgcThreshold sends m_agcMode, which sliceChanged has already set to off synchronously, and an equal mode keeps the held mode (Hl2Backend.cpp:3004-3008). SliceModel::applyDelta's AGC update is equality-guarded and emits no command.
  • Restore/seed matrix, read. Fresh connect, restore into DIGU, then seed: off, held = remembered. Carried receiver already in DIGU, then a different-radio seed: off over the seeded mode. Carried DIGU, then restore USB: the held mode returns. Same-radio reconnect: no reseed, and the restore block runs once per connect (m_passbandDerivedThisConnect). Every write to Receiver::mode (:967, :2914, :7116) either calls followModeAgc or copies the held mode.
  • Capture. The data mode's off never reaches m_agcMode/currentOperatingState(), except when the operator picks a different AGC and then off inside DIGU. That is an explicit choice, so it being kept is consistent with the table.
  • Doc numbers checked against #5629. 32/82 at +30 dB, 58/76 at +40 dB, 0.042 dB, 25–29 dB, 18.25 dB, and 39 dB = 65 × 0.6 (Hl2DbReference::agcCeilingDb). agcFixedGainDbForOffLevel(10) = 10 dB. All match.
  • Automated pass (/code-review, medium): one low-severity finding, the pre-PR bookmark path, folded into nit 3 after I checked it against MainWindow.cpp:5310-5351. It found nothing else.
  • Engine boundary. tools/check_engine_boundary.py gives identical per-file findings on the merge base 732b99a9 and the head.
  • Not driven in the app. The demo is SimBackend, which never runs Hl2Backend, so a bridge session would exercise none of the changed lines. Nothing was run on an HL2. Speaker and TCI levels and the on-air decode result are unverified, and the PR says so itself.

Recommendation: Needs maintainer decision. If the default is accepted, the code is mergeable as is, after a CI re-run on current main. Nit 1 (the off level is not persisted) is the input most likely to change how the default lands.

on8st and others added 2 commits October 3, 2026 22:49
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>
…VIII.

Section 18.5 now says the DIGU/DIGL AGC default is HL2-only while the
mechanism aethersdr#5629 measured is wcpAGC's: ANAN-G2 runs the same AGC and
keeps the operator's mode in the data modes, and RTL-SDR runs it at
WDSP's medium with no data mode and no AGC command. Review nit 2 asked
for the divergence to be on record.

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

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first, two items.

  1. Nothing in this push settles the PR. The default is yours to rule on and the PR waits for it. I am not arguing it; I write the RFC only if you ask.
  2. The merged tree is not green here: 613 of 615 (macOS, Qt 6.12.0). hl2_pan_create_async_test ("the reuser has its own chain", fix(hl2): drop spectrum and meter output from a closed receiver's DSP. Principle VIII. #6113) and peripheral_auth_dialog_test (feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections #6008) fail, identically on clean main at d9de044c. Neither check is this PR's. 6d09a779 was 610 of 610.

Pushed bbddff97, two commits, no rebase:

Inputs to your ruling, not built:

If you accept the default I build both as follow-ups, the persisted off level first: it touches every DIGU session on every launch, the bookmark recall only entries saved earlier.

On those two failures: the HL2 one fails here on clean main every time (60 of 60), and its cause is in the test, not in the guard; I posted it on #6113. The other one I have not traced beyond a layout that needs 680 px in a 640 px viewport on this Mac.

73, ON8ST

@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Maintainer ruling on #6119:

73, Jeremy KK7GWY

on8st and others added 2 commits October 4, 2026 01:26
Maintainer ruling on aethersdr#6119: the AGC-off level is the operating gain of
DIGU/DIGL once they open with AGC off, so it has to survive a restart.

RestoredRadioState gains agcOffLevels, one 0..100 entry per receiver
index (-1 = none). RadioStateMemory stores it as the agcOffLevels array
of the radio's OperatingState document, and reads or writes it only for
a backend that declares the Agc domain and hasAgcThreshold. A document
without the array restores the default of 10. No schema bump: the field
is optional, as the AGC pair was when it was added.

Hl2Backend captures a change in requestSliceAgc(), seeds each receiver
with its own entry in seedReceiverAgc(), gives a receiver opened later
the entry for its index, and drops an out-of-range entry at the restore
boundary. No other family reads or writes the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HERMES.md §5 (AGC) and §18.5, and the clientSettingsDomains row of the
capabilities map, describe where the level is stored, which declaration
gates it, and that ANAN and RTL-SDR ignore it.

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

on8st commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first: the head is not green here. 612 of 615 on macOS; the three failures are upstream's and are named in the body. Merged with main at 5fae2f86: 616 of 617 (peripheral_auth_dialog_test, #6008).

Pushed 71225eee, two commits on bbddff97, no rebase. The AGC-off level persists per receiver.

  • Where. RestoredRadioState::agcOffLevels, stored as the agcOffLevels array of the radio's OperatingState document. No flat key.
  • Gate. RadioStateMemory reads and writes it only for a backend declaring the Agc domain and hasAgcThreshold. Other families ignore it.
  • HL2. Captured in requestSliceAgc(), seeded per receiver in seedReceiverAgc(). No array: 10.
  • No schema bump, as with the AGC pair (fix(hl2): persist the operator's AGC mode and threshold across restarts — Principle III. #4911). An older build drops the array. Yours to choose.

Tests: two levels, capture, store, new backend, load, restore; each WDSP channel reports the gain. Ten checks red on the old source. Dropping the restore: 4 red. Dropping the capture: 5 red.

Not verified: a radio, a real app restart, more than two receivers. The bookmark follow-up is not started.

73, ON8ST

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

ten9876 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

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

…VIII.

testAgcOffLevelCapture restores {20, 61}, sets receiver 0 to 37 and
expects the capture {37, 61} plus one capture request. It runs without
connectRadio(), so the capture half of the per-receiver persistence has
a check that runs where hl2_state_restore_test (which binds UDP) cannot.
Red when requestSliceAgc() stops remembering the level, when
currentOperatingState() stops reporting it, or when applyRestoredState()
stops loading it.

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

ten9876 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Maintainer pass: answers to my review of 6d09a779, at head f48369a7b

The rulings are as posted on 2026-10-03: the default is accepted, the AGC-off level persists per receiver in this PR, and stale bookmarks are a follow-up. Point by point:

  1. The AGC-off level did not survive a restart. Fixed by on8st in 7ce11806 / 71225eee, and I verified it at the head:
    • Shape. RestoredRadioState::agcOffLevels is stored as the agcOffLevels array in the radio's OperatingState document, scope (hl2, <MAC>). There is no flat key.

    • Gate. RadioStateMemory reads and writes the array only when ownsAgcOffLevel(caps) holds, meaning the Agc client-settings domain and hasAgcThreshold (src/core/RadioStateMemory.cpp). Only Hl2Backend declares Domain::Agc. ANAN, Flex and Sim declare hasAgcThreshold but not the domain, so they are handed nothing. There is no family check (Constitution II/III).

    • No schema bump. This is the maintainer's decision, the same as fix(hl2): persist the operator's AGC mode and threshold across restarts — Principle III. #4911: an additive array that older builds ignore.

    • Mutations, all run on the merged tree, each restored and sha256-checked:

      removed red check
      the load line (RadioStateMemory) radio_state_memory_test round-trip
      the store line the round-trip and "one array in the OperatingState document"
      the capability gate four gate checks
      the capture in requestSliceAgc() hl2_tci_signaling_test testAgcOffLevelCapture, a socket-free check added in f48369a7b
      the report in currentOperatingState() the same test
      the load in applyRestoredState() that test plus hl2_pan_create_async_test "a new receiver opens on the AGC-off level remembered for its index"
      the pan-seed level the pan test

      The seedReceiverAgc() restore and the store→load→connect round trip are in hl2_state_restore_test. That test binds a UDP socket through MetisClient, so I reviewed it from the code and did not run it.

  2. Siblings. §18.5 records the default as HL2-only. on8st's correction stands: RTL-SDR has no DIGU/DIGL and an empty setSliceAgc, so only ANAN-G2 differs.
  3. Pre-PR band-stack bookmarks. Filed as HL2: an old DIGU/DIGL band-stack bookmark re-applies medium AGC over the data-mode AGC off #6142 (bug), with the recall order on current main: MainWindow.cpp:5338 applies the mode, :5371 → RadioModel::recallBandStackReceiveDsp() applies the stored med, and Hl2Backend::setSliceAgc() clears the held AGC. Not in this PR.
  4. The 10 dB figure. It is now in §13 item 30 measured: TCI's post-AGC feed costs decodes only beside a +30 dB neighbour, and the Wideband tap is not the fix #5629's thread with the harness lines, where the measurement lives.
  5. Stale CI. Main is merged in at a8cfd9901 (signed merge c201755ec). git merge-tree was clean. Main had gained fix(squelch): SQL line and Auto SQL from a backend-published scale; HL2 amsq gate referred to the LNA. Principle VIII. #6127, fix(rit): carry the slice id through the RIT/XIT seam; HL2 holds them per receiver. Principle I. #6109, fix(gui): manual Black Level works on a dBm row. Principle VIII. #6115, fix(gui): NB Blank works on a dBm waterfall row. Principle VIII. #6117 and test: drop two host-dependent assumptions that fail on macOS. Principle VIII. #6134 in files this PR touches, so this was a real update, not drift. I built the merged tree locally (Linux, Qt 6, RelWithDebInfo, -j8). hl2_tci_signaling_test, hl2_pan_create_async_test and radio_state_memory_test pass. The full socket-free ctest passed 507 of 508; the one failure is connection_panel_size_test, the known local failure (Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811). The 123 tests whose sources open or drive a socket were excluded. CI on the new head is running.

Socket notice. hl2_state_restore_test (the restore cases, the seed case and the off-level round trip) was not executed. It is reviewed from the code only.

Static gates. check_engine_boundary --strict, gen_touchpoint_manifest --check, check_capability_records --strict and check_command_plane --strict give byte-identical output on main a8cfd9901 and on the head.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved at f48369a7b: maintainer pass (/pr-land).

Maintainer decisions (Jeremy, KK7GWY)

  • Default accepted. On HL2, DIGU/DIGL open with AGC off, on the basis of #5629's offline FT8 measurement.
  • The AGC-off level persists per receiver in this PR. on8st did it in 7ce11806 / 71225eee: RestoredRadioState::agcOffLevels, the agcOffLevels array in the radio's OperatingState document. I verified it is capability-shaped (ownsAgcOffLevel = Agc domain ∧ hasAgcThreshold; only HL2 declares the domain). There is no family check and no flat key.
  • No schema bump, as with #4911: an additive array that older builds ignore.
  • Stale DIGU/DIGL band-stack bookmarks that restore med are follow-up #6142, not this PR.

Findings, each closed at this head

  • Blocker, the connect-time restore bypassed the default. Fixed by on8st in 6d09a779. followModeAgc() (Hl2Backend.cpp:2922) is called from setSliceMode() :2972, seedReceiverAgc() :6699, and the pushInitialState() restore block :7288, inside the m_passbandDerivedThisConnect guard (:7268–:7318). The setAgc push at :7341 follows it.
  • Nit, the pan seed stranded off. Fixed in 6d09a779 (:992). Proved by hl2_pan_create_async_test, which goes red when the copy is removed.
  • Nit, the capture fallback looked unreachable. Refuted: it is reachable before a connect, and it is covered by testDigitalModeDefaultAgc, which goes red when the fallback is removed.
  • Nit, the mirror-image restore test. Present in hl2_state_restore_test.
  • My review's points 1–5 are answered in the top-level comment. Main is merged in at a8cfd9901 (signed merge c201755ec). f48369a7b adds testAgcOffLevelCapture, a socket-free capture check.

Verified empirically (Linux, Qt 6, RelWithDebInfo, the merged tree)

  • hl2_tci_signaling_test, hl2_pan_create_async_test and radio_state_memory_test pass.
  • Full socket-free ctest: 507 of 508. The one failure is connection_panel_size_test, the known local failure (#5811). The 123 tests whose sources open or drive a socket were excluded.
  • Ten mutations, each red, each restored and sha256-checked: RadioStateMemory load, store and gate; capture in requestSliceAgc(); report in currentOperatingState(); load in applyRestoredState(); pan-seed level; pan held AGC; capture fallback; the mode-click followModeAgc call.
  • Static gates (engine boundary, touchpoint manifest, capability records, command plane) give identical output on main a8cfd9901 and on the head. No ratchet moved. All commits are verified.

Reviewed from code only, not run

  • hl2_state_restore_test: the restore-into-DIGU case, the second link-up, the seed in DIGL, and the off-level store→load→connect round trip. It binds a UDP socket through MetisClient and was not authorised to run.
  • Nothing ran on an HL2 radio, and there was no app session: the demo's SimBackend never runs Hl2Backend.

Freshness. The CI run on this head was created at 2026-10-04T02:25:37Z. Main's tip is a8cfd9901 (01:53:44Z), which the head already contains, so the run builds against current main. Auto-merge waits for that run.

@ten9876
ten9876 enabled auto-merge (squash) October 4, 2026 02:27
@ten9876
ten9876 merged commit e0c68e3 into aethersdr:main Oct 4, 2026
5 checks passed
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 4, 2026
Conflict: src/core/dsp/WdspChannel.h only. This branch added the transmit
Config fields txAmCarrierLevel and txFmDeviationHz where main (aethersdr#6119)
added a comment on blockForOutput. Both are kept, main's comment as
written.

AETHER_HL2_TX_TXA and the phasing-modulator arm are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 4, 2026
…, AGENTS.md line count. Principle VIII.

- CTR2: the intro, README highlight and Roadmap line, and ROADMAP entries
  say Wi-Fi works today and USB's host side is ready, has carried the
  radio's status stream to development firmware, and waits on a CTR2 USB
  firmware release; the CHANGELOG bullet notes USB is the default and
  nothing persists, so current firmware needs Wi-Fi chosen each launch.
- #6119's upgrade note carries the open #6142 bookmark exception.
- AGENTS.md is 300 lines, not 301.
- The 413 -> 369 command-plane drop credits #6049's and #6124's reroutes
  alongside the seam migration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 4, 2026
…lay, Qt 6.12 (#6159)

## Summary

Six-file release prep for **v26.10.1**, metadata and docs only. No
source, test or CI changes.

Cutoff: `origin/main` at `18c5245e5` (#6155, merged 2026-10-04).
Anything that merges after it is folded in before the tag.

**95 merged changes** (95 PRs, no direct commits) from **12 human
contributors**, plus AetherClaude (2) and Dependabot (0). There are no
first-time contributors this cycle. That was checked for each login by
looking for a merged PR before v26.9.5's tag, not from
`author_association`.

## Changelog

Built from `v26.9.5..18c5245` mapped through the commit-to-PR API
(`repos/…/commits/<sha>/pulls`), not from commit subjects. All 95 PRs
are cited in the section. The notes lead with three things:
- the sweep that makes every control on a radio without a Flex command
plane either work or refuse with a reason (#6044, #6049, #6015, #6111);
- the CTR2 relay (#6090, #6114, #6155) and authenticated 4O3A
connections with a Peripherals page (#6008, #6027);
- the Hermes-Lite 2's squelch, APF, AGC-off, RIT/XIT and FFT AVG.

The structural half covers Qt 6.12 for every binary and source build
(#5842, #6055) and the slice receive controls moving behind the seam
(#5892, #5907, #5919; raw Flex commands above the seam 413 → 369,
confirmed with `check_command_plane.py` at the cutoff). Upgrade-visible
changes are called out with **On upgrading** inline: the Ulanzi default
and removed MOX/TUNE pills (#5964), auto RF gain arming and law (#5976,
#6062), DIGU/DIGL opening with AGC off (#6119), HL2 squelch calibration
and the Auto SQL fallback (#6127), and the macOS 14.4 floor (#5842).

The previous tag v26.9.5 is an ancestor of `main`, so nothing is
excluded from the range.

## Docs refresh, not just version strings

- **README:** Highlights "Broad hardware control" gains the CTR2 relay.
Supported Hardware says direct TGXL/PGXL/Antenna Genius connections
authenticate. The Other radio families paragraphs gain the HL2's
squelch, APF, RIT/XIT, FFT averaging and CL1 reference, the ANAN-G2's
receiver audio and radio speaker, and RTL-SDR's confirmed receive state,
capture browsing and zoom FFT. In the Roadmap section, the aetherd line
gains the receive controls and a CTR2 relay line is added. No maturity
label changed. The six backends and seven NR engines were re-checked
against `src/core/backends/` and `AetherRxProfiles.cpp`.
- **ROADMAP, In flight:**
  - aetherd: the receive controls behind the seam.
- Icom: TUNE power live, no false drop notices, the one-panadapter
correction. The stale "VOX and CW break-in" leaves Remaining:
`IcomCivBackend` has implemented both since #4799/#5113.
  - ANAN-G2: receiver audio and radio speaker.
  - RTL-SDR: M1, with Remaining restated from #5924's body.
- Hermes-Lite 2: the v26.10.1 control parity, and the CW latency cost
updated to 4096 taps.
  - A new **CTR2 controller relay** entry.
  - Nothing left In flight as finished.
- **ROADMAP, Open RFCs:** diffed against `label:rfc`. #5893 leaves
(closed). #6106 and #6079 join under Radios, protocol and devices.
- **ROADMAP, Recently shipped:** 8 new `(v26.10.1)` entries. The list is
trimmed to the v26.10.x cycle at the month rollover, and its intro names
v26.10.x. Everything trimmed is still in CHANGELOG.

## Judgment calls flagged for the maintainer

- **Recently shipped trimmed to v26.10.x at the rollover:** maintainer's
call. This makes the validator's "only the four current-version lines
lose `26.9.5`" check FAIL. The lines it lists are exactly the trimmed
`(v26.9.5)` entry markers, and there is no historical mention elsewhere.
That is the one FAIL below. One side effect: the trim removes ROADMAP's
last mentions of #5630 and #5684, approved RFCs that shipped in v26.9.4.
Both are now closed as completed, and both remain in CHANGELOG.
- **A CTR2 relay In-flight entry:** maintainer's call. Wi-Fi works
today. USB's host side shipped and has carried the radio's status stream
to the firmware author's development firmware; it waits on a CTR2 USB
firmware release.
- **Intro wording with zero Dependabot updates:** "AetherClaude and no
dependency updates within that total" keeps the house shape and passes
the count check. The Contributors block has no Dependabot sentence.
- **#6125 is filed under the seam section.** It fixes a WAN/SmartLink
receive regression introduced by #5907 inside this range, so released
builds never had the regression. The section states what ships: receive
intents gate on `RadioModel::isConnected()`.
- Ready for review, not draft: maintainer's call.

## Scope

| file | change |
|---|---|
| `CMakeLists.txt` | `project(AetherSDR VERSION 26.10.1)` |
| `README.md` | Current version line, plus the prose refresh above |
| `AGENTS.md` | Current version line |
| `CHANGELOG.md` | new `[v26.10.1] — 2026-10-04` section; `[Unreleased]`
stays and stays empty; history below byte-identical |
| `packaging/linux/io.github.aethersdr.aethersdr.metainfo.xml` |
`<release version="26.10.1" date="2026-10-04"/>` first in `<releases>` |
| `ROADMAP.md` | cycle heading → post-v26.10.1, plus the prose refresh
above |

## Validation

`check_release_prep.py --mapping mapping.json`: 29 checks, **1 FAIL**
(explained above), 0 WARN.

- All six version spots say 26.10.1. The CHANGELOG heading date equals
the metainfo date, 2026-10-04.
- `appstreamcli validate` passed. The `<release>` list is
date-descending with unique versions (22 entries).
- `[Unreleased]` stays empty, `[v26.10.1]` sits directly under it, and
the CHANGELOG diff is exactly the inserted section, with history
byte-identical to `origin/main`.
- All 95 PRs in the range are cited. The only other numbers cited are
#3849 (RFC), #5262 (tracking issue), #5468 (RFC), #6091 (RFC, approved
and closed) and #6142 (open follow-up issue for #6119).
- The intro (95/12), the Contributors counts (13 entries) and
"first-time: none" are re-derived from the mapping, and they match.
- ROADMAP `(vX)` markers lead with v26.10.1. Every local link in README
and ROADMAP resolves. No removed heading is linked elsewhere. Code
fences balance, with no duplicated table headers or doubled `---`.
- **FAIL:** "only the four current-version lines lose `26.9.5`". The
lines are the trimmed Recently-shipped markers; see the judgment calls
above.
- `git diff --check` is clean. The diff touches no `src/`, `tests/`,
`third_party/` or `.github/workflows/` path.
- Configure: `cmake -S . -B <scratch>` reports
`CMAKE_PROJECT_VERSION=26.10.1`. No build or test was run, because this
is metadata only; CI builds it.
- `main` CI at the cutoff: `CI` passed on `18c5245e5`.

After merge: tag the resulting **main** commit, not this branch.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 pushed a commit that referenced this pull request Oct 6, 2026
…ite 2. Principle III. (#6140)

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

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

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

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

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

## Limits, first

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

## What was wrong on `main`

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

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

## The change

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

## Tests

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

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

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

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

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

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

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

## With the open PRs

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

## Not verified

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

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

73, ON8ST

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
jensenpat pushed a commit that referenced this pull request Oct 7, 2026
Fixes #6142

On a Hermes-Lite 2, recalling a band-stack bookmark into DIGU or DIGL no longer puts the bookmark's stored AGC mode over the data-mode AGC off. The bookmark's threshold still applies. The AGC held before entering the data mode still returns on leaving it. An AGC the operator selects while already in DIGU/DIGL stays until they leave.

A BandStackEntry stores only the mode string, so a stale pre-#6119 value and an AGC the operator chose inside DIGU and then bookmarked cannot be told apart. Both recall to AGC off. The operator can select that mode again afterward. Stored entries are not rewritten; a stale one ages out on the next save, which reads the slice.

SliceAgcRequest carries Origin { Operator, Recall }, default Operator. RadioModel::recallBandStackReceiveDsp is the one caller of SliceModel::recallAgcMode. Hl2Backend::requestSliceAgc drops a Recall mode while the receiver is in DIGU/DIGL and echoes the receiver's state synchronously, so the threshold recall that follows does not clear the held AGC. No other backend reads the origin. Flex still sends the stored agc_mode= text.

Squashed-from: #6150

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Patrick Jensen <patjensen@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants