Skip to content

fix(hl2): let auto RF gain arm across the native range — the +19 ceiling came from one faulty unit. Principle II. - #5976

Merged
ten9876 merged 9 commits into
aethersdr:mainfrom
on8st:hl2/autogain-native-ceiling
Oct 2, 2026
Merged

ten9876 merged 9 commits into
aethersdr:mainfrom
on8st:hl2/autogain-native-ceiling

Conversation

@on8st

@on8st on8st commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #5943. Follows up #5354, #5535, #5652.

Why

kAutoRfGainMaxBaselineDb = 19 refused to arm auto RF gain above +19 dB. It rested on #5354's "+48 dB measures like +18 dB", which was measured on one HL2 with a hardware fault: a bad joint at AD9866 pin 1, the FPGA→AD9866 Tx[5] line, which carries RX gain bit 5 while receiving. The bit was held high, so every commanded −12…+19 dB ran 32 dB hotter than displayed. The joint was reflowed on 2026-09-27; corrections are posted on #5354, #5535 and #5943.

The gateware agrees with the native range: with bit 6 set, ad9866.v (883a338, unchanged on master) takes all six gain bits as given (rx_gain <= cmd_data[6] ? cmd_data[5:0] : …), so codes 0–60 are −12…+48 dB with no five-bit fold.

On the repaired radio:

  • Gain ladder: codes 0–60 rise monotonically (−97.4 → −42.2 dBFS on an 80 m EFHW, ~0.9 dB/code). Code 32 over code 0 went from 0.0 dB to +33.6 dB, and a repeat run matches to 0.10 dB.
  • Clip point: on a dummy load there is no ADC-overload observation anywhere in −12…+48 dB. On live antennas the first clip moves with band conditions (+28…+38 dB across runs on an 80 m fan dipole and EFHW), so the +20 dB default sits inside the clean range.

What changes

  • kAutoRfGainMaxBaselineDb becomes hl2::kLnaGainMaxDb (+48), so every baseline the slider offers can arm.
  • kAutoRfGainFloorMaxDb becomes the full native span, kLnaGainMaxDb − kLnaGainMinDb (60), so the operator's floor can reach the register floor from any armable baseline. The default floor (26) is unchanged.
  • The refusal branch stays as defensive code. It cannot be reached through the public API, because every writer of the baseline clamps to −12…+48. Its operator text says "above this radio's maximum", and the log line no longer cites HL2: drive RF/LNA gain from the ADC clip counter (roadmap item 14) — front end clips at any usable gain on a live antenna #5354.
  • Auto-gain stays off by default; default-on for profiles that never expressed a wish is RFC [RFC] Automatic RF-gain protection for the Hermes-Lite 2 #5535's separate decision.
  • The RF Gain slider describes the range the radio published (HL2 publishes a -12..+48 dB RF gain slider while its own auto-gain declines to arm above +19, and asking for more gain can deliver less #5943's second half; maintainer decision to fold it in here). The unarmed slider hardcoded Flex's "−8 to +32 dB in 8 dB steps" on both the accessible description and the tooltip, so an HL2 (−12…+48, 1 dB) was described as a Flex to screen readers and sighted operators alike. SpectrumOverlayMenu::applyRfGainRangeText() now writes both channels from the slider's published range, step and unit, and setRfGainRange uses it. It also fixes a pre-existing overwrite: a range push while Auto was armed replaced the slider's read-only tooltip with the range text.
  • Comments in Hl2Backend.{h,cpp}, Hl2DbReference.h, MainWindow_Wiring.cpp, SpectrumOverlayMenu.cpp and docs/HERMES.md (§17 and roadmap row 12) state that the fold was one unit's hardware defect.

Behaviour change for existing installs

#5828 made the operator's ask survive a refusal. Anyone who ticked Auto RF gain on the stock +20 dB baseline (always refused until now) has autoEnabled: true saved with the loop never having run. From the first connect after this change, that profile arms. That is the safe direction (attenuate only, on clip evidence, with the AtFloor warning) and it is what those operators asked for. Profiles that never expressed a wish stay off.

Tests

  • hl2_gain_split_test: kAutoRfGainMaxBaselineDb == kLnaGainMaxDb; arming succeeds from +19, +20 (the default) and +48; +49 clamps to +48 without moving the baseline or the wire; the deepest operator floor reaches the register floor from +48; armed/settled notifications are counted.
  • hl2_gain_restore_test: arming from the shipped +20 succeeds and the "off" persists; a restored autoEnabled: true switched off before the connect-time arm clears the profile (HL2: switching auto RF gain off after a refused arm is never recorded, and the next connect arms it again #5828).
  • spectrum_overlay_auto_rf_gain_refusal_test: updated to the new refusal sentence; the slider's description and tooltip name the published −12…+48 range when unarmed, keep the read-only reason across a range push while armed, and return to the range on disarm.
  • The refusal path itself is no longer exercised: no public input reaches it.

🤖 Generated with Claude Code

…ing came from one faulty unit

kAutoRfGainMaxBaselineDb was +19 because aethersdr#5354 recorded
"+48 dB measures like +18 dB" on a Hermes-Lite 2. That radio had a bad joint
at AD9866 pin 1 (Tx[5], which carries RX gain bit 5 while receiving) holding
the bit high, so every commanded -12..+19 dB ran 32 dB hotter. Repaired, the
same radio steps monotonically through -12..+48 dB, as the protocol, the
gateware (ad9866.v at 883a338) and the datasheet say. Corrections are posted
on aethersdr#5354, aethersdr#5535 and aethersdr#5943.

- The arming ceiling is now hl2::kLnaGainMaxDb (+48), so the shipped +20 dB
  default and every other baseline the setter can produce arms.
- The refusal branch is kept as defensive code; its operator text no longer
  says the gain axis is "not trusted" and the log drops the aethersdr#5354 suffix.
- Comments that stated the fold premise (Hl2Backend, Hl2GainSplit,
  Hl2DbReference, MainWindow_Wiring, SpectrumOverlayMenu, docs/HERMES.md)
  now record it as one unit's hardware defect.
- The 26 dB auto-gain floor is unchanged; its comments now say the aethersdr#5354
  sweep labels were 32 dB low ("-6 first clean" was a true +26) and that the
  floor is to be re-derived on a healthy radio.
- Auto gain stays OFF by default. The +20 > +19 arithmetic that kept it off
  is gone; flipping the default is left for a separate change under aethersdr#5535.

Tests: arming now asserted to succeed from +20 and +48 (a +49 request clamps
in setPanRfGain); the invariant kAutoRfGainMaxBaselineDb == kLnaGainMaxDb is
pinned; the default-is-not-armable assertion is inverted. Refusal-path tests
that relied on a +20 baseline declining are converted to the arm path, as no
baseline above the ceiling is reachable through the public API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st
on8st requested a review from a team as a code owner September 27, 2026 12:00

@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.

Issue fit

Yes, for the half it claims. #5943 asks that the published −12…+48 dB slider range and the range the backend is willing to operate in either agree or be explained; its author's correction comment (2026-09-27T11:40:37Z) narrows the ask to "the fix is to raise that baseline, not to narrow the slider", and that is exactly what kAutoRfGainMaxBaselineDb = hl2::kLnaGainMaxDb does. The evidence chain checks out as far as it can from here: the correction comments on both #5354 and #5943 attribute the fold to a bad joint on AD9866 pin 1 (Tx[5], RX gain bit 5) and report code 32 over code 0 measuring +33.6 dB after the reflow, against ~+32 expected. That agrees with ad9866.v at 883a338 passing all six gain bits in native format, which is the HL2's Principle I authority — so this change moves the client toward the protocol reference rather than away from it, and the asymmetry with #5752 (which refused to narrow the range on one board's evidence) resolves in the right direction.

Two things the PR does not address, both noted below: #5943's second half (the hardcoded −8 to +32 dB (8 dB steps) tooltip/accessible description the triage identified) is untouched, and the re-sweep the author said would precede this PR has not happened, so the two floor constants remain derived from labels the PR itself now marks 32 dB low.

Scope

File(s) What changes Claimed by title/body? Verdict
src/core/backends/hl2/Hl2Backend.h kAutoRfGainMaxBaselineDb 19 → hl2::kLnaGainMaxDb; rationale comments Yes In scope — the only behavioural change in the PR
src/core/backends/hl2/Hl2Backend.cpp Comments; refusal tr() text reworded; #5354 provenance dropped from the qWarning Yes In scope
Hl2AutoGainPolicy.h, Hl2DbReference.h, Hl2GainSplit.h Comment-only (verified: no +/- outside comment blocks) Yes In scope
src/gui/MainWindow_Wiring.cpp, src/gui/SpectrumOverlayMenu.cpp Comment-only Yes In scope
docs/HERMES.md §17 fold note + roadmap row 12 corrected Yes In scope
tests/hl2_gain_split_test.cpp, hl2_gain_restore_test.cpp, hl2_dbref_test.cpp, spectrum_overlay_auto_rf_gain_refusal_test.cpp Refusal legs replaced with arm legs Yes In scope, but see nits 1–2

Everything in the diff is explained by the issue. No new public/protocol surface, no settings key, no CHANGELOG.md entry (correct), no socket-owning test added or modified (checked all four test sources for QTcpServer/QUdpSocket/bind/listen/connectToHost — none). One user-visible behaviour change is disclosed in the comments (the first tick of Auto on a fresh install now arms), and one is not — finding 2.

Blockers

None. The change itself is small, correctly wired, and the "this is now a dead guard" claim holds (see What I tried to break).

Findings

  1. kAutoRfGainFloorMaxDb = 31 no longer spans the armable range — src/core/backends/hl2/Hl2Backend.h:220, unchanged by this PR. 31 is exactly 19 − (−12): with the old +19 arming ceiling, an operator's floor knob at maximum reached the register floor from any armable baseline. With the ceiling at +48, ceiling = min(cfg.maxOffsetDb, obs.availableOffsetDb) (Hl2AutoGainPolicy.h:763-765, availableOffsetDb = m_lnaGainDb − kLnaGainMinDb) caps the dig at 31 dB from a +48 baseline — down to +17 dB, with 29 dB of native range the loop cannot be configured to use. Reasoned from code, not run. Inline comment has the detail. Non-blocking, but it is the one arithmetic relationship the change silently breaks, and the constant is the only one in this cluster with no comment recording its derivation.

  2. A persisted autoEnabled: true from a previously refused arm now activates the loop on the next connect, undisclosed. The invariant #5828 established — "the asking survives the refusal" — means every operator who ticked Auto on a stock +20 dB baseline (which, per the v26.9.4 notes quoted in #5943, was always refused) has autoEnabled: true on disk with the loop having never run. After this PR the constructor path at Hl2Backend.cpp:590 arms them on first connect after upgrade. The direction is the safe one (attenuate-only, on clip evidence, with the AtFloor warning), and the PR is careful to say default-on is not decided here — but this is default-on for an existing population, and the body/comments don't mention it. Worth one sentence in the PR body and, arguably, docs/HERMES.md.

Nits (non-blocking)

  • The refusal machinery is now retained-but-unreachable, and its tests were deleted. Hl2Backend.cpp:8225-8270 (the guard, the tr() sentence, the settled-as-not-armed emit) plus applyRestoredState's clear-on-radio-swap now have zero coverage: the deleted legs included the "names both numbers", "cites no issue number at the operator" and "a radio swap clears a reason composed about the previous radio" assertions. hl2_gain_split_test.cpp discloses this honestly, which is why it's a nit and not a finding — but if #5943's option 2 (mark the untrusted region) is ever taken, that machinery comes back live and untested.
  • hl2_gain_split_test.cpp:317 restates the definition. check(kAutoRfGainMaxBaselineDb == kLnaGainMaxDb) cannot fail against an implementation that defines the first as the second; hl2_gain_restore_test.cpp already static_asserts the same identity. Useful as a tripwire against re-hardcoding 19, not as behavioural coverage.
  • #5943's other half is untouched. SpectrumOverlayMenu.cpp:2953 and :2960 still hardcode "RF gain, minus 8 to plus 32 dB in 8 dB steps." / "RF Gain: −8 to +32 dB (8 dB steps)" in the unarmed branches, overwriting the range-derived text setRfGainRange wrote. That is the non-UX defect the #5943 triage said "lands either way", and the accessible description is the worse half. If this PR is used to close #5943, please split that into its own issue rather than closing over it.
  • The floor constants are still the faulty radio's numbers. The 26 dB default (AutoGainConfig::maxOffsetDb) and the 24 in probingReleaseConfig() are both now annotated "to be re-derived from a sweep on a healthy radio"; the author's own correction on #5354 said the follow-up PR would come after the re-sweep. Shipping the ceiling first is defensible (the ceiling now equals the datasheet range, the floor is an operator-owned number), but the sweep stays outstanding and should probably be a tracking issue rather than a comment.
  • docs/HERMES.md:1533 (roadmap row 12) now reads both ways in one sentence — it keeps #5752's "remains unresolved against the native bit-6-selected RTL path" and then says the observation was a hardware defect. Worth one clause reconciling them.

What I tried to break

  • "Every writer of m_lnaGainDb clamps to the native range", the premise that makes the guard dead. It holds. setPanRfGain qBounds (Hl2Backend.cpp:4195), the band change qBounds (:8616), the restore parses lnaDbByBand through qBound (:7821), the reset writes kLnaDefaultGainDb (:7598), and connectLna clamps both the param and the stored branch (Hl2BandMemoryPolicy.h:55-78). The one unclamped write I found — m_lnaGainDb = params["lnaGainDb"].toInt() at :2662 — is overwritten by the clamped seed at :2692 under exactly the condition that made it fire (paramPresent), so a connect param of 500 cannot leak a >48 baseline into setAutoRfGain. The "DEFENSIVE" comment is accurate.
  • A stale copy of +19 somewhere the diff didn't reach. Grepped src/, tests/, docs/ for 5354, issues/177, +19, kAutoRfGainMaxBaselineDb, "fold", "wrap", "not trusted": every remaining hit is either updated by this PR or historical narration. No GUI or automation surface reads the constant, so no operator-facing copy went stale (unlike the pre-existing hardcoded tooltip above).
  • Build reachability of the new constant. hl2::kLnaGainMaxDb comes from Hl2BandMemoryPolicy.h, included at Hl2Backend.h:16 — no forward-declaration or include-order trap. CI agrees: 5/5 green on be44640 (build, check-macos, check-windows, Static checks, Sanitizer configures). Note that per ci.yml those ctest calls are -R-filtered, so green is not "the suite passes"; I could not run the four modified tests.
  • Whether the loop can now reach somewhere it couldn't. The axis is still attenuation-only (Hl2GainSplit.h), so raising the arming ceiling cannot make the radio louder than the operator asked — arming at +48 still only digs downward. What it can now do is stop short of the register floor, which is finding 1.
  • Whether the "No amount of LNA will fix this" warning became a lie. It can fire with native range remaining — but I checked, and that was already true before this PR (default floor 26 from a +20 baseline leaves 6 dB unused), so the overclaim is pre-existing and not something this PR introduced. Only the size of the gap grows.
  • The evidence itself. I read #5354 and #5943 end to end, including the author's two correction comments. The repair evidence is one unit, one operator, self-reported, with no second board — the same evidentiary shape #5752 declined to act on. I could not verify it independently and am not treating it as reproduced; what makes it acceptable here is that the conclusion lands on the documented native range rather than away from it.

All findings above are reasoned from the code in /tmp/aetherclaude/pr-5976; nothing was built, run, or exercised against hardware.

Recommendation

Approve with nits — one maintainer call attached. The constant change is correct, the dead-guard argument survives an attempt to break it, and the comment and doc corrections are thorough and honest about what is still unmeasured. Before merge I'd ask for two sentences in the PR body on finding 2 (existing profiles with a refused-arm wish will now arm on upgrade), and a decision on finding 1 — either raise kAutoRfGainFloorMaxDb to kLnaGainMaxDb − kLnaGainMinDb (60) so the operator's floor can still reach the register floor, or add a comment recording that 31 is now a deliberate cap rather than the range span. Whether to close #5943 against this PR while its hardcoded-tooltip half is still live is the maintainer's call.

Thanks for filing the correction against your own two issues rather than quietly editing the constant — the provenance trail in these comments is what made this reviewable at all.


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

Comment thread src/core/backends/hl2/Hl2AutoGainPolicy.h Outdated
Comment thread src/core/backends/hl2/Hl2Backend.cpp Outdated
Comment thread tests/hl2_gain_split_test.cpp
…d wishes now arm

kAutoRfGainFloorMaxDb was 31, exactly +19 - (-12); with the arming ceiling at +48
it would strand up to 29 dB the operator could not configure the loop to use.
It is now kLnaGainMaxDb - kLnaGainMinDb (60); the default floor (26) is unchanged.
The AtFloor paragraph now says what AtFloor means at the default.

applyRestoredState: a profile whose autoEnabled survived a refusal (aethersdr#5828) arms on
the first connect after this change. Stated in the comment rather than left implicit.

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

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

A correction to this PR's body, and the floor re-measurement that the body said was still owed.

Correction first. The body says: "With a terminated input the flag appears from +31…+35 dB." I could not reproduce that. On 2026-10-01 (02:39–03:27 local), on the same repaired HL2, a dummy load gave zero ADC-overload observations at every point of a −12…+48 dB sweep (step 4 dB, 6 s per point), on three dials. It also gave zero in 90 s with the loop armed at +48. I do not know why the 27 September run saw the flag. I have marked that sentence in the body as not reproduced. The code in this PR does not depend on it.

The antenna figures also moved. The body gives the first clip at +34 / +37 / +38 dB on a fan dipole. That night it came 2 to 6 dB earlier:

band, antenna last clean gain first clip
40 m, fan dipole +28 dB +32 dB
30 m, fan dipole +24 dB +28 dB
80 m, EFHW +28 dB +32 dB

Earlier the same evening (22:20) I needed about +8 dB on 40 m for a clean ADC. So the clip point moves by 20 dB or more over an evening on this station. One sweep does not define it.

The floor, re-measured (receive only, this PR's head ad95680c, fresh profile). A fresh connect installs the ramp law with a floor of 26.

  • From a +48 baseline, the loop settled at an offset of 24–26 dB on all three bands. That is at the floor or within 2 dB of it. It reached clean that night. With the 22:20 conditions it would not have: +48 − 26 = +22, and clean needed about +8.
  • From the +20 default, the loop never went deeper than 3 dB. A floor of 26 from +20 would allow gains below the point where the antenna noise is still 6 dB above the terminated floor. That point was +8 dB on 40 m and +12 dB on 30 m. On those two bands the floor allows more attenuation than is harmless. This was not exercised in the run.
  • Arming at +20, attenuating within 0.24–0.49 s of clip evidence, not hunting, and "off stays off" across a restart all passed on the three bands (23 checks). Of the 22 attacks, none came without clip evidence.

So the 26/24 figures are not wrong for a +48 baseline on a quiet night, and they are too deep for the +20 default on two of the three bands. Because the floor is an offset from the baseline, a stale high baseline stops short of clean on a busy evening. I am not changing the floor in this PR: #5535's ruling keeps maxOffsetDb as it is. These are the measurements to decide it on.

73, ON8ST

@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

A correction to my comment above: I undercounted.

I wrote that a floor of 26 dB from the +20 default is too deep "on two of the three bands". It is all three. From +20, the attenuation that still leaves the antenna noise 6 dB above the terminated floor was 12 dB on 40 m, 8 dB on 30 m and 24 dB on 80 m. The ramp floor of 26 exceeds all three, 80 m by 2 dB. A floor of 24 would exceed it on 40 m and 30 m only, and that is where my "two" came from. A fresh connect runs ramp, so 26 is the figure that applies.

Nothing else in that comment changes. The loop was not driven to the floor from +20 in the run, so this is still arithmetic on the measured table, not an observed failure.

73, ON8ST

@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

One more correction, and this one is a defect in our own merged code.

In my comment above I wrote that "a fresh connect installs the ramp law with a floor of 26". That is true, and I did not say what it means: it is not the law #5535 approved, and the cause is a line in our own #5652.

  • Hl2Backend's constructor installs the bandscope law (m_autoGainConfig = bandscopeReleaseConfig(…), Hl2Backend.cpp:357 at main 25da98de).
  • applyRestoredState() runs on every engaged connect and resets m_autoGainConfig = AutoGainConfig{} and m_autoGainMode = "ramp" (Hl2Backend.cpp:7618-7619). The comment above that block says the live members "reset to the same virgin defaults a fresh backend construction would have". They do not.
  • The reset line predates the commit that made the bandscope law the constructed default, and that commit did not touch applyRestoredState(). No test reads the law after a restore.

So since #5652 merged, an HL2 that arms auto RF gain after a connect has been running ramp. That law attacks and releases on the ADC clip counter alone, with a 26 dB floor. The #5535 ruling approved moving to the wideband bandscope as the input (point 3) and a maxOffsetDb of 24 (point 2). Neither applies after a connect.

What this changes here:

I have a local fix in progress: applyRestoredState() installs the same law and config as construction, and a test reads the law after a restore. I will bring it as its own PR, and then re-measure on the law that was approved.

73, ON8ST

@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

The fix for the connect law is now open as #6062.

Two things in this PR are affected by it:

  • This PR's body says "The default floor of 26 is unchanged." With fix(hl2): install the bandscope law on every connect. Principle VIII. #6062 the law after a connect is the bandscope law, and its floor is 24. That sentence is then true only of the ramp law.
  • The two PRs conflict in one place, tests/hl2_gain_split_test.cpp. That test asserted a floor of 26 in a restored session, which was the defect. Merged together locally with that assertion at 24, the full suite passes (558/558). Whichever of the two lands second gets that one-line resolution, and I will do it.

73, ON8ST

@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

I can now explain the terminated-input figure I could not reproduce, and I withdraw it.

The body said that with a terminated input the clip flag appears from +31…+35 dB. This morning I wrote that I did not know why the 27 September run saw that. The reason is in that run's own record: the leg was not on a terminated input. The sweep's "dummy" leg is taken on whatever antenna the switch is on when the run starts, and the switch's own read-back for that leg shows the 80 m EFHW, at 14.074 MHz. The leg's name said dummy. The antenna was live.

So:

  • The +31…+35 dB figure is a live-antenna clip point on 20 m. It is not a property of the converter with its input terminated. The sentence is struck in the body.
  • On a real dummy load (1 October, the switch's port read back by its label before every leg) there is no ADC-overload observation anywhere from −12 to +48 dB.
  • The fan-dipole figures in the same paragraph (+34 / +37 / +38 dB) were taken on the antenna they name. They stand as one afternoon's figures. My comment above gives the night's (+28 / +32).

This PR's code does not depend on the withdrawn figure. The check that let a leg be named for a load it was not on is in our own bench runner, and it is being fixed there.

73, ON8ST

@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

An update to what I wrote above about #6062. After its review round the two PRs conflict in two places, not one. The second is the floor comment in Hl2Backend.h: #6062 now rewrites its opening lines (it starts at 24 dB), and this PR rewrites the paragraph about the sweep behind the ramp's 26. It is a comment-only conflict. The resolution keeps #6062's opening and this PR's paragraph for the 26, and I will do it on whichever lands second.

73, ON8ST

on8st and others added 2 commits October 2, 2026 09:01
Main's comment sweep (aethersdr#6072) condensed every block this branch had
rewritten. Five files conflicted, all in comments except the one
constant this PR exists to change.

Resolution, per file:
- Hl2AutoGainPolicy.h, Hl2GainSplit.h: main's text as is. The fold
  narrative this branch corrected there is gone from main, so there is
  nothing left to correct.
- Hl2Backend.h: main's condensed comments, with the three statements
  that this PR makes false corrected: the 26 dB default is not a measured
  bound, the arming ceiling is the top of the native range
  (kAutoRfGainMaxBaselineDb = hl2::kLnaGainMaxDb), and the shipped +20 dB
  default is armable.
- Hl2Backend.cpp: main's condensed comments; the restore comment no
  longer says arming by default would be refused, and the refusal
  comment no longer says the issue number stays on the qWarning (this
  branch removed it).
- Hl2DbReference.h: main's condensed block; the fold is stated as one
  unit's hardware defect instead of an open question.

This branch's own added comments are cut to the comment rule: the
kAutoRfGainFloorMaxDb derivation (7 lines to 3) and the DEFENSIVE note
on the refusal guard (4 lines to 2). No behaviour changes in the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The arm-then-off leg in hl2_gain_restore_test and the first-tick leg in
hl2_gain_split_test each carried the history of the issue they came from.
They now say what the leg does. No assertion changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ten9876 ten9876 self-assigned this Oct 2, 2026
ten9876 and others added 5 commits October 2, 2026 09:42
…an. Principle VIII.

HERMES.md row 12 now states the fold caveat closed against ad9866.v
(883a338) and the repaired unit. Test and header comments drop the
retelling of the +19 history. hl2_gain_split_test pins that the deepest
operator floor reaches the register floor from the top armable baseline,
which goes red if kAutoRfGainFloorMaxDb returns to 31.

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

With the +19 ceiling gone, leg one of the aethersdr#5828 block arms for real before
the off, so a guard regressed to checking only the running flag stayed
green. The new leg restores autoEnabled: true, switches off before the
connect-time arm, and asserts the profile reads off; it goes red against
the regressed guard.

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

The unarmed branch of applyAutoRfGainToSlider hardcoded Flex's -8..+32 dB
in 8 dB steps on the accessible description and the tooltip, so an HL2
(-12..+48, 1 dB) was described as a Flex. Both channels now come from the
range setRfGainRange published, and a range push while Auto is armed keeps
the read-only reason instead of overwriting the tooltip.

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

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at b53498423. The change is accepted, and every finding is addressed and proved.

Fixed in this pass

  • a473bde63:
    • docs/HERMES.md roadmap row 12 no longer calls the fold caveat open; it states it closed against ad9866.v@883a338 and the repaired unit.
    • Comments that retold the #5354 history are condensed to AGENTS.md § Comments.
    • New check "the deepest floor reaches the register floor from the top armable baseline". With kAutoRfGainFloorMaxDb = 31 it goes [FAIL]; restored, it passes.
  • 9e482a786: the #5828 off from a restored wish, with the loop not yet running, is pinned. A guard regressed to if (on == m_autoRfGainEnabled) return; goes [FAIL]; restored, it passes. This came from the automated code-review pass.
  • 5b4ac55e8 (maintainer decision: fold #5943's second half in here and keep Closes #5943):
    • The RF Gain slider's accessible description and tooltip now come from the range the radio published, no longer from a hardcoded Flex −8..+32.
    • A range push while Auto is armed no longer overwrites the read-only tooltip.
    • Reverting the fix turns 4 checks [FAIL]. Removing the isEnabled guard turns the armed check [FAIL]. check_a11y reports no new warning.
  • ec678cdbd, b53498423: signed merges of main (#6045, #6007, #6021, #6005), both clean.

Verified empirically

  • Gateware authority (Principle I): ad9866.v:134 reads rx_gain <= cmd_data[6] ? cmd_data[5:0] : …. With bit 6 set all six bits are native, so codes 0–60 = −12…+48 dB and there is no five-bit fold. The file at 883a338 is identical to master.
  • Ceiling mutation (back to 19): hl2_gain_restore_test fails to compile on its static_assert, and hl2_gain_split_test gives 8 [FAIL]s.
  • Full ctest at b53498423: 550/551 pass. The one failure is connection_panel_size_test, a known local failure (#5811). Socket-owning tests were excluded per the test-layer boundary.
  • Static gates: engine boundary, capability records, command plane and the touchpoint manifest all match between merge base and head.

Reasoned, not run

  • The connect-time arm of a stored autoEnabled: true is a MetisClient::linkUp lambda and cannot be driven socket-free. It is disclosed in the body and in applyRestoredState.
  • The retained refusal branch is unreachable, because every writer of the baseline clamps to the native range. Its absent coverage is disclosed.

Hardware: on8st's receive-only runs on the repaired unit. Nothing was keyed, and no radio was touched in this pass.

@ten9876
ten9876 merged commit 7975baf into aethersdr:main Oct 2, 2026
5 checks passed
ten9876 added a commit to on8st/AetherSDR that referenced this pull request Oct 2, 2026
Resolves the two documented conflicts with aethersdr#5976: the floor comment in
Hl2Backend.h keeps this branch's per-law floors and aethersdr#5976's account of the
ramp's 26; hl2_gain_split_test keeps the 24 dB connect floor. The comment on
kAutoRfGainFloorMaxDb now says the default floor belongs to the law.

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

Refs #5535, #5652

**This is a defect in our own #5652, on main since it merged on
2026-09-18.** On every connect the automatic RF gain loop was reset to
the `ramp` law with a 26 dB floor. The ruling on #5535 approved the
bandscope as the input (point 3) and holds `maxOffsetDb` at 24 dB (point
2). So no connected Hermes-Lite 2 has run the approved law: the loop
attacked and released on the ADC clip counter alone, and the bandscope
gate was never started.

**This PR changes which law runs on every HL2 whose operator has
switched the loop on.** The loop still ships off, so an install that
never armed it is not affected. For one that did: 6 dB steps in both
directions instead of 3 to 6 dB down and 1 dB up, a floor of 24 dB
instead of 26, a release that waits for a measured wideband headroom
reading, and the EP4 gate running (about 0.11 Mbit/s, per the capability
map row for `hasAutoRfGain`).

**Hardware, updated 2026-10-01:** the connect-edge law was checked on a
Hermes-Lite 2, receive only, in one 49-minute run (figures in the
comment of that date). The re-arm after a link silence was not exercised
there. Everything below is from the source and from an offline test.

**It carries a second fix, which the title does not name.** The `linkUp`
handler now asks for the bandscope gate again when the loop is already
armed. That repairs a separate defect: a link that drops on EP6 silence
and resumes on the same session left an armed loop without its gate. It
is in this PR because the title's change is what makes it reachable by
default; on main it needed the bandscope law selected over the bridge.
The detail is under "The second fix" below. It can be split into its own
PR if the maintainers prefer; it would have to merge before this one or
with it.

**An existing test asserted the defect.** `hl2_gain_split_test` checked
`autoRfGainFloorDb() == 26` in a restored session, with the message "the
floor defaults to 26 dB". It pinned what the session happened to have.
It reads 24 here.

## What was wrong

`Hl2Backend::applyRestoredState()` resets the backend's live members and
says so: "the same virgin defaults a fresh backend construction would
have". For the gain law it did not. The constructor installs
`bandscopeReleaseConfig(...)` under the name `bandscope`.
`applyRestoredState()` installed `AutoGainConfig{}` under the name
`ramp`. `RadioModel::handRestoredStateToBackend()` calls it on every
engaged connect, also with an empty state.

How it got there (#5652's commits, from the GitHub API): the reset line
was written on 2026-09-09, when `ramp` was the only law. The commit that
made `bandscope` the default on 2026-09-12 changed the constructor, the
member initialiser and the header comment. It did not touch
`applyRestoredState()` or the `default` alias. So there were three
copies of the default and one was moved.

The alias is the same defect. `setAutoRfGainMode("default")` installed
`ramp`, six lines above a comment that calls `bandscope` "THE DEFAULT".

Under `ramp` nothing reads the bandscope. `applyBandscopeForAutoGain()`
starts the gate only when the installed law has
`requireHeadroomToRelease`, and `ramp` does not.

## The change

| what | where |
|---|---|
| One source for the default: `defaultAutoGainLaw()` returns the name
and the config together, `installDefaultAutoGainLaw()` installs both |
`Hl2Backend.cpp`, above `setAutoRfGainMode` |
| The constructor calls it | `Hl2Backend::Hl2Backend` |
| `applyRestoredState()` calls it, in place of `AutoGainConfig{}` and
`"ramp"` | `Hl2Backend::applyRestoredState` |
| `default` names whatever `defaultAutoGainLaw()` returns |
`Hl2Backend::setAutoRfGainMode` |
| The member initialiser `m_autoGainMode = "bandscope"` is removed: it
was the third copy | `Hl2Backend.h` |
| **The second fix**: on `linkUp`, when the loop is already running, ask
for the gate again (see below) | the `MetisClient::linkUp` handler in
the constructor |
| `operator==` on `AutoGainConfig`, defaulted, so "the same law" is one
comparison over every field | `Hl2AutoGainPolicy.h` |
| A friend declaration for the test seam | `Hl2Backend.h` |
| Two comments corrected: the floor comment now opens with the 24 dB a
backend starts at and says which law carries which floor (24 for
`bandscope` and `probe`, 26 for `ramp` and `binary`), and the
`bandscope.enable` note no longer says nothing in the app decides from
the stream | `Hl2Backend.h`, `Hl2Backend.cpp` |

Files: `src/core/backends/hl2/Hl2Backend.cpp`, `Hl2Backend.h`,
`Hl2AutoGainPolicy.h`, `tests/hl2_auto_gain_law_test.cpp` (new),
`tests/hl2_gain_split_test.cpp`, `tests/tests.cmake`.

**Persisted choices are untouched.** The only automatic-gain facts on
disk are `autoEnabled` and the per-band gains, and both are restored as
before. The law and the floor were never persisted and still are not. A
law selected over the bridge (`pan autorfgain mode <law>`) lasts until
the next connect. That was already so; the header now says it.

## Does the bandscope feed run after a connect

Followed in the source, with the fix:

1. `applyRestoredState()` installs the bandscope law and restores the
operator's switch (`autoEnabled`).
2. `linkUp`: `m_connected` is set, the bandscope mirrors are reset, the
restored baseline is pushed, and then a saved switch calls
`setAutoRfGain(true)`.
3. If the baseline is one the loop arms from, `setAutoRfGain` sets the
running flag and calls `applyBandscopeForAutoGain()`. Connected, armed,
and the law needs the reading: it invokes
`MetisClient::setBandscopeEnabled(true)`.
4. `linkUp` is only emitted by a running client, so the enable is not
dropped at `setBandscopeEnabled`'s `!m_running` guard. The gate arms,
blocks arrive as `bandscopeBlockReady`, and `stepAutoGain()` classifies
the newest one into the observation the policy reads.

On main today step 3 still runs, and the test for "the law needs the
reading" is false, because of the law.

## The second fix: the gate after a link bounce

`MetisClient` re-emits `linkUp` when EP6 resumes after a silence, with
no `connectRadio()` and no `applyRestoredState()` in between. The
silence watchdog ended the gate's intent before it emitted `linkDown`
(`MetisClient::onWatchdogTick`, stage 2), the backend dropped its claim
on that edge, and nothing disarmed the loop. The `linkUp` handler only
armed when the loop was not running, so the gate was not asked for
again. Under a law that releases on a measurement the offset would then
hold, reporting "held — no wideband headroom reading", until the
operator toggled the switch.

It could be reached on main only after selecting the bandscope law over
the bridge. With this PR it would be the default path, so the handler
now calls `applyBandscopeForAutoGain()` in that case. It is a no-op for
a loop that is off and for a law that does not read the bandscope.

**A bandscope the operator enabled by hand, across a bounce.** It does
not survive the bounce, with or without this PR: the watchdog ends every
gate, whoever asked for it. What changes is what follows when the loop
is armed under the bandscope law. Before, nothing ran. Now the loop
starts a gate of its own, so the stream is back, and it is the loop's:
disarming the loop or selecting another law stops it. An operator who
wants the diagnostic stream to outlive the loop has to enable it again
after the bounce, as they already had to.

**Off by default still means no stream.** With no saved switch the law
is installed and the gate is not asked for. The test checks it.

## Tests

`hl2_auto_gain_law_test` (new, 48 checks). Socket-free: it never calls
`connectRadio()`. The link edges, telemetry and bandscope blocks go in
through `MetisClient`'s own signals, so the backend's real handlers run.

1. A constructed backend: law `bandscope`, floor 24, release needs
headroom. The numbers are the ruling's, written in the test.
2. After `applyRestoredState({})` the law, the floor, every field of the
config and the health rows equal a constructed backend's.
3. After a restore with saved state, from a session that had selected
`ramp` and the deepest floor: the law and the config are the constructed
ones, and the saved switch and per-band gain are restored.
4. `default` installs the constructed law.
5. After a link edge with a saved switch: the loop arms, runs
`bandscope`, and the backend has asked for the gate. Then it steps on
telemetry:
- an occasional clip is answered with one 6 dB step (the ramp's is 3);
- 40 s clean with no bandscope block gives nothing back, reason
`HeadroomAbsent` (the ramp and the probing law would both have
released);
   - a block with room, and the next clean window releases 6 dB.
Then `linkDown` and `linkUp` on the same session: the gate is asked for
again. Then `ramp` is selected and the gate is released.
6. A profile with no switch: connected, not armed, law installed, no
gate asked for.
7. The second fix, from each side. `linkDown` and `linkUp` on the same
session:
- armed under `bandscope`, after the operator's own `bandscope.enable`
had taken the claim from the loop: the gate is asked for again;
   - armed under `ramp`: no gate is asked for;
   - loop off: no gate is asked for.

**What the test does not prove.** It shows the backend asks for the
gate. It does not show a radio answering: `MetisClient` ignores the
enable with no stream behind it, and the gate itself is
`hl2_ep4_gate_test`'s subject. The baseline is seeded by hand through
the friend seam, because `connectRadio()` hands the DSP opens to the I/O
thread and its completion would start the wire.

Red before green, full suite (558 tests), macOS arm64, Qt 6.12.0:

| tree | result |
|---|---|
| test commit on main's code (`df0120fb`) | RED:
`hl2_auto_gain_law_test` fails 18 of 40, 557 of 558 pass |
| the fix (`9dfb326c`) | GREEN, 558 of 558 |
| this branch after review (`2c8409f8`) | GREEN, 558 of 558 |

Mutations of this branch, same suite:

| mutation | result |
|---|---|
| `applyRestoredState()` back to `AutoGainConfig{}` and `"ramp"` | RED:
17 checks in `hl2_auto_gain_law_test`, and the floor check in
`hl2_gain_split_test` |
| the `linkUp` re-ask removed (re-run on `2c8409f8`) | RED: two checks,
"the resumed link asks for the gate again" and "and the resumed link
asks for the gate on the loop's behalf"; 557 of 558 pass |
| `default` back to `ramp` | RED: one check, "and installs the law a
constructed backend has" |

`hl2_gain_split_test`: one assertion changed, 26 to 24, as said above.

`tests/tests.cmake`: the new test is registered there, and listed in
`AETHER_SETTINGS_CONSUMERS` beside `hl2_gain_restore_test` and
`hl2_gain_split_test`.

Static checks from `.github/workflows/static-checks.yml`, run locally:
all pass.

## With #5976

#5976 (now on main) lets a saved switch arm from the stock +20 dB
baseline. With both, every profile with `autoEnabled` set arms the
bandscope law on the next connect and starts the EP4 gate. The floor
comment in `Hl2Backend.h` carries both: 24 dB for `bandscope` and
`probe` (what a backend starts with), 26 for `ramp` and `binary`, and
#5976's note that the 26 was sized from a sweep on a unit with a faulty
gain bit.

## Not done

- **Hardware.** One receive-only run on one board (2026-10-01 evening):
`bandscope`, floor 24 after each of three connects, and the EP4 feed
running while armed. The re-arm after a link silence has not been seen
on a radio. Every d168 figure posted before that date is a `ramp`
figure.
- **The floor question on #5535.** Our figures for sizing a floor were
taken under `ramp`. They should be taken again under the approved law
before that question is asked.
- **Persisting the law or the floor.** Not asked for, and a design
choice.
- **`applyRestoredState()` while the gate is ours.** It clears the
running flag without releasing a gate the loop started. The link edges
around a connect reset the claim, so I found no path where it matters,
and it is unchanged here.
- **Default-on.** Unchanged. `autoEnabled` absent still reads false.

Cross-platform: built and tested on macOS arm64 only. Nothing here is
platform-specific code, but Linux and Windows were not run.

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>
ten9876 added a commit that referenced this pull request Oct 3, 2026
…ciple VI. (#6104)

## Summary

On HL2 and Icom, a TUNE power change made while TUNE is keyed now
reaches the carrier. Before this, the value waited for the next
key-down, and the operator saw a "dropped" notice for a control that
looks live. This PR also fixes two TX-power defects in the same paths:

- **HL2 transmit tail.** At TUNE release the RF drive was restored right
after MOX-off, but the HL2 keeps radiating for its FIFO drain plus PTT
hang after the MOX-off frame. The restore landed under the
still-draining full-scale tune tone. The drive restore and the receive
filter switch now wait out that tail. (Found by ON8ST.)
- **Icom restore.** An RF power change made during TUNE was written over
the tune drive and then lost at the unkey.

## What changed

**Seam and capability**
- `IRadioBackend::setTunePower(int)` is a new verb with a default no-op,
next to `setTxPower`. It never keys. A backend that generates its own
carrier re-applies drive to a carrier in progress and otherwise does
nothing; the next `setTune()` carries the value. Owner-thread contract
as for `setTxPower`.
- `RadioCapabilities::TransmitDriveControl::tunePowerAppliesLive`
defaults to `false`. It is a field inside the existing record, not a
top-level bool, so `FROZEN_BOOL_COUNT` stays at 71
(`check_capability_records.py --strict` reports 71 on both base and
head). The contract text in `IRadioBackend.h` and
`docs/architecture/radio-capabilities-map.md` are updated to match.

**Models**
- New signal `TransmitModel::tunePowerCommandIssued(int)`. Only
`setTunePower()` emits it, after `commandReady`, mirroring
`rfPowerCommandIssued`. `applyChanges()` (status decode) never emits it.
- `RadioModel` connects to it in the constructor's model-lifetime block,
next to `rfPowerCommandIssued` (#4599). It forwards a change only when
all of these hold:
  - `isTuning()` is true
  - a live `TxActivity::Tune` contribution exists
  - the backend declares `tunePowerAppliesLive`
- The connect checks `tunePowerAppliesLive` before calling
`setTunePower`. The behaviour is the same for every real backend,
because the base verb is a no-op. The check makes the contract explicit:
the field promises the verb is real, and a backend that doesn't declare
it is never handed the call.
- Both the forward and the drop-notice gate use one predicate,
`RadioModel::tuneCarrierLive()`: `isTuning()` AND a live
`TxActivity::Tune`. The gate is `routed = canTransmit &&
transmitDriveControl && (!isTuning() || (tuneCarrierLive() &&
tunePowerAppliesLive))`. While tuning, a value that is not forwarded
live always keeps its notice: a backend without the field, or a tune
state decoded off the radio with no TUNE this client admitted.
- Every `TransmitDriveControl` is built with the field stated
explicitly, as the ADDING A FIELD rule requires: `true` on HL2 and Icom,
`false` on Flex (whose tune power travels as command-plane text) and
ANAN. Sim and RTL declare no record.

**HL2**
- `Hl2Backend::setTunePower` writes only the drive register, through
`applyDrive()`, so the clamp and the transmit gate still apply. It
doesn't touch `m_rfPowerPercent`, band memory or
`notifyOperatingStateChanged`, and it doesn't route through `setTxPower`
or re-call `setTune(true)`.
- It acts only while `m_tuning` is true. It also acts only while the
TUNE operation, stored as `m_tuneOperation` in `setTune(true)`, still
permits dispatch. That optional defence is included: a TUNE whose
admission was revoked or emergency-stopped takes no drive change. Every
unkey and the connection reset clear `m_tuneOperation`, so it never
outlives the carrier.
- `tunePowerAppliesLive = true` in the HL2 capabilities.

**Icom**
- `IcomCivBackend::setTunePower` writes CI-V `14 0A` (the tune drive)
while tuning and does nothing otherwise. It also requires the same
admission `setTune(true)` checked: the TUNE operation stored there, and
the backend's transmit context. The operation is cleared at every unkey
and session reset. A refusal is logged on `aether.icom.tx`, because the
verb returns nothing to `RadioModel`. `tunePowerAppliesLive = true` in
the Icom capabilities.
- **Restore fix.** `setTxPower` during TUNE now records the value in
`m_preTuneTxPowerPercent`, which the unkey (and the disconnect emergency
path) writes back. It no longer writes over the tune drive.
- The raw write is factored into `writeTxPowerLevel()`. The TUNE
re-request branch in `setTune(true)` and the `tx.power` controls scrub
both use it, so neither can stamp the tune drive into the restore slot.
- **Readback during TUNE.** The CI-V read that confirms the tune-drive
write (`14 0A`) is no longer published as `TransmitDelta::rfPower` while
tuning. The periodic poll already skipped that read during TUNE. Without
this, `TransmitModel::rfPower()` showed the tune level. A relative
control (the power wheel, or a CAT/TCI read-modify-write) would then
step from that level, and the result would become the value the unkey
restores. Example: 100% RF and 10% tune, then one wheel step, left the
radio at 11%. *Visible effect:* on Icom the RF power slider no longer
jumps to the tune level while TUNE is keyed. It keeps the operator's RF
power, matching Flex. (Found by code review of this branch.)

**Evidence for the readback leak, and its limits.** This comes from the
code path and a scripted-reply unit test. It is **not
hardware-verified**: no Icom was on the bench.
- Icom has no tune-carrier command, so TUNE writes the tune level into
`14 0A`.
- `sendUserCommand()` queues a confirming read of that level 60 ms later
(`queueRead(..., now + 60)`).
- The `kRfPower` reply handler published it as `TransmitDelta::rfPower`.
- `icom_backend_seam_test` scripts that CI-V reply. It does not observe
a real radio's answer.

**Still wanted:** a CI-V capture from a real Icom of TUNE plus one
RF-power wheel step. It should show the confirming `14 0A` read during
TUNE and confirm that the restore after the unkey is the operator's RF
power.

## The HL2 transmit tail (found by ON8ST)

ON8ST showed from the gateware RTL that the HL2 keeps transmitting after
the MOX-off frame:
- MOX travels with the samples, not as a register.
`dsopenhpsdr1.v:351-352` tags each sample with `ds_cmd_ptt`.
- In `PTTTX`, `radio.v` drains the keyed samples already queued in the
FIFO (`tx_buffer_latency`, 20 ms default, `radio.v:934`). AetherSDR
never writes 0x17, so the default applies.
- It then holds the last sample for `ptt_hang_time` (12 ms default,
`radio.v:935`, 1036-1047). `tx_on` stays high until the state machine
reaches `NOTX` (`radio.v:1002`).
- Drive is not buffered. `ad9866ctrl.v:134-140` loads the TX gain as
soon as the command arrives.

So restoring RF drive one frame after MOX-off still landed under roughly
20 + 12 ms of full-scale tune carrier. ON8ST's unit (gateware 74.2)
unkeyed itself 40-54 ms after the host stopped sending keyed frames. The
same applies to the receive filter switch at unkey.

**Why a timer, not `tx_on`.** `tx_on` is reported only in the discovery
/ alternate-port reply: `resp_control` (`control.v:899`) feeds
`usopenhpsdr1.v:292` (`6'h20`). The EP6 stream's responses carry
`ptt_resp = cw_on | ext_ptt` instead (`control.v:456`, 472-475), which a
host MOX never raises. `MetisProtocol.cpp:546` is in
`parseDiscoveryReply()`. Reading `tx_on` mid-stream would mean polling
the discovery port during TX, which the stream-free telemetry design
avoids because that reply preempts EP6 (`Hl2TelemetryCadence.h`).
Gateware without `EXTENDED_RESP` also sends zeros there, so a "`tx_on`
low" trigger would fail open.

**Fix (`Hl2Backend`).**
- Every key-up arms a transmit tail of `kTxTailHoldMs` = 70 ms, after
the MOX-off is queued.
- 70 ms is never below the existing RX unkey hold (a `static_assert`
pins it). It covers the gateware's 20 + 12 ms default tail and ON8ST's
40-54 ms measurement. The RX-hold measurement of the same tail on one
HL2 was 51.66-66.15 ms, n = 11.
- While the tail is pending, `applyBandFilter()` keeps the transmit
filter byte, and `setTxPower()` records an owed RF drive without writing
it.
- When the tail ends, the receive filter and the owed RF drive are
applied.
- A re-key cancels the tail, so a restore is never applied over a new
carrier, and the drive stays owed to the next unkey's tail.
- A disconnect or connection reset drops the tail.

Icom is unchanged: its PTT-off is queued before the power restore, and
the radio owns its own timing.

**Maintainer decisions (Jeremy):**
1. A fixed 70 ms timer ends the tail. There is no `tx_on` poll, and 0x17
is not written.
2. A voice key inside the tail of a TUNE release keeps the tune drive
for that transmission. The restore is cancelled, never applied over the
new carrier, and RF drive returns at the next unkey's tail.
3. The transmit filter byte is held for the tail on every unkey,
including CW break-in elements.

## Out of scope (unchanged)

- No changes to TCI `tune_drive` or MIDI power limits. Note that TCI
`tune_drive` and the automation `transmit tunepower` verb go through
`TransmitModel::setTunePower`, so on HL2/Icom they now also apply live
while TUNE is keyed, as they already did on Flex.
- No slider dimming or tooltip UX for backends that don't declare the
field.
- No tune-power ceiling tied to RF power. Flex keeps them independent,
and so does this PR.
- Drag flood is accepted as is, matching RF power: no coalescing and no
debounce.

## Tests (all socket-free; nothing keys real hardware)

- `transmit_seam_drop_notice_test`:
- Flag on: `setTunePower(30)` arrives exactly once, with no re-key and
no drop notice.
  - Flag off: no call, and the notice stays.
  - Not tuning: no call, and the next `startTune` carries the value.
- A decoded tune power while keyed (`applyChanges`, a different value)
sends no call.
  - After `stopTune`, no call.
- A tune state decoded off the radio (`applyChanges`, `tune=true`) with
no live Tune activity: no call, and the drop notice stands. This pins
the Tune-activity check on its own, in both the forward and the gate.
- `hl2_tx_gate_test` reads the 0x09 bank bytes off the real EP2 packet
builder.

  | Step | 0x09 bank result |
  |---|---|
  | RF power 40% | drive 102 |
  | TUNE at 10% | drive 25 |
  | live 60% | drive 153, PA on |
  | 150% | 255 |
  | −5% | 0, PA off |
  | release | 102 restored |
  | 80% after release | no bank, no keyed frame |
  | gate closed while tuning | 0, PA off |
  | TUNE operation emergency-stopped | no bank |
  | release | stored TUNE operation dropped |
  | stopped client | no bank |

The same file has the transmit-tail tests, with the hold parked so each
test ends the tail itself:
- The unkey's queued calls post no RF-drive restore; walked packet by
packet with an event filter on `MetisClient`.
  - The tail ending restores RF drive.
- A re-key inside the tail cancels it; no restore under the new carrier,
and the next unkey's tail pays it.
  - TUNE re-keyed inside the tail keeps its tune drive.
  - RF power set during the tail waits for it.
  - A live tune drive with no key-time tune power is still restored.
  - A disconnect drops the tail.
- On a TX-only filter board, the TX byte holds through the tail, and
through a retune inside it, until the tail ends.
  - The real timer ends a 20 ms tail.
- `icom_backend_seam_test` checks the CI-V `14 0A` writes:
  - The capability is declared.
  - Not tuning: no write.
  - While tuning: the live 25% is written, with no re-key.
  - RF 50% during TUNE: no write, and it is held for restore.
  - A TUNE re-request writes 15% and keeps 50% held.
- A `tx.power` scrub during TUNE re-asserts the live 15% and keeps 50%
held.
  - Release writes 50%.
  - After release: no write, and the stored TUNE operation is dropped.
  - A TUNE whose operation was emergency-stopped: no write.
- A lapsed transmit context with the operation still live: no write, and
one warning logged.
- An RF power readback of the tune level during TUNE publishes no
`rfPower`. The premise is checked first: the same readback outside TUNE
does publish.

### Mutation results (each mutation made its test fail, then the file
was restored and verified by sha256 against HEAD)

| Mutation | Result |
|---|---|
| RadioModel connect deleted | RED |
| gate reverted to `!tuneKeyed` | RED |
| flag ignored (gate + connect) | RED |
| wired from `tunePowerChanged` instead | RED |
| `isTuning`/Tune-activity check removed | RED |
| Tune-activity check alone removed | RED |
| HL2 `m_tuning` guard removed | RED |
| HL2 implemented via `setTxPower` | RED |
| HL2 `applyDrive` bypassed | RED |
| HL2 tune-operation check removed | RED |
| HL2 flag not declared | RED |
| HL2 tail deferral removed (restore at unkey) | RED |
| HL2 re-key cancel removed | RED |
| HL2 `setTxPower` tail check removed | RED |
| HL2 filter predicate ignores the tail | RED |
| HL2 disconnect cancel removed | RED |
| HL2 `setTunePower` not marking drive owed | RED |
| Icom restore fix reverted | RED |
| Icom `setTunePower` guard removed | RED |
| Icom `setTunePower` no-op | RED |
| Icom TUNE re-request via `setTxPower` | RED |
| Icom scrub via `setTxPower` | RED |
| Icom flag not declared | RED |
| Icom tune-drive readback published during TUNE | RED |
| Icom tune-operation check removed | RED |
| Icom tune operation not stored at `setTune(true)` | RED |
| Icom tune operation not cleared at unkey | RED |
| Icom transmit-context check removed | RED |
| Icom refusal warning removed | RED |
| Drop-notice gate ignores `tuneCarrierLive` | RED |
| Forward uses `isTuning()` alone | RED |
| HL2 tune operation not cleared at unkey | RED |

## Merge with main

The branch was cut from `2d5712946`. It now carries signed merges of
`origin/main` up to `c7513e125` (27 commits, including #6062, #6005,
#5979, #5982, #5976, #6011, #5980, #6019, #5981, #6012 and #6050 on HL2,
and #6049 in `RadioModel`). Every merge was clean. The files both sides
touched (`Hl2Backend.cpp/.h`, `IRadioBackend.h`, `RadioModel.cpp`,
`radio-capabilities-map.md`) needed no manual resolution, and none of
main's changes in them touch keying, drive or TUNE. Everything below was
re-run on the current head.

## Static gates (merge base `c7513e125` vs merged head, findings diffed
per file)

`check_engine_boundary.py --strict`, `gen_touchpoint_manifest.py
--check`, `check_capability_records.py --strict` and
`check_command_plane.py --strict` all produce identical findings on base
and head once line numbers are normalised:

- Engine boundary: 102 tracked findings, 0 blocking.
- Touchpoint manifest: up to date.
- Capability records: 71 bools.
- Command plane: 401 commands across 32 files.

No ratchet moved, and no new wire text was added.
`gen_seam_probe_table.py --check` is clean (51 signals): this PR adds a
seam verb but no seam signal.

## Not verified

- No live-radio test on HL2 or Icom; per the TX rules, nothing was
keyed. The Icom path, including the readback leak and its guard, is
proven only against the backend's CI-V output and scripted CI-V replies.
A real-Icom CI-V capture of TUNE plus one RF-power wheel step is still
wanted.
- Icom's RF-power readback is suppressed during TUNE. CI-V cannot tell
the confirming read of the tune drive from a front-panel change, so an
RF-power knob turned on the radio during TUNE is not seen and the unkey
restores the pre-TUNE value (the code comment says so).
- The HL2 transmit-tail fix has no hardware test. Its 70 ms rests on the
gateware source defaults and on bench measurements made before it
existed: ON8ST's 40-54 ms unkey, and the RX unkey-hold measurement of
the same tail. Nothing here observed `tx_on` or RF output with this
change.
- Full default ctest at -j8 on the merged head, with every test whose
source uses a socket API excluded (523 of 620 run; the pty-backed
`thumbdv_queue_test` and four new socket tests from main are excluded
too): all pass except `connection_panel_size_test`, which is a known
local failure (#5811). `transmit_seam_drop_notice_test`,
`hl2_tx_gate_test` and `icom_backend_seam_test` pass.

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

---------

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>
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.

HL2 publishes a -12..+48 dB RF gain slider while its own auto-gain declines to arm above +19, and asking for more gain can deliver less

2 participants