Skip to content

fix(hl2): install the bandscope law on every connect. Principle VIII. - #6062

Merged
ten9876 merged 10 commits into
aethersdr:mainfrom
on8st:hl2/auto-gain-law-on-connect
Oct 2, 2026
Merged

ten9876 merged 10 commits into
aethersdr:mainfrom
on8st:hl2/auto-gain-law-on-connect

Conversation

@on8st

@on8st on8st commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

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 [RFC] Automatic RF-gain protection for the Hermes-Lite 2 #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

on8st and others added 2 commits October 1, 2026 12:07
RFC aethersdr#5535 approved the automatic RF gain loop driving on the wideband
bandscope with a floor of 24 dB, and the constructor installs that law.
applyRestoredState(), which RadioModel calls on every engaged connect,
resets it to the clip-counter ramp with a floor of 26. No test read the
law after a restore, so the suite was green across it.

hl2_auto_gain_law_test reads it: after applyRestoredState({}), after a
restore with saved state, and through a link edge fired on MetisClient's
own signal, where it also steps the loop on telemetry and a bandscope
block. Socket-free; it never calls connectRadio().

RED on this commit, by design: 18 of its 40 checks fail against main's
code and nothing else in the suite does (557 of 558 pass). They are the
law, the floor, the config and the health rows after a restore, the
"default" name, the gate request, the 6 dB step, and the release that
waits for a bandscope reading.

Scaffolding only in src/: a friend declaration for the test seam and a
defaulted operator== on AutoGainConfig, so "the same law" is one
comparison over every field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Our own defect, from aethersdr#5652. applyRestoredState() reset the automatic RF
gain loop to AutoGainConfig{} under the name "ramp", while the
constructor installed the bandscope law. RadioModel calls it on every
engaged connect, so a connected HL2 never ran the law RFC aethersdr#5535
approved: it attacked and released on the clip counter alone, with a
floor of 26 dB where the ruling holds 24, and the bandscope gate was
never started because only a law that needs the reading starts it.

One source for the default now. defaultAutoGainLaw() returns the name
and the config together; the constructor, applyRestoredState() and the
name "default" all go through it, and the member initialiser that
carried a third copy is gone. "default" named the ramp for the same
reason: it was written when the ramp was the default and was not moved
with it.

The linkUp handler also asks for the gate again when the loop is already
running. MetisClient re-emits linkUp when EP6 resumes after a silence,
with no restore in between, so nothing re-armed the loop while the link
edge had ended the gate. Under a law that releases on a measurement the
offset would then hold until the operator toggled the switch.

The operator's persisted choices are untouched: autoEnabled and the
per-band gains. The law and the floor were never persisted and still are
not; both return to the constructed default on a connect.

This changes which law runs for every operator who has switched the loop
on. The loop still ships off. Not verified on hardware.

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

@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

No Fixes #NNNN was available to me in the review material, so I reviewed this against its stated intent and against the governing ruling, RFC #5535. It solves the stated defect, and the defect is real.

I verified the divergence rather than taking the body's word for it. At 25da98de:

  • Hl2Backend.cpp:357 — constructor installs bandscopeReleaseConfig(...) (maxOffsetDb = 24, via probingReleaseConfig()).
  • Hl2Backend.cpp:7618-7619 — applyRestoredState() installs AutoGainConfig{} (floor 26) under the name "ramp".
  • git blame puts both lines in the same commit, 020d31fb, so this is an internal inconsistency in one change rather than a later deliberate override. Nothing else in the tree writes those two members (grep for m_autoGainConfig = / m_autoGainMode = returns only the constructor, applyRestoredState, and setAutoRfGainMode).

The authority checks out, and it is stronger than the body claims: @ten9876's ruling on #5535 says in as many words "maxOffsetDb stays at 24 dB. Do not re-size it against the fold" and puts the wideband input "on record as approved, not merely tolerated". And docs/architecture/radio-capabilities-map.md:159 — untouched by this PR — already documents "Arming in the default bandscope law starts the wideband gate — about 0.11 Mbit/s". So the docs and the health row (autoRfGainMode, which reported m_autoGainMode) were already describing bandscope while a connected backend ran the clip-counter ramp. This PR makes the code agree with the ruling and with the documentation, rather than changing the contract.

One consequence worth stating plainly for the maintainer, since it is on-air behaviour rather than bookkeeping: for an operator who already had Auto armed, this PR changes what their radio does. The law becomes bandscope (6 dB steps, release gated on a measured reading), the floor becomes 24 dB, and arming now genuinely starts the EP4 wideband gate — which the effective-ramp path never did. That is the approved design and the bandwidth is already in the capabilities table; it is not undisclosed canon, but it is the row a release note would want.

2. Scope

File / group What it changes Claimed by the title? Verdict
Hl2AutoGainPolicy.h defaulted hidden-friend operator== on AutoGainConfig Implied — the test's "every field matches" comparison needs it In scope. All members are int/std::int64_t/double/bool, so constexpr ... noexcept = default is well-formed; no prior == to make ambiguous
Hl2Backend.cpp — ctor / applyRestoredState / defaultAutoGainLaw / setAutoRfGainMode the fix itself, one source for the default Yes In scope
Hl2Backend.cpp:586-600 — else { applyBandscopeForAutoGain(); } on linkUp a second, separate defect: an already-armed loop loses its gate claim on a watchdog link bounce and never re-asks No — the title covers the law, not the gate Not a blocker; see nit 1. Disclosed in the code comment, covered by test section 5
Hl2Backend.cpp:6395-6411 comment rewrite at the bandscope.enable verb Not claimed, but it is the comment the change falsifies ("nothing in the app makes a decision from it") In scope — correcting it was required
Hl2Backend.h helper declarations, AutoGainLaw, test friend, doc comments Yes In scope
tests/hl2_auto_gain_law_test.cpp new socket-free test Yes In scope
tests/hl2_gain_split_test.cpp 26 → 24 Yes In scope and correct: that test's Session fixture calls applyRestoredState() at :109 before asserting, so 26 was pinning the defect
tests/tests.cmake registration Yes In scope

No CHANGELOG.md edit, no new public/protocol surface ("default" is an existing accepted alias whose meaning changes, and grep finds no caller in src/ that passes it — AutomationServer.cpp:3587 documents only bandscope|ramp|probe|binary), no removed guard, no sibling implementation left behind (IAutoRfGainControl has exactly one implementor).

3. Blockers

None.

4. Nits (non-blocking)

  • The gate re-ask is a second fix riding the same title. linkUp's new else branch cures a distinct failure (armed loop + bandscope law + EP6 silence bounce → HeadroomAbsent and a held offset until the operator toggles the switch). It is correct, idempotent, and tested — but a reader of the title would not know it is in here. Worth a sentence in the body, or its own commit message.
  • Hl2Backend.h:204 still opens "DEFAULT 26 dB" and is corrected eleven lines later at 216. A reader skimming for the default lands on the wrong number. Inline comment with a suggested lead.
  • Hl2Backend.cpp:6407 is 97 columns in a file that otherwise wraps at 80 — a reflow artifact from the comment rewrite. Inline suggestion attached.
  • hl2_auto_gain_law_test is not in AETHER_SETTINGS_CONSUMERS (tests/tests.cmake:6693) while both of its nearest neighbours — hl2_gain_restore_test, hl2_gain_split_test — are. Harmless: aethercore links aether_sqlite3 PUBLIC (CMakeLists.txt:2308), so the symbols resolve. Consistency only.
  • Commit order is test-then-fix, so df0120fb is red on its own. Deliberate red-green is a legitimate style here; flagging only in case git bisect across the pair matters to you.

5. Socket-owning tests

The new hl2_auto_gain_law_test is socket-free, and I checked the source rather than only the cmake block: no QTcpServer/QUdpSocket/bind/listen/connectToHost, no peer process, no Fake* radio. It constructs a real Hl2Backend (whose MetisClient owns a UDP socket) but never calls connectRadio(), so nothing is opened or bound; link edges, telemetry and EP4 blocks are injected through MetisClient's own signals with BlockingQueuedConnection onto the I/O thread the constructor starts at Hl2Backend.cpp:415. Qt6::Network is linked because aethercore's headers need it, not because the test binds anything. Nothing removed. Nothing to escalate.

Registration is canon-correct: the test is declared in tests/tests.cmake and not added to ci.yml, which is what tools/check_ci_test_gate.py requires ("that list is FROZEN: no new test joins it"). It runs unfiltered in full-suite.yml after merge.

6. What I tried to break

Everything below is reasoned from the head checkout at /tmp/aetherclaude/pr-6062; I have no build and ran nothing, so no runtime claim here is observed.

  • "The test fails on main." Checked by hand rather than trusted. Section 2's autoRfGainReleaseNeedsDb row is published only under requireHeadroomToRelease (Hl2Backend.cpp:6934), so on main it is present for fresh and absent for restored — the comparison genuinely goes red, and the isValid() positive control stops two absent values from agreeing. Section 4 goes red because main's "default" maps to "ramp". Section 5's == 6 discriminates the laws (bandscope steps one 6 dB quantum; the ramp's first step at that rate is 3). The three clean windows that precede it match warmupWindows = 3 exactly, so the step is not being eaten by warmup.
  • A deadlock or a timeout in the new test. BlockingQueuedConnection into m_metis would hang if the I/O thread had no event loop; m_ioThread->start() runs in the constructor (:415), before any test code, so it does not.
  • m_autoGainMode losing its member initialiser. Looked for a read between zero-init and installDefaultAutoGainLaw() at :357. The base IRadioBackend constructor cannot dispatch to a derived virtual, nothing else runs first, and defaultAutoGainLaw() only needs the static MetisClient::bandscopeSamplePeriodMs() — the comment's "before m_metis exists" claim holds.
  • The "don't stomp a manual enable" invariant, across a watchdog bounce. The sharpest thing I could construct: operator enables the bandscope via the extension, link bounces, resetBandscopeMirrors() clears both the mirror and the ownership flag, the new re-ask then claims ownership of a stream the operator started — and a later disarm kills it. It does not reach: per the #5650 note at :523-528 the silence watchdog ends the gate's intent too, so the operator's stream is already gone before the re-ask, and auto-gain claims a gate it then starts itself. applyBandscopeForAutoGain also reads m_bandscopeEnabled (an observation of LinkCounters, never a request), so the guard is reading reality.
  • Sibling reset paths and persistence. grep confirms no third site writes the law or the config. The "neither the law nor the floor is persisted" claim holds: currentOperatingState() writes only autoEnabled + lnaDbByBand + defaultDb (:8080), and applyRestoredState reads autoEnabled with no schema gate, so the test's extensionSchemaVersion = 1 profile is representative.
  • Downstream readers of the old default. grep over src/, tests/ and docs/ for the floor and the law names: the only tests that read them are the two in this PR, the bridge help text already lists bandscope first, no GUI surface renders the floor, and laws() already returned bandscope first — so there is no doc or UI left asserting 26 / "ramp".
  • CI. All five checks green on 9dfb326, and the Linux (ci.yml:282) and macOS (:1147) jobs are full cmake --build, so the new test target and the defaulted operator== really do compile on both. check-windows is a --target subset and does not compile this test; I read it for the usual MSVC traps (M_PI, std::min/max under windows.h) and found none.

What I could not check: that any of this passes at runtime, that a radio answers the gate the backend now asks for, and the PR body's own claims — the body was not in the material I was given, so if it asserts a local ctest result or hardware evidence, that is unverified here rather than confirmed.

7. Recommendation

Approve with nits. The defect is verified against git blame, the fix collapses three copies of the default to one, the authority cited is really in #5535's ruling, and the new test is strong where it matters — it pins the ruling's own numbers rather than reading them back out of the code under test, and it carries positive controls at the two places a comparison could pass vacuously. The nits are a stale comment lead, one over-long line, and a bundled second fix worth naming in the body; none of them should hold the merge. Nice work, and thank you for pinning autoRfGainReleaseNeedsDb's presence — that is the check that makes section 2 mean something.


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

Comment thread src/core/backends/hl2/Hl2Backend.h Outdated
Comment thread src/core/backends/hl2/Hl2Backend.cpp Outdated
Comment thread src/core/backends/hl2/Hl2Backend.cpp
on8st and others added 2 commits October 1, 2026 13:49
Review of aethersdr#6062. The comment over setAutoRfGainFloorDb() still opened
"DEFAULT 26 dB" and was corrected eleven lines further down, so a reader
looking for the default found the ramp's number first. It now opens with
the floor a backend starts at, 24 dB, says which law carries which floor
(24 for bandscope and probe, 26 for ramp and binary, as
Hl2AutoGainPolicy.h has them), and keeps the measured argument for 26
where it belongs, under the ramp. The trailing correction is folded into
the lead.

Also reflows one 97-column comment line at the bandscope.enable verb.

Comments only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of aethersdr#6062. That PR carries a second fix its title does not name:
the linkUp handler asks for the bandscope gate again when the loop is
already armed. After 2 s of EP6 silence MetisClient ends the gate's
intent and emits linkDown without stopping; when EP6 resumes it emits
linkUp on the same session, with no restore in between. The loop stays
armed and nothing asked for the gate again, so the bandscope law held
its offset on HeadroomAbsent until the operator toggled the switch. The
defect was latent while a connected backend ran the ramp; installing the
bandscope law on every connect is what makes it reachable.

Section 5 of hl2_auto_gain_law_test fired that edge once. Section 7 pins
it from each side: the gate is asked for again even when the operator's
own enable had taken the claim before the silence, and it is not asked
for under a law that ignores the bandscope or while the loop is off.
Without the re-ask in the linkUp handler, two checks fail.

Also lists the test in AETHER_SETTINGS_CONSUMERS beside its neighbours.
It linked before, through aethercore; this is for consistency.

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

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first, four items.

  1. The second fix was in the body and not where a reader would find it. It sat as one paragraph under "Does the bandscope feed run after a connect", and the title and the lead did not name it. The review is right that it could be merged without being noticed. It now has its own section and a sentence in the lead (body update follows), its own test section, and its own commit message.
  2. The header led with the wrong number. Hl2Backend.h opened "DEFAULT 26 dB" and corrected itself eleven lines later. I had added the correction below the sentence instead of rewriting it. Fixed below.
  3. Fixing the header adds a merge conflict with our own fix(hl2): let auto RF gain arm across the native range — the +19 ceiling came from one faulty unit. Principle II. #5976. Both now rewrite the floor comment in Hl2Backend.h: this PR its lead, fix(hl2): let auto RF gain arm across the native range — the +19 ceiling came from one faulty unit. Principle II. #5976 the paragraph about the sweep behind the 26, which ran on a unit with a faulty gain bit. A trial merge of 2c8409f with fix(hl2): let auto RF gain arm across the native range — the +19 ceiling came from one faulty unit. Principle II. #5976's head ad95680c conflicts there and, as before, in tests/hl2_gain_split_test.cpp. The body said one conflict; it is two. The resolution keeps this PR's opening paragraphs and takes fix(hl2): let auto RF gain arm across the native range — the +19 ceiling came from one faulty unit. Principle II. #5976's text for the ramp's 26, and I will do it on whichever lands second. The merged tree was green at 9dfb326 (558 of 558) and was not re-run on 2c8409f; the added conflict is in a comment.
  4. Still not verified on hardware. Nothing in this round changes that. The bandscope law has not run on a live antenna here, and the gate re-ask is tested on the request, not on a radio answering.

Hl2Backend.h:204 and :219, the floor comment: 551df83, docs(hl2): lead the floor comment with the 24 dB floor. I rewrote the lead and did not take the suggestion for the lower paragraph alone, because line 204 was the fault. The comment now opens "A BACKEND STARTS AT 24 dB" and says why: the floor is part of a law's configuration and the installed law is the bandscope one. A second paragraph says which law carries which floor. The argument for 26 is kept as main has it, under "THE RAMP'S 26 dB"; #5976 corrects that argument and this PR does not pre-empt it. The trailing correction is folded into the lead. The numbers were checked against Hl2AutoGainPolicy.h:

law floor where
ramp 26 AutoGainConfig::maxOffsetDb, :312
probe 24 probingReleaseConfig(), :516
bandscope 24 bandscopeReleaseConfig() starts from probingReleaseConfig(), :588, and does not touch the floor
binary 26 binaryHighLowConfig()'s default lowOffsetDb, :368

The quoted words are from the ruling on #5535 of 2026-09-18, point 2.

Hl2Backend.cpp:6407, the 97-column line: same commit. Suggestion taken.

Hl2Backend.cpp:599, the gate re-ask on linkUp: 2c8409f, test(hl2): pin the gate re-ask on a resumed link. I checked the defect against the source before adding to its test:

  • the silence watchdog's second stage clears the gate's intent and then emits linkDown without stopping (MetisClient.cpp:678-679);
  • the backend's linkDown handler drops its claim on the gate and does not disarm the loop;
  • the linkUp handler armed only when the loop was not running, so on a resumed link nothing asked for the gate again.

So the defect is real, and it is separate from which law a connect installs. It is in this PR because this PR is what makes it reachable by default. On main it needs the bandscope law selected over the bridge. With only the title's change, every armed loop would lose its release after one link bounce.

The test for it existed (section 5, one check). Section 7 now pins it from each side:

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

Mutation check. A throwaway commit removed the re-ask from the linkUp handler. Full suite: 557 of 558, hl2_auto_gain_law_test red on exactly two of its 48 checks, "the resumed link asks for the gate again" and "and the resumed link asks for the gate on the loop's behalf". The two no-gate checks stay green under the mutation, as they should: they guard the other direction.

The question that hunk invites: a bandscope the operator enabled by hand, across a bounce. Your reading is right. The watchdog ends every gate, whoever asked for it, so the operator's stream is gone before the re-ask runs, with or without this PR. What changes is what follows when the loop is armed under the bandscope law: 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, then stops it. An operator who wants the diagnostic to outlive the loop has to enable it again after the bounce, as they already had to. This goes into the body.

Splitting it. If the maintainers want the re-ask as its own PR, I will split it. It would have to merge before this one or with it, for the reason above.

The other nits.

  • AETHER_SETTINGS_CONSUMERS: added in 2c8409f, beside hl2_gain_restore_test and hl2_gain_split_test. Agreed that it linked already through aethercore.
  • Commit order, df0120fb red on its own: deliberate. It is the evidence that the test fails on main's code. Main's recent history is one single-parent commit per PR, so I expect this to be squashed, and then the red commit does not reach main and a bisect there does not cross it. If you merge without squashing, say so and I will reorder.

Issue fit. There is no issue for this, so there is no Fixes line. The PR says Refs #5535, #5652: the ruling it restores and the PR of ours that introduced the defect. #5976 carries the discussion: our comment there of 2026-10-01 09:46Z names the defect and its cause, after an earlier one the same morning had reported the symptom without saying so. I have not filed a separate issue. Say if you want one for the record.

The on-air consequence. Agreed, and the body leads with it: for an operator who has the loop switched on, this changes the law, the step, the floor and starts the EP4 gate. It is the row a release note wants. The loop still ships off.

Capability map and docs. No change needed, and I checked it the same way: the hasAutoRfGain row already describes the bandscope law and the gate's bandwidth, and nothing under docs/, src/ or tests/ still states a 26 dB default or ramp as the connect law.

What you could not check. The body does claim a local run, so here is this round's: full suite on 2c8409f, 558 of 558 with nothing waived, macOS arm64, Qt 6.12.0, Ninja, ASR off. The static checks in static-checks.yml pass locally; the sanitizer-option job was not run locally, it needs the CI image. Linux and Windows were not run here. No hardware.

73, ON8ST

@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

The body says this PR "has not been verified on hardware". It has now run on a Hermes-Lite 2, receive only, and the body is updated. What the run does not show comes first.

What it shows:

result
law after the first connect, fresh profile, no command sent bandscope, floor 24, probe interval 30 s
law after a second connect, restored state on disk same
law after a third connect, explicit off on disk same
arm on connect from a stored autoEnabled: true armed 1.0 s after connected, no command sent
EP4 feed while armed, 40 / 30 / 80 m 540 blocks in 540 s on each band, 0 drops, 0 timeouts
EP4 feed after disarming off within 0.06 to 0.78 s, 0 blocks accepted after 2 s
steps seen 0, 6, 12, 18, 24 dB: whole 6 dB steps
+48 dB armed: first attack after first clip evidence 0.46 / 0.26 / 0.26 s

So on a radio the connect edge installs bandscope and not ramp, in all three connect cases, and the gate the law needs is running when the loop is armed.

One finding that belongs to #5535 and not to this PR: from +48 dB the loop used all 24 dB of travel on all three bands. On 30 m it still clipped 2.46 % of observations in the last 180 s at the floor, and showed "AT FLOOR and still clipping" in 31 % of polls. On 40 m and 80 m it was clean there.

73, ON8ST

@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Two more receive-only runs on the same build, later the same evening (21:31 and 22:20 local). They add one thing to the comment above and leave one thing as it was.

  • Added: the law also survives real, unplanned reconnects. In the 21:31 run the radio's stream went silent five times on the 40 m leg ("no EP6 for 2 s") and the app reconnected six times. In every poll after each reconnect the law read bandscope, floor 24, and the log shows the bandscope feed re-enabled each time. With the nine planned connects of the three runs that is fifteen connects on a radio, none of which came up on ramp.
  • Still not exercised: the re-arm on a linkUp without a connect. All five silences ended in a full disconnect and reconnect, so that path is still shown by the test only.

Why the stream went silent is not established. The other two runs had no silence.

The busy-band figures from the 22:20 run are about the loop's limit and its behaviour at the default, not about this PR. I will bring them to #5535.

73, ON8ST

on8st and others added 2 commits October 2, 2026 09:02
Main's comment sweep (aethersdr#6072) condensed the blocks this branch had
edited. Two files conflicted, Hl2Backend.h (four hunks) and
Hl2Backend.cpp (one), all in comments and one friend declaration.

Resolution: main's condensed text, with what this PR makes true added
to it.
- The floor comment says the floor belongs to the law: 24 dB for
  "bandscope" (what a backend starts with) and "probe", 26 for "ramp"
  and "binary". Main's sentence about the 26 is kept for the ramp.
- The law table gains "default" and the note that neither law nor
  floor is persisted.
- m_autoGainMode keeps no initialiser; installDefaultAutoGainLaw() is
  the one copy of the default.
- Both friend declarations are kept: main's Hl2Cl1ReferenceTestAccess
  and this branch's Hl2AutoGainLawTestAccess.
- The bandscope.enable comment says the loop starts the gate itself.

This branch's own added comments are cut to the comment rule (history
of the defect removed; it is in the PR body and the commit messages):
the linkUp re-ask, applyRestoredState, defaultAutoGainLaw, the mode
branch, the test header and section 7, the gain-split floor check and
the tests.cmake block. No behaviour changes in the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 ten9876 self-assigned this Oct 2, 2026
ten9876 and others added 4 commits October 2, 2026 12:08
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>
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 c1042a01a. The change is accepted, and every finding is addressed and proved.

This pass

  • a423a0a09: signed merge of main after #5976 landed. It resolves the two conflicts on8st documented (the floor comment in Hl2Backend.h and the floor check in hl2_gain_split_test), as he set out on #5976.
    • The header keeps this PR's per-law floors (24 for bandscope/probe, 26 for ramp/binary) and #5976's note on the ramp's 26.
    • The test keeps 24.
    • The kAutoRfGainFloorMaxDb comment no longer names maxOffsetDb as the default floor.
  • PR body: the #5976 coordination notes are trimmed to current state.
  • Review nits: on8st fixed all three (551df83f, 2c8409f8). Each thread has a reply with evidence and is resolved.

Verified empirically at b64077ac9 (a signed merge of main for #5982 and #5979, which merged cleanly). Later clean merges of main (#6088 in 0e7288a60, then #6011 and the split QSY fix in d3a14a9d4, then #6070 and #6071 in c1042a01a). At c1042a01a the HL2 gain tests, hl2_ep4_* (the overlap with #6011) and radio_owned_display_settings_test pass 8/8, and the static gates are clean

  • Full ctest: 551/553 pass. The two failures are connection_panel_size_test, a known local failure (#5811), and wdsp_channel_test, which is new from #5979 and timing-sensitive. It failed once under -j8 load and passed 3 of 3 runs in isolation and under ctest; this PR does not touch WdspChannel. Mutations were re-run at this head with the same results. Socket-owning tests were excluded; hl2_auto_gain_law_test is socket-free and ran.
  • Mutations:
mutation result
applyRestoredState() back to AutoGainConfig{} / "ramp" 20 [FAIL] in hl2_auto_gain_law_test, 1 in hl2_gain_split_test
linkUp re-ask removed 2 [FAIL]
"default" mapped to ramp 1 [FAIL]
all restored 0
  • Static gates: engine boundary, capability records and command plane are identical between origin/main and head, and the manifest is current.
  • Automated code-review (medium): no findings.

Not verified here: the gate re-ask on a resumed link has not been seen on a radio. The test shows the backend requests it, and on8st's receive-only runs covered 15 connects, none of which came up on ramp. Nothing was keyed, and no radio was touched in this pass.

Green freshness, stated plainly: the required checks on c1042a01a are all green. They ran in runs created at 2026-10-02T20:37:44Z. Main's tip is now 712bd8499 (21:18:42Z), so one commit landed after that run was created: 712bd8499 fix(spectrum): release replaced waterfall textures after the frame (#6093).

  • git merge-tree of the head with current main is clean.
  • That commit touches none of this PR's auto-gain files (Hl2Backend.{cpp,h}, Hl2AutoGainPolicy.h, Hl2GainSplit.h, Hl2DbReference.h, the gain tests).

On the maintainer's decision it is accepted as drift rather than re-run, so this green does not cover #6093.

@ten9876
ten9876 merged commit ef4cd12 into aethersdr:main Oct 2, 2026
5 checks passed
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.

2 participants