Repository navigation
feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections - #6008
Conversation
There was a problem hiding this comment.
Issue fit
Partially. #2313 asks for an Auth Code field so TGXL/PGXL/AG can be reached directly over a WAN without a VPN. The TGXL and PGXL halves land that: the greeting's AUTH suffix is detected, the code is sent in the device-specific form the probe observed, and — importantly — the direct path is no longer marked isConnected() before authorization, which also fixes the pre-existing "Tune goes out over an unauthenticated direct socket" routing recorded in the evidence doc. The Antenna Genius half is implemented but, per §"Blockers" below, its reply check accepts the 4O3A family's documented rejection reply as success, so the AG requirement is not safely met yet.
The issue proposed per-applet settings with a Local/Remote toggle; the PR extends the central Peripherals tab instead. That divergence is disclosed and correctly routed to the maintainer.
Scope
| File / group | What it changes | Explained by #2313 / PR body | Verdict |
|---|---|---|---|
src/core/{Tgxl,Pgxl}Connection.{h,cpp} |
AUTH state machine, attempt token, failure budget, 64 KiB line bound, authcode= log redaction |
yes | in scope |
src/models/AntennaGeniusModel.{h,cpp} |
AG AUTH, per-target block/budget, hostname connect, prologue extracted to completePrologue() |
yes | in scope |
src/core/PeripheralAuthCode*.h |
code validation + line-oriented command builders | yes | in scope |
src/gui/PeripheralAuthStore.{h,cpp}, CMakeLists.txt |
QtKeychain adapter, endpoint-bound record, session fallback | yes | in scope |
src/gui/RadioSetupDialog.cpp |
code field / Show / Clear columns; status-cell states; grid column re-index 4→7 | yes | in scope — the column shift is mechanically forced by the three new columns, not a restyle |
src/gui/{Tuner,Amp,AntennaGenius,ShackSwitch}Applet.* |
new ● DIRECT / ● RADIO / ● OFFLINE indicator; PGXL indicator relocated to match TGXL |
yes, and flagged by the author | needs maintainer decision — new user-visible UI + a relocation of a working PGXL indicator. The PR already asks for this ruling; I agree it is the maintainer's call, not a violation |
src/AutomationSensitiveLineEdit.h, src/core/AutomationServer.cpp |
redact sensitive line edits in dumpTree and in grab |
yes | in scope |
src/core/SettingsCredentialPolicy.h |
six reserved credential names | yes | in scope, and the right table per AGENTS.md |
src/core/LogManager.cpp |
aether.peripheral.auth category |
yes | in scope |
docs/4o3a-remote-auth-hardware-evidence.md, touchpoint manifest + tags |
evidence, manifest regen (230→231) | yes | in scope; counts are self-consistent |
tools/probe_4o3a_auth.py |
new one-shot hardware probe | referenced by the evidence doc, not called out as a new permanent tool in the body | in scope, but worth naming — it's new tooling that outlives the PR |
.gitignore |
third_party/qtkeychain/ |
yes; scripts/setup/setup-qtkeychain.sh it names already exists on main |
in scope |
CHANGELOG.md |
untouched | — | correct per AGENTS.md |
Nothing in the diff is unrelated to the issue. The one genuine judgment call is the applet indicator work, which the author has already routed for a ruling.
Socket tests (disclosure, not a finding)
peripheral_auth_dialog_test,peripheral_auth_handshake_test,peripheral_auth_keychain_test— all three are socket-free. I read the sources, nottests.cmake: the dialog test only clicks Connect on the invalid-code path (which returns beforeconnectFn) and otherwise drivesbeginAttemptAt/processLine/processTcpBytesthrough theQ_INVOKABLEseams; the keychain test uses an in-memoryQKeychaindouble viatests/fakes/qt6keychain/. Nobind(),listen()orconnectToHost()in any of them. All three are registered and run by default.pgxl_panel_test— modified, and it is a pre-existing registered loopbackQTcpServertest. The PR did not add the socket; it added atests.cmakecomment disclosing the bind, which is the right direction. It skips with 77 on bind failure.- No fake radio/amp/tuner peer was added. The handshake test injects captured frames, which is the correct layer for this per AGENTS.md ("refusals, malformed or disconnected input… socket-free transport or state-machine injection").
Blockers
1. Antenna Genius treats the vendor family's documented rejection reply as success — inline on src/models/AntennaGeniusModel.cpp:564.
The accept condition is fields[0] == "R1" && result(hex) == 0. The body is never examined. Your own evidence document says, about the sibling device:
The vendor TGXL API document lists
R1|0|Unauthorizedfor a rejected code. Because its numeric result is also zero, the client must check the reply body.
TGXL and PGXL both do exactly that — line == "R0|0|auth OK" || "R1|0|auth OK" and line == "R1|0|Authorized". AG is the one path that does not, despite being the only one of the three with no live handshake observation at all.
Failure scenario (reasoned from code — I have no AG hardware and cannot reproduce it): operator types a wrong or stale AG code → device answers R1|0|Unauthorized → result == 0 → authCodeAccepted fires, completePrologue() runs, m_connected = true, info get is sent on an unauthorized socket, the applet shows ● DIRECT, and the rejected code is written to the keychain, replacing whatever was there. The R1|FF|Denied case the test covers is the one shape this bug does not affect.
I cannot prove AG's firmware uses that exact string — but the safe direction is unambiguous and matches what the other two connections already do. Suggested shape: require a body that is empty or an explicit success token, and treat any other body as a rejection — the mirror of the TGXL check. Whatever form you pick, the AG test at tests/peripheral_auth_handshake_test.cpp:449 currently pins R1|0|OK as accepted, so it will need to move with the fix.
Nits (non-blocking)
- AG prologue gate may never fire on a WAN greeting.
AntennaGeniusModel.cpp:516still requiresline.contains("AG"), and the new AUTH branch sits inside it. The only AUTH greeting this PR actually observed is TGXL'sV1.2.17 AUTH— which dropped the device token. If AG's WAN greeting does the same (V4.0.22 AUTH),contains("AG")is false, the prologue is never recognised, and the entire AG auth path is dead rather than wrong. The tests all feedV4.0.22 AG AUTH, so they cannot distinguish. Worth confirming against a live AG before this is called done — the PR already lists AG hardware as unverified, so this is a specific thing to look for during that check. (Inline at line 524.) peripheralAuthFailureBlocks(int&)(src/core/PeripheralAuthCode.h:10) increments through a reference inside what reads as a predicate, and the member it increments is namedm_authTimeoutswhileonDisconnectedalso feeds it mid-auth socket closes. It works; the name and the mutating-predicate shape make the three-strike budget harder to audit than it needs to be.- A failed keychain delete leaves the session cache disagreeing with the vault.
PeripheralAuthStore::save()clearsg_entries[...]before queuing theDeletePasswordJob, so after a failed Clear the session reportsMissingwhile the OS vault still holds the code and will restore it next launch. The UI does say "retry Clear code", so this is visible rather than silent — but the fail-safe direction would be to roll the cache back on failure. authTarget()returns an empty string for an empty host or port 0, andfailAuthenticationwill thenm_authBlockedTargets.insert(""), which makesisAuthBlockedFor()true for every other malformed target. Unreachable through the current call sites; cheap to guard.m_authTimeoutsByTarget/m_authBlockedTargetsgrow without bound across discovery churn. Trivial in practice.- Stale paragraph in the evidence doc. The last paragraph of "Follow-up review checks (2026-09-28)" still says the dialog regression "makes outbound connection attempts to reserved documentation addresses". It no longer does — it uses the injected connect-ordering seam, exactly as the PR body says. Worth correcting so the doc and
tests.cmakeagree.
Other tools' findings
All six CodeGuard CG-PATH-001 hits are false positives and I am not repeating them as findings. src/core/TgxlConnection.h:19 is a protocol-format comment line (M|<text>\n — alert text), not a path. src/gui/MainWindow.cpp:9549, :9661, :9780-9782 are the pre-existing NR2-wisdom status label and the macOS .app bundle-root walk — none of which this diff touches (the MainWindow hunks are at ~6736–6790). No hardcoded-credential findings were reported, and the diff introduces none that I could find.
What I tried to break
- Accept-a-rejection. Walked each of the three reply parsers against the vendor-documented rejection forms. TGXL's exact-match on
R0|0|auth OK/R1|0|auth OKand PGXL's onR1|0|Authorizedboth surviveR1|0|Unauthorized; AG does not. That is the blocker above. abort()re-entrancy.failAuthenticationclearsm_authPendingbeforem_socket.abort(), so the synchronousonDisconnectedcannot double-count the attempt as a "closed during authentication" failure, and the!m_reconnectTimer.isActive()guard prevents the double re-arm.connectToTgxl/connectToPgxlcorrectly wrap the abort inm_deliberateDisconnectand emitdisconnected()themselves. This one held on all three paths.- Late keychain replies to a dead socket. Tried to make a reply from an abandoned attempt authenticate a newer one:
++m_authAttemptinbeginAttempt()plus theattempt == m_authAttempt && m_waitingForAuthCodegate insetAuthCodeForAttemptcloses it, andPeripheralAuthStore::loadholds aQPointercontext so a torn-down window drops the callback. Held. - Carrying a typed code to the wrong host.
beginAttemptAtcompares the normalised target and discards + signals;sendAuthenticationre-checksm_userAuthEndpoint == m_attemptEndpointfor AG; an unchallenged greeting discards an unverified code instead of keeping it.PeripheralAuthStore::endpoint()binds the stored record to the resolved peer IP, not the configured hostname, so a hostname that re-resolves elsewhere will not receive the old code. Held. - Removed guards. Read the
-lines: the only behavioural removals arem_tgxlConn.disconnect()/m_pgxlConn.disconnect()on tuner/amp absence, now skipped when a manual IP is configured. That is required for the WAN case #2313 describes, not a lost guard. NosendCommand()was added, and the unconditionalemit connectionFailedremoved fromTgxlConnection::onErrorhas no consumer onmainoutside this PR's new wiring — I grepped. - Credential leakage.
widgetValuenow routes throughautomationLineEditValue, the grab mask flips revealed fields toPasswordfor the duration of the capture and restores viaQPointer, andsendCommandredacts anyauthcode=setup write. The mask's double-negative (!sensitive && echoMode == Normal → skip) is correct. No plaintext path intoAppSettings— the six reserved names are inSettingsCredentialPolicy.h, which is the authoritative table. - Could not check: anything at runtime. I have no build, no AG, and no keychain. Every finding above is reasoned from the source in the head checkout plus the PR's own evidence document; none was observed executing. The AG greeting shape and the AG rejection string are the two facts that would settle finding 1 and the first nit, and only live AG hardware can supply them.
CI is green on all five checks at 120ed439 (build, check-macos, check-windows, Static checks, Sanitizer configure). Note that per-PR ctest is -R-filtered, so green does not mean the three new targets ran.
Recommendation
Request changes, on finding 1 alone. The TGXL and PGXL state machines are careful work — the attempt token, the endpoint binding, the "an unchallenged greeting cannot verify a code" rule, and the redaction of the authcode= setup write are all things that are easy to get wrong and are right here. But the AG reply check accepts the one reply shape your own evidence document warns about, and the consequence is not a cosmetic wrong state: it writes a rejected credential to the operator's keychain and shows ● DIRECT on an unauthorized socket. Tighten that check and move the R1|0|OK assertion with it, then this is ready for the maintainer's separate ruling on the indicator UI and the central-tab-vs-per-applet placement, both of which you have correctly flagged rather than assumed.
Thank you for the evidence document in particular — separating "observed on firmware 1.2.17" from "vendor documentation" from "not yet verified" is what made the AG gap findable at all.
🤖 aethersdr-agent · cost: $10.1235 · model: claude-opus-5
|
Cisco CodeGuard — static analysis of this PR (6 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $10.3171 · model: claude-opus-5 |
|
@aetherbot Please re-review PR #6008 at commit 80f40fe. The review follow-up fixes AG authorization reply validation, failed Keychain deletion cache handling, and bounded per-target retry tracking, and clarifies the remaining AG hardware and maintainer design checks. Focused handshake, Keychain, and committed-dialog tests passed; four regression guards were mutation-tested. Full dispositions and validation are in the updated PR description. Self-assignment was attempted through both GitHub APIs but denied because w5jwp lacks upstream assignment permission. |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
Yes for TGXL and PGXL, likely for AG, with one untested safety behavior. #2313 asks for an authorization code so TGXL, PGXL, and AG can be reached directly over WAN without a VPN. The existing manual IP/port rows already handled the "direct connect" part (per the triage on the issue). This PR adds the missing handshake. On an AUTH greeting, TGXL sends C1|auth <code> and accepts only R0|0|auth OK/R1|0|auth OK. PGXL sends C1|auth code=<code> and accepts only R1|0|Authorized. AG sends C1|auth code=<code> and accepts only R1|0 with an empty or OK body. The code goes to the Keychain only after the device accepts it. The previous review's AG blocker (a zero-result Unauthorized treated as success) is fixed at this head, and a mutation confirms the new negative cases catch it (see below). One requirement from the maintainer triage has no coverage: "Failure → surface error, do not retry" (blocker 1).
The PR puts the fields in the central Peripherals tab rather than per-applet with a Local/Remote toggle. That choice is disclosed and routed to the maintainer, so I am not treating it as a finding.
Scope
| File / group | What it changes | Claimed in body? | Verdict |
|---|---|---|---|
src/core/{Tgxl,Pgxl}Connection.*, src/models/AntennaGeniusModel.* |
AUTH state machines, attempt token, failure budget, 64 KiB line cap, per-target AG budget | yes | in scope |
src/core/PeripheralAuthCode*.h |
code validation + command builders | yes | in scope |
src/gui/PeripheralAuthStore.*, PeripheralAuthConnectFlow.h, CMakeLists.txt |
Keychain adapter (endpoint-bound record), session-only fallback, connect-ordering seam | yes | in scope |
src/gui/RadioSetupDialog.cpp |
code / Show / Clear columns; status column 4→7 for every row | yes | in scope; see nit 3 on the resulting width |
src/gui/{Tuner,Amp,AntennaGenius,ShackSwitch}Applet.* |
new DIRECT/RADIO/OFFLINE indicators; PGXL indicator relocated and recoloured | yes, flagged by author | needs maintainer decision (new UI + change to a working PGXL indicator) |
src/gui/MainWindow{,_Wiring}.cpp |
auth wiring; auto-connect now prefers *_ManualIp over the radio-reported IP; presence loss no longer disconnects when a manual IP is saved |
partly — the IP-precedence and no-disconnect changes are not described | in scope, but undisclosed (nit 4) |
src/AutomationSensitiveLineEdit.h, src/core/AutomationServer.cpp |
redact sensitive line edits in dumpTree and grab |
yes | in scope |
src/core/SettingsCredentialPolicy.h, LogManager.cpp |
reserved credential names, aether.peripheral.auth category |
yes | in scope |
docs/architecture/aetherd-touchpoint* |
+1 row (PeripheralAuthCodeValidation.h), AppSettings 103→104, ThemeManager 156→157 |
yes, each justified in the body | in scope (AGENTS.md conformance item 2 satisfied) |
docs/4o3a-remote-auth-hardware-evidence.md, tools/probe_4o3a_auth.py |
evidence + permanent TGXL/PGXL probe | yes | in scope |
.gitignore |
third_party/qtkeychain/ |
yes | in scope |
| tests (5 new, 5 modified) | see below | yes | in scope |
Everything in the diff is explained by the issue. CHANGELOG.md is untouched, which is correct.
Socket tests: peripheral_auth_{dialog,handshake,keychain}_test and both automation_sensitive_* tests are socket-free. I read the sources: they inject frames through the Q_INVOKABLE seams and use an in-memory QKeychain double, with no bind/listen/connectToHost. pgxl_panel_test is a pre-existing loopback QTcpServer test. This PR only changed its layout assertions and added a tests.cmake comment naming the bind, and it skips with 77. I did not run it. No fake device peer was added.
Blockers
1. The "do not retry after rejection" rule for TGXL/PGXL has no test. Removing every guard still passes the suite (inline on src/core/TgxlConnection.cpp:26).
The maintainer triage on #2313 specifies R1|0|Unauthorized → surface error, do not retry, and the body claims "suppress automatic retries after a blocking failure". I mutated both connections and removed !m_authBlocked from all three places that arm or fire the reconnect timer:
- the timer lambda (
TgxlConnection.cpp:26) onDisconnected(:186)failAuthentication(:417), with the same three edits inPgxlConnection.cpp.
After rebuilding, peripheral_auth_handshake_test still exits 0. With the mutation in place, a rejected or saved-but-stale code would be re-sent every 5 s for as long as the radio is connected. That is the loop the triage ruled out, and it can hammer a device that may rate-limit or lock out.
A deterministic seam already exists, because the AG side does this correctly. agReconnectTimer has an object name, and the handshake test asserts on it (tests/peripheral_auth_handshake_test.cpp:619). The suggested fix:
- give the TGXL/PGXL
m_reconnectTimeran object name - call
setAutoReconnect(true) - inject
V1.2.17 AUTH+R1|0|Unauthorized, then assertisAuthBlocked()and!reconnectTimer->isActive() - repeat for the third consecutive
onAuthTimeout.
It is socket-free, and it is the only assertion that pins the requirement.
Nits (non-blocking)
- AG auth uses a bare
\rterminator, but the only live AG capture on #2313 shows\n(inline onsrc/core/PeripheralAuthCode.h:29). The 4O3A wiki does say<terminator> = 0x0D. But the pcap in this issue comment from the vendor's own AG Utility showsC<seq>|auth code=<code>\n. It also reports that AetherSDR's existing\r\n(sendCommand,AntennaGeniusModel.cpp:727) reached the AG auth parser live and got a realR<seq>|FF|back. Of the three terminators, bare CR is the only one never seen working against real hardware. If the firmware splits on LF, every AG auth will time out three times and block.\r\nsatisfies both the wiki and the capture. Reasoned from the thread, not reproduced; I have no AG. Please weigh this strongly before calling AG done, and update the test pins atperipheral_auth_handshake_test.cpp:54,435,611with it. - The same capture settles the previous review's
contains("AG")concern. It records the WAN greeting asV<version> AG AUTH\n. The evidence doc lists that greeting as an open item, but it can cite this capture as observed. The capture also notes that the vendor Utility silently truncates the AG password to 8 characters. That is worth a line in the doc, because an operator typing the full code into AetherSDR will be rejected. - The Status column is off-screen at the default dialog size. From driving the demo build: Setup → Peripherals at the default 960×680 has a group box 890 px wide inside a 649 px scroll viewport, so a horizontal scrollbar appears. At the default scroll position, "Clear code" is cut off and the Status column is not visible at all. That column is where every new error is shown ("Authorization code rejected", "saved code remains in keychain"). The layout is part of the maintainer UI ruling, but it should be settled with that in mind.
- Undisclosed auto-connect change (inline on
src/gui/MainWindow_Wiring.cpp:6452). Tuner/amp presence now connects toTGXL_ManualIp/PGXL_ManualIpbefore the radio-reported IP, and presence loss no longer drops the direct socket when a manual IP is saved. That is defensible for WAN use, but it changes LAN behaviour for anyone with a saved manual IP. Please state it in the body. - Saved codes are bound to the resolved peer IP, not the configured host (inline on
src/gui/PeripheralAuthStore.cpp:153). WAN users behind residential DHCP typically use a DDNS hostname. Every WAN IP change turns the saved code into "Authorization code required" and blocks the connection until the operator retypes it. Binding to the IP is a defensible security choice, but it is the main remote use case of #2313. Please ask the maintainer whether to bind to the configured host+port instead, or in addition. AntennaGeniusModel's production constructor gains astd::functionwhose only purpose is to support tests. The TGXL/PGXL tests reach the same seam throughQ_INVOKABLEwithout changing a public constructor. Minor.
What I verified vs. read
- Built the PR head merged with current
main(20d022d50, clean auto-merge) on Linux. CI was created after main's tip, so the green is current. - Ran
peripheral_auth_{dialog,keychain,handshake}_test,automation_sensitive_{line_edit,grab_command}_test,tgxl_docked_parity_test,tgxl_applet_ports_test, andamp_applet_test: 8/8 pass. - Mutations that were caught (good):
- TGXL accepting any
|0|reply → handshake test fails - AG body allowlist forced true → fails
- Keychain delete clearing the cache before confirmation → keychain test fails
- TGXL accepting any
- Mutation that survived: blocker 1.
- Engine-boundary gates: I ran all four (
check_engine_boundary --strict,gen_touchpoint_manifest --check,check_capability_records --strict,check_command_plane --strict) onmainand on the merge. They show identical finding sets apart from line-number shifts inAutomationServer.cpp(102 findings each). The manifest diff is exactly the three rows the body justifies. - Bridge session: isolated settings, offscreen, attached to
DEMO-0001and confirmedget radio→AetherSDR Demo. I opened Setup → Peripherals, typed a TGXL code, and clicked Show.dump_treestill returned"value": "<hidden>", andgrab_widgetrendered dots while the button read "Hide", so the redaction claim holds. That session is also where nit 3's geometry comes from. The instance was closed afterwards. - Attacks that held up:
- a code typed for host A carried to host B (discarded; covered)
- a late Keychain reply for an abandoned attempt (token-gated)
- a LAN greeting with a pending typed code (discarded, not saved)
|/whitespace/control characters in a code (rejected before interpolation)- an oversized line (capped)
- credential text in logs: the auth write bypasses
sendCommand's debug log, PGXLsetupwrites are redacted, and no path logs raw received lines.
- Not verifiable here: any live TGXL/PGXL/AG handshake (no hardware; the demo has no 4O3A devices), macOS/Windows Keychain behaviour, and the applet indicator placement (the applets are hidden without devices).
|
@aetherbot @ten9876 Please re-review PR #6008 at 601b4b4. The September 29 blocker and actionable nits are addressed:
Maintainer ruling requested: should saved credentials bind to configured hostname+port, resolved peer IP+port, or a combination? Current peer-IP binding remains in place, with the DDNS re-entry limitation disclosed. Please also rule on the compact Setup layout, central-tab placement, and applet indicators already called out in the body. Validation: the desktop app builds; all eight focused auth, automation-redaction, TGXL and amp applet tests pass. Reconnect-guard, bare-CR, and old-layout mutations each fail at the intended assertion; all eight tests pass again after restoring the fixes. Static baseline, manifest, registration, colour-ratchet, and whitespace checks found no new blocker. No new hardware, OS-vault, or TX verification was performed; the existing loopback PGXL panel test was not rerun. The commit is signed. GitHub again denied self-assignment because |
Firmware 3.9.8 with authorization enabled returns the code in `setup read` and refuses any `setup` that carries `authcode=<code>` with 50000013, so every fan-mode change and MEffA toggle was refused. Probed on the hardware: the same group without `authcode` is accepted and the stored code is unchanged, as is `setup fanmode=` alone. With no code set the key is still sent empty, as the vendor utility does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed 00d6f79: fan-mode changes and MEffA toggles failed on an authorization-enabled PGXL. This PR's own feature exposes the bug, because only an authenticated connection can reach it. Cause. Probe (PGXL firmware 3.9.8, authorization enabled, authenticated session). Each write used the amplifier's current values, so an accepted write changed nothing:
Afterwards Fix. The write leaves out Tests. Verified in the app. After the fix, no refusals were logged, and the PGXL reported the fan mode selected in AetherSDR ( Observed, not yet understood. On firmware 3.9.8 the fans respond to a profile change intermittently, in AetherSDR and in the vendor's PGXL Utility alike, even when the amplifier reports the new mode. The vendor utility also disables fan selection while the amplifier is in STANDBY. Neither is changed here. Both are being monitored. |
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rinciple VII. A saved authorization code was bound to the connected peer IP and port, so an operator reaching a device through a DDNS name had to retype it every time their residential IP changed. That is the main remote use case of aethersdr#2313. Bind to the lowercased hostname and port when the operator entered a name, and keep the connected peer IP and port when they entered a literal address. Nothing binds before a socket has a real peer address. A different name, port or a literal IP is a different identity, so a code saved for one is not offered to another. The cost is that whatever the name resolves to receives the saved code, which the header and the evidence doc now say. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…inciple VII. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> # Conflicts: # tests/fakes/PeripheralAuthStoreFake.cpp
…) into the docked layout. Principle XI. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…Principle XI. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts: # tests/tests.cmake
…rge. Principle XI. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
Yes for TGXL and PGXL. AG is still unverified on hardware, as the body says. The last review's blocker (no test for "do not retry after rejection") is fixed. checkReconnectSuppression asserts that tgxlReconnectTimer/pgxlReconnectTimer stay inactive after a rejection and after the third timeout (tests/peripheral_auth_handshake_test.cpp:112-114). The AG CRLF change and the hostname-bound credential (907a88e8) both hold up against the attacks listed below.
What blocks the merge is mechanical. The branch conflicts with current main, and one of the three conflicts is a trap that would silently revert this PR's own 00d6f79e fix (blocker 1).
This review covers 71d5bcd7, reviewed as stage 1 of the #6008 → (#6027 ∥ #6033) → #6036 stack. #6027 and #6033 both branch from this head; they are siblings, not a chain.
Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/{Tgxl,Pgxl}Connection.*, src/models/AntennaGeniusModel.*, src/core/PeripheralAuthCode*.h |
AUTH state machines, attempt token, failure budget, 64 KiB line cap, code validation | yes | in scope |
src/gui/PeripheralAuthStore.*, PeripheralAuthConnectFlow.h, CMakeLists.txt |
QtKeychain adapter bound to host+port (hostname) or peer IP+port (literal); session-only fallback | yes | in scope |
src/models/AmpModel.cpp |
setup writes omit authcode once a code is set (00d6f79e) |
yes (PR comment + body) | in scope; feature exposed it |
src/gui/RadioSetupDialog.cpp |
code / Show / Clear rows, status cells, two-line layout | yes | in scope; UI awaits maintainer ruling |
src/gui/{Tuner,Amp,AntennaGenius,ShackSwitch}Applet.* |
DIRECT/RADIO/OFFLINE indicators; PGXL indicator moved | yes, flagged | needs maintainer decision (already routed) |
src/gui/MainWindow{,_Wiring}.cpp |
auth wiring; saved manual host wins over discovered IP; presence loss keeps a manual session | yes (disclosed after the last review) | in scope; see maintainer item M1 |
src/AutomationSensitiveLineEdit.h, src/core/AutomationServer.cpp, SettingsCredentialPolicy.h, LogManager.cpp |
redaction, reserved credential names, log category | yes | in scope |
docs/architecture/aetherd-touchpoint* |
+1 row PeripheralAuthCodeValidation.h (peripheral(4o3a)), AppSettings 103→104, ThemeManager 156→157 |
yes | in scope; additions only, no reclassification |
docs/4o3a-remote-auth-hardware-evidence.md, tools/probe_4o3a_auth.py |
evidence + one-shot probe | yes | in scope |
tests (5 new, 6 modified), tests/tests.cmake |
see socket note | yes | in scope |
.gitignore |
not in the net diff. Merge be82a425 correctly took main's superset block (#6055) |
body still claims it | nit N5 |
Nothing else in the diff is unexplained, and CHANGELOG.md is untouched.
Socket tests (disclosed to the operator before running): the five new targets are socket-free. I read the sources, and they inject through Q_INVOKABLE seams and an in-memory QKeychain double. Two existing tests are modified and both bind a loopback QTcpServer as a stub PGXL: pgxl_direct_protocol_test (gains the authcode-omission case) and pgxl_panel_test (layout assertions). Neither is on the #5254 legacy-exception list, and neither is in a ci.yml -R filter. The operator approved running both on loopback for this review, and both pass.
Blockers
1. The branch conflicts with main (1352368e), and the AmpModel.cpp conflict reverts 00d6f79e if resolved the easy way. git merge-tree --write-tree origin/main 71d5bcd7 reports content conflicts in three files, all from commits that landed on 2026-10-02:
src/models/AmpModel.cpp(~L225-250). Main's side (#6072, comment condensing) still carries the old writesetup nickname=%1 meffa=%2 ledintens=%3 fanmode=%4 authcode=%5withm_setupAuthCode. Take this PR's side. Taking main's side re-sends the code and brings back the50000013refusal of every fan-mode/MEffA change on auth-enabled 3.9.8. I checked that the test catches this: with the old write restored,pgxl_direct_protocol_testfails 5 checks.src/gui/MainWindow_Wiring.cpp(~L6212). Keep both sides: main's #6044TunerModel::relayedCommandRefused → showUnsupportedControlNotice()connect, and this PR's auth wiring block. Neither side of the hunk carries its own closing});; they share one after the marker. Check that every lambda still closes.983d743calready committed markers once.src/gui/TunerApplet.h(~L209). Keep this PR's three members (m_sourceLabel,m_directConnected,m_directFailureReason) and main's condensed comment.
After resolving, rerun python3 tools/gen_touchpoint_manifest.py. It passes at this head, but main changed the manifest twice since the base (#6045, HL2 CL1), and a hand-resolved Totals line is what failed #6036's CI on 798d12ad.
Maintainer decisions
-
M1. A saved manual host now always wins over the radio-reported IP (
src/gui/MainWindow_Wiring.cpp:6455-6476for TGXL,:6622-6634for PGXL). At the merge base, presence connected totunerModel().tgxlIp()and presence loss disconnected. Now a savedTGXL_ManualIpis used whenever present, and presence loss leaves that session up. The body discloses this. Two concrete consequences:- A LAN user who once saved
192.168.1.50loses direct control when DHCP moves the TGXL to.51, and nothing falls back to the address the radio reports. - The keys are client-global, so connecting to a different station still aims at the home TGXL/PGXL.
A fallback to the discovered IP when the manual connect fails would keep the WAN case without the LAN regression.
- A LAN user who once saved
-
M2. Unchanged from the last round: central-tab placement versus per-applet, the compact layout, the applet indicators, and hostname versus peer-IP binding.
Nits (non-blocking)
- N1. A keychain outage blocks auto-reconnect exactly like a device rejection.
setAuthCodeForAttemptcallsfailAuthentication("Stored authorization code unavailable")with the defaultblockReconnect = true(src/core/TgxlConnection.cpp:65,.h:112; same inPgxlConnection.cpp:65andAntennaGeniusModel.cpp:111).- Failure: if the keyring is locked or denied when the AUTH greeting arrives,
m_authBlockedis set. Every auto path checksisAuthBlocked(), so the station never recovers after the keyring unlocks; only Setup → Connect does. - That is the unattended WAN case #2313 is about. Suggest passing
falseon the unavailable path. Reasoned from code.
- N2.
src/models/AmpModel.cpp:250treats a missingauthcodekey like an empty one.kvs.value("authcode")is empty either way, so a firmware that leaves the key out ofsetup readwhile auth is enabled getsauthcode=on every write. Appending it only whenkvs.contains("authcode")would follow what the device actually said. Also, the 3.9.8R4|50000013|probe exists only in a PR comment.docs/4o3a-remote-auth-hardware-evidence.mdshould carry it (Principle VIII). - N3.
src/gui/MainWindow_Wiring.cpp:6638logs"PGXL status:" << kvsunredacted. On an authenticated sessionsetup readcontainsauthcode=<real code>. Today only the seq match inPgxlConnectionkeeps it out of this log; droppingauthcodebefore logging would make that robust. - N4. The new authcode-omission assertion was added to a synthetic-PGXL socket test (
tests/pgxl_direct_protocol_test.cpp:384-408).docs/agents/tests-ci.md§ Test-layer boundary puts wire encoding in a socket-free test. EmittingsetupReadand capturing thesetupline works without the loopback peer, the same wayperipheral_auth_handshake_testdoes. - N5. The body still says this PR adds
third_party/qtkeychain/to.gitignore. Main's #6055 block superseded it. - N6. Comment policy (#6072, landed after this branch's base). AGENTS.md § Comments: "History does not go in code."
AmpModel.cpp:240-246retells the 3.9.8 bug ("so every fan-mode change and MEffA toggle failed"). The firmware fact with its version can stay; the narrative belongs in the commit.
What I verified vs. read
- Built
f5eafa06(#6036's head, which contains this one) and0ef90af5(#6027) on Linux. - Ran the 13 focused targets on both heads, 13/13 passing: the three
peripheral_auth_*tests, bothautomation_sensitive_*tests,tgxl_docked_parity,tgxl_applet_ports,amp_applet,amp_model,meter_model,health_applet, and the two loopback tests above. - Mutation: restoring the
authcode=<code>write madepgxl_direct_protocol_testfail 5 checks, so the guard is real. - Gates:
check_engine_boundary --strict,gen_touchpoint_manifest --check,check_capability_records --strictandcheck_command_plane --stricton the merge base and on this head. Both are flat at 102 findings (line shifts inAutomationServer.cpponly). The tag sidecar is one addition, with no retags. - Bridge (on #6027's head, which carries this PR's redaction): offscreen, isolated settings, attached to
DEMO-0001only (get radio→"model":"AetherSDR Demo"). I typed a TGXL code and clicked Show.invoke setTextechoed"newValue":"<hidden>",dumpTreenever contained the digits, andgrabof the field rendered dots while the button read "Hide". The redaction claim holds. The instance was closed. - Attacks that held (reasoned from code, with a read-only second pass):
- Hostname binding. Case and whitespace normalise. An empty host falls back to the peer IP. Port 0 or an unparseable peer gives no endpoint, and
save()refuses. Exotic numeric forms (127.1) key as distinct hostnames and never cross-match. Records are per device type. - Credential leakage. Checked across auth writes, the AG pending table, failure strings, accessible text, automation
get/grab, and the session fallback (AppSettings::m_sessionCredentials, in-memory only, never saved). The only gap found is N3. - Merge resolutions
be82a425/983d743c+71d5bcd7. Re-run withmerge-tree, they differ from the auto-merge only in.gitignore(correct) and the automation test list (both entries kept). No markers remain. - Antares' unbounded
readAll()flag. Peak memory is onereadyReadplus the 64 KiB cap, and overflow aborts the socket.
- Hostname binding. Case and whitespace normalise. An empty host falls back to the peer IP. Port 0 or an unparseable peer gives no endpoint, and
- CI is stale. The earliest run on this head was created at 2026-10-01 22:26Z;
main's tip is 2026-10-02 19:07Z. Five commits landed between, touching the conflicting files. The green describes a merge that no longer exists, and the per-PRctestis-R-filtered, so none of the new targets ran in CI. - Not verifiable here: any live TGXL/PGXL/AG handshake, real OS-vault behaviour, and N1/N2 at runtime.
…. Principle XI. setAuthCodeForAttempt passes blockReconnect=false when the credential store is unavailable, in the TGXL, PGXL and Antenna Genius paths. The handshake test asserts the reconnect timer stays allowed after an "unavailable" failure and still stops for an empty code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y. Principle XI. AmpModel tracks whether setup read contained the authcode key and appends authcode= only when it was present and empty. The setup comment states the firmware fact and version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… change. Principle XI. - Add the firmware 3.9.8 R4|50000013| probe result to the evidence doc. - Log the PGXL status key/values with authcode removed. - Move the authcode-omission assertion into the socket-free handshake test through a command-writer seam on PgxlConnection. - When a connect to a saved manual TGXL/PGXL address fails at the socket level, retry once at the radio-reported address on the default port; no retry when auth is pending or blocked. The pure address choice is unit tested and the MainWindow wiring has a manual verification section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…6166) Refs #5370 **Limits first.** 1. **The ruling we asked for on #5370 (2026-09-30) was not answered.** This PR implements option (1) of that request: gate the Timings fields on a radio with no command plane, dimmed with the reason. It is filed so the choice is concrete, not to pre-empt it. If you prefer hiding the group, or want (3) first, say so and this changes or closes. 2. **Option (3), a host-side PTT-to-RF delay for radios with no command plane, is not here** and is still an open question. This PR says `Refs`, not `Fixes`, so #5370 stays open for that choice: it is not ours to close. 3. **No field holds a value on such a radio, on purpose.** Option (2), a stored value that delays nothing, is what the reporter and the triage both advised against. 4. **Nothing was run in the app and nothing on a radio.** Everything below is the production dialog in a socket-free test, on macOS. 5. **Only the six fields wired through `connectTimingField` change.** Three neighbours on the same tab have their own shape and are left alone, listed under "Deliberately not covered". 6. **The Demo radio is not covered.** `RadioModel::hasCommandPlane()` is true for the Sim backend, and nothing under `src/core/backends/sim/` parses `interlock`, so on the Demo the six fields stay live and still take no effect. Whether the Demo should dim is your call; the predicate here is the one #6044 uses. ## What was wrong `RadioSetupDialog::buildTxTab()` wires six fields through the `connectTimingField` helper (`src/gui/RadioSetupDialog.cpp`). Each sends `interlock set <key>=<value>` through `RadioModel::sendCommand` unconditionally and keeps no local value. On a radio with no command plane `RadioModel::sendCmd` drops the text (`"no command plane for this backend, dropping"`, `commandDropped`), so on a Hermes-Lite 2 a typed TX Delay did nothing, the operator saw at most the once-per-session notice, and the field read `0` on the next open. Meanwhile the box looked like amplifier sequencing had been set. Nothing else consumes these values on such a radio: `accTxDelay`, `txDelay`, `tx1Delay`, `tx2Delay`, `tx3Delay` and `interlockTimeout` are carried into `TransmitModel` only by `FlexBackend` (`TransmitDelta`), and no file under `src/core/backends/hl2/` reads or writes them. The HL2's own TX timing (`tx_buffer_latency`, `ptt_hang_time`) is gateware state that these fields do not reach. ## The change On a **connected radio with no command plane** the six fields are unavailable: disabled, dimmed with `color.control.unavailable`, never hidden, with the same reason on the tooltip and `accessibleDescription`: > Unavailable: this radio does not accept transmit timings from here. No delay or timeout set here is applied. An edit that arrives anyway (automation, a queued signal) returns before any send, so there is no drop and no notice. The gate follows `RadioModel::connectionStateChanged` and lets go when disconnected. - **Predicate:** `!isConnected() || hasCommandPlane()`. It is the predicate #6044 uses for the title-bar headphone pair (`headphoneControlsAvailable()` in `MixerControlAvailability.h`) and the one the Record Mode pair in this same dialog uses (`radioSideRecordingReachable()`). No family name, no `usesFlexCommandPlane()`, no new capability field. - **Mechanism:** `setEnabled` + tooltip + `accessibleDescription`, by hand, as #6044 and the Record Mode pair do. Not `ControlAvailabilityRegistry`: command-plane reachability is not in `RadioCapabilities`. - **With a command plane nothing changes on the wire:** each field sends the same `interlock set` text, same clamp, Timeout still in ms. - **Two things do change on every radio:** each field gains an accessible name from its caption (it had none), and its stylesheet gains a `QLineEdit:disabled` rule, which applies only while disabled. ## Fields | Field | Wire text | Now, connected and no command plane | |---|---|---| | ACC TX | `interlock set acc_tx_delay=` | dimmed, with the reason | | TX Delay | `interlock set tx_delay=` | dimmed, with the reason | | RCA TX1 | `interlock set tx1_delay=` | dimmed, with the reason | | RCA TX2 | `interlock set tx2_delay=` | dimmed, with the reason | | RCA TX3 | `interlock set tx3_delay=` | dimmed, with the reason | | Timeout (sec) | `interlock set timeout=` (ms) | dimmed, with the reason | **Deliberately not covered:** - **TX profile combo** (same group). It calls `TransmitModel::loadProfile`, a model verb that emits `profile tx load`, not a dialog `sendCommand`. A different shape, not named in #5370. - **TX Band Settings button.** It triggers MainWindow's action, which MainWindow already disables with a reason where there is no command plane. The button itself stays live and then does nothing; a separate, smaller defect. - **Interlocks - TX REQ, RCA and Accessory polarity.** Neither combo is connected to anything, on any radio, Flex included. Not the dropped-write shape. - **Max Power** is already read-only watts there (#6015). **Show TX in Waterfall** is #6139. **Slice/TX Follow** is client state and works everywhere. ## Tests New `radio_setup_tx_timing_fields_test`: the production `RadioSetupDialog`, an injected backend, no socket. 3 cases, all six fields in each: - **no command plane, connected:** each field is visible, disabled, its `accessibleDescription` starts `Unavailable:` and equals the tooltip, it has its accessible name; a typed value plus `editingFinished` logs no `interlock set` and raises no `commandDropped`. Disconnect: enabled, no reason. Reconnect: dimmed again. - **command plane (bare Flex model):** each field is enabled with no reason and sends its exact text once (`interlock set acc_tx_delay=11`, `tx_delay=30`, `tx1_delay=12`, `timeout=45000`, `tx2_delay=13`, `tx3_delay=14`); a negative entry is still sent and shown as `0`. - **command plane, connected (Demo):** enabled, same text, no `commandDropped`. **Mutations, each built and run through the full suite, never pushed:** - availability predicate replaced by `true`: the test fails at `!edit->isEnabled()` on ACC TX (4 passed, 1 failed); - only the send guard in the `editingFinished` handler removed: the test fails with `interlock set` logged 2 times where 0 is expected (4 passed, 1 failed). **Full suite** (macOS arm64, Qt 6.12.0, `-j8`), at `51bad5ff`: 642 of 644 pass; at `2c2853c1` (the review nits): 643 of 644, `vkamp_connection_test` passing that time. Both failures are known on `main` on this Mac and both also fail when rerun alone: - `peripheral_auth_dialog_test` (#6008). This PR touches `RadioSetupDialog.cpp`, so the comparison is exact: at this head and at `main` the output is the same line for line. `Clear code or Status is clipped at the default dialog size`; `dialog 960x680, viewport 658, page minimum 784, horizontal scroll max 126`; three `serial combo (hidden): minimum 344` lines naming this Mac's "LG Monitor Controls" serial port. The page minimum did not move, so this change did not widen the dialog. - `vkamp_connection_test` (#5254): one of its two known checks each time (`commands after a disconnect are dropped, not queued` at `-j8`, `out-of-range antenna ports never reach the wire` alone). No VKAMP file is touched, and it passed in the next full run on this machine. **Static checks:** every script step of the "Static checks" job passes locally, the colour ratchet at +0. `tools/check_a11y.py` on `RadioSetupDialog.cpp`: 9 warnings at this head, the same 9 at the base, none on the lines changed here. The touchpoint manifest regenerates unchanged. ## Overlap with #6139 #6139 (Show TX in Waterfall) edits the same function, `buildTxTab()`, and the same region of `tests/tests.cmake`. A trial merge of this head with #6139's head is clean: no conflict in either file. Either order works. ## Not verified - Nothing in the running app: no screenshot, no screen reader, not the look of the dimmed fields in either theme. - Nothing on a radio, HL2 or Flex. The Flex side rests on the bare-model and Demo cases above. - Linux and Windows locally; CI only. - ANAN, Icom and RTL individually. They have no command plane, so the same gate applies to them; only the injected no-command-plane backend was run. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#6230) Refs #6229, Refs #5198. ## Limits first - **Nothing was keyed for this PR and nothing was observed on a radio.** The evidence is 30 existing captures of one radio and socket-free tests that replay three of them. - **The guard rests on one event.** One forward-power word in 293 211 is the defect. The marker catches it: 1 of 1, one unit. - **The marker is incomplete.** 61 responses at key-on are not measurements; 54 carry a temperature word of 0 and are skipped. The other 7 carry a wrong non-zero temperature (512 to 1007) and still pass. Their forward words are 0 to 4, so nothing shows here. A high forward word in such a response would still be displayed. - **It does not explain the gauge pinned at 6 W on #5198.** No word in the captures reaches the gauge top. - **This corrects my own #6019.** The window maximum keeps every sample, so it also keeps one that is not a measurement. - The cause in the radio is not established. Other units, other gateware versions, Linux and Windows hosts are not measured. - Only the forward-power window skips a marked response. It still reaches the last-value fields. If the 10 Hz publish lands while it is the last value (1.3 to 2.0 ms per key-on in the three replayed captures), the raw temperature row shows 0 for one publish and the smoothed PA temperature steps toward −50 °C through its α = 0.2 pole: 18 °C down from 40 °C, 5.9 °C still missing five publishes later, under 1 °C after 13. The forward word 2944 can likewise be the last value for 661 µs and reach the instantaneous forward row and the SWR pair. Whether to gate these at the receive loop instead is open for the maintainer (#6229 triage). - The new fixture header is 64 KB of recorded words. ## What happens For about 2 ms after the T/R switch-over, the radio's slow-ADC words in RADDR 1 and RADDR 2 are not measurements (#6229 has the counts). In one of 34 recorded key-ons the forward word was 2944 counts on a carrier of 1649. `ForwardPowerWindow` took it as the window maximum and the peak hold showed 3.82 W on a 1.23 W carrier, falling over 3 s. ## The change `ForwardPowerWindow::observe` skips a non-ACK RADDR-1 response whose temperature word, DATA[31:16], is 0. The rule is the free predicate `isNonMeasurementRaddr1()` in `MetisProtocol.h`. The sensor formula puts 0 °C at 0.5 V, so 0 counts is 0 V, which no working sensor reads. The window counts what it skips; the count rides the telemetry emit as `forwardPowerSkipped` and the backend publishes its session total as one Radio Health row, `forwardPowerSkippedTotal`. Four source files: `MetisProtocol.h`, `MetisClient.cpp`, `Hl2Backend.h`, `Hl2Backend.cpp`. - No timer and no threshold: the marker is in the same response as the forward word. - A temperature word of 0 occurs in 54 responses at 27 of 34 key-ons, 1.74 to 4.05 ms after the first EP2 frame with MOX, and in none of 292 842 responses elsewhere, nor in 516 820 receive-only ones. - The skipped responses come 16 ms or more before the first RF sample (20.9 ms on TUNE), so no real sample is lost. - If every response of a window is skipped (a unit whose temperature channel reads 0), the peak is `nullopt` and `publishTelemetry` falls back to the last value, as before #6019: low on speech, never high. `forwardPowerSkippedTotal` then rises all the time, which is how that unit shows. - `forwardPowerRaw`, SWR, temperature, reverse power, bias and the existing Radio Health rows are unchanged. One row is new: `forwardPowerSkippedTotal`, "Forward responses skipped, not measurements (since connect)", absent until a RADDR-1 response arrived. The `HL2 directional` debug line prints the window's skipped count. Nothing else above the backend seam, no capability, no setting. Options not built: a wider marker (temperature word jumped by more than a threshold); a fixed blanking time after key-on (needs a time per unit); dropping the marked response from the last-value fields too, which the #6229 triage asks for at the receive loop. It protects the last-value forward word, the SWR pair and the temperature pole, and a unit that always reads 0 would then lose forward power, SWR and temperature outright. Left for the maintainer; with the predicate it is one call. Rejecting a lone sample above both neighbours, or a median at the head of the over, would discard real one-sample speech peaks, which #6019 exists to catch. ## Tests Socket-free. `tests/Hl2KeyOnRaddr1Fixture.h` holds the recorded RADDR-1 words of three key-ons (2789 responses; captures named with their sha256 in the header). The replay feeds them through `MetisClient::handleDatagram` and `Hl2Backend::publishTelemetry` at ten phases of the 100 ms publish window. | replay | a plain window maximum shows | this PR shows | |---|---|---| | 1 kHz tone at 40 %, the over with the lone word, first second | 3.822 W | 1.211 W, the carrier's own maximum in that second | | TUNE 100 %, first 0.4 s | 5.715 W | 5.715 W, every window equal | | TUNE 10 %, first 0.4 s | 0.879 W | 0.879 W, every window equal | - `hl2_backend_seam_test`: 86 checks, 16 new. The tone over never displays above its carrier; both TUNE key-ons publish the plain maximum in every window, so the rise is not delayed by any window; a **synthetic** speech-like envelope with one-sample peaks at 190 responses a second still wins every window. The health row is absent, then 0, then the session total; the tone replay shows exactly the recording's 2 marked responses and the synthetic speech replay shows 0. - `hl2_metis_protocol_test`: 15 new checks and 2 `static_assert`s. A marked response is neither peak nor counted; the response after it is kept; a lone sample far above both neighbours is kept; a temperature word of 1 is not a marker; a window of marked responses only has no peak. The predicate alone: an ACK, RADDR 0, 2 and 3, and any non-zero temperature word are not marked. The skipped count rises on a marked response and on nothing else; a window of marked responses only shows a count and no peak. - `hl2_fwd_window_wiring_test`: 5 new checks: the emit carries the skipped count and the next window starts at zero. Its existing checks, `hl2_tx_level_policy_test`, and the existing checks in the two files above are unchanged and pass. Full suite on macOS (Qt 6.12.0) at `ba223304`, on `main` `5f09371e`: 646 tests, 643 pass. Three fail at `-j8`, and clean `main` `5f09371e` fails the same three on this Mac: - `vkamp_connection_test`: #5254; passes serially here. - `peripheral_auth_dialog_test`: #6008; this diff touches no dialog file. - `unified_title_bar_test`: two checks ("Alt+F still opens the File menu with the menu bar hidden", "opening a menu rounds the submenus added to it since"), the same two on clean `main`. New with #6198; this diff touches no GUI file. ## Mutations Each a scratch commit on `ba223304`, built and run through the full suite, never pushed. Red checks in the three HL2 tests: - Guard removed: the tone replay check is red (3.822 W shown) and the replay's health count, plus 8 leaf and wiring checks. - Guard widened to normal temperature words (`< 2048`): both TUNE replays, the synthetic speech check and both replay health counts are red, plus 16 leaf and wiring checks. - Guard also drops the response after a marked one: 3 leaf and wiring checks are red. **The replays stay green**, because in the recordings that response carries forward 0 to 4. - Predicate flipped: 6 seam checks and 20 leaf and wiring checks. - Skipped count not incremented: the tone replay's health count and 4 leaf and wiring checks. - Skipped count also incremented on a kept response: both replay health counts and 4 leaf and wiring checks. - Health row reading the response total instead: 6 seam checks. The two predicate mutations also delete the two `static_assert`s, which otherwise stop the build. ## Not verified - Anything on a radio with this build. Nothing was keyed. - Any second unit. A capture of 20 or more key-ons at 384 kHz from another station would show whether its marked responses look the same. - Windows and Linux builds (CI). - The Radio Health dialog and the bridge `health` reply with the new row: the app was not launched; the row is tested through `healthSnapshot()`. - `docs/HERMES.md` is not updated. `docs/automation-bridge.md` has one paragraph on the new row. Static checks: every step of the "Static checks" job in `static-checks.yml` was run locally and is green at `ba223304`. The sanitizer configure job needs the CI image and was not run. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…II. (#6254) Refs #5942 **Hermes-Lite 2 receive audio becomes 12.04 dB quieter, at every slider position.** The panel does not depend on the mode. The level is measured in USB and LSB. AM is checked only for the setting surviving a rebuild, and CW and FM are not measured. That covers the speaker (and the HL2's codec, which the speaker mix feeds), the per-slice audio sent to TCI, and the in-app decoders. Today the chain output sits above full scale: WDSP's panel multiplies by 4.0 after the AGC has limited at about -0.16 dBFS. **No maintainer has ruled on this level change.** It needs a release note: anyone who pulled the slice AF slider down to about 25 to stop strong stations distorting can put it back at 100. ## Limits first - **HL2 only.** ANAN and RTL behave exactly as before. ANAN in every mode, and RTL outside narrow FM, still run WDSP's 4.0. Those backends have their own owners, and this PR does not decide for them. RTL narrow FM already opens the panel at 1.0 (#5924). The new field is not set on any RTL path, wide FM (#5973) included. - **Offline only.** The figures below come from a real `Hl2RxDsp` fed synthetic IQ. No radio, simulator or socket was involved, and nothing was measured on the air. Step 5 of #5942 (does the slider-25 crossover hold across LNA gain) is still unrun. This PR fixes the gain structure, which is provably 12 dB hot. It does not claim to cure any one listener's distortion. - **Unity does not promise `|out| <= 1.0`.** Speak, mpeak and the second bandpass pass all run between the AGC and the panel, as the triage noted. The test therefore bounds a steady tone, not every signal. - This is #5942's step 1, plus step 4's test for the HL2 chain only. Step 2 (the slider moving into `SetRXAPanelGain1`) is not here, because the triage asked for it as a separate change. Step 3 (`Meter::AgcGain`) is not here either. - Measured on one Mac (macOS, Qt 6.12.0). ## What changes - `WdspChannel::Config` gains `std::optional<double> rxPanelGain`. When it is unset, `open()` leaves WDSP's `create_rxa()` value of 4.0 untouched, so every existing owner is unaffected. When it is set, `open()` calls `SetRXAPanelGain1` after the FM block, so a set value wins over the FM recipe's 1.0 (no caller sets both). `validateConfig()` refuses a set value that is non-finite, negative, or on a transmit channel. - `Hl2RxDsp::buildChannel()` sets it to `Hl2RxDsp::kRxPanelGain = 1.0`, so every HL2 build, rebuild and rate change opens at unity. - Two comments in `hl2_rxdsp_unmute_staleness_test` that described the x4 are updated (its marker is still above its own floor). `docs/HERMES.md` §AGC notes that its recorded peaks (3.186, 2.664) include the x4. It also notes that the stages between the AGC and the panel mean the AGC's ceiling does not bound the output. ## The test: `hl2_rx_panel_gain_test` (socket-free, about 1 s) | leg | this commit | HL2 without the panel line (= main) | |---|---|---| | AGC off, 1500 Hz tone at 0.01: chain gain | **+10.01 dB** (the 10.00 dB fixed gain alone) | +22.05 dB | | control: the built config re-opened directly | -0.01 dB vs the HL2 chain | -0.01 dB | | negative control: the same config, panel unset | **+12.03 dB** vs the HL2 chain | -0.01 dB (fails, as it should) | | AGC med (39 dB ceiling), tone at 0.1, USB peak | **0.830 (-1.62 dBFS)**, rms 0.587 | 3.318 (+10.42 dBFS) | | the same, LSB | 0.828 (-1.64 dBFS) | 3.311 (+10.40 dBFS) | | rebuild to AM at 96 kHz keeps unity | yes | no | The ratio between the two AGC-med columns is 4.00 (12.04 dB), and the AGC-off gain moves by the same amount. The negative control takes `Hl2RxDsp::channelConfig()` and clears only `rxPanelGain`. That isolates the panel from everything else the HL2 sets. **Red on mutation** (scratch commits, never pushed; the full suite each time): | mutation | result | |---|---| | `Hl2RxDsp` does not set `rxPanelGain` (main's HL2 behaviour) | RED: the gain, negative-control, both AGC-med and both config checks | | `WdspChannel::open()` ignores `rxPanelGain` (the field is set but never applied) | RED: the gain, negative-control and both AGC-med checks, while the config checks pass. So the level legs check the effect, not the field. | Full ctest suite on macOS (Qt 6.12.0, offscreen), on `121340c0` (head `594d96cf`): 654 of 658 pass at -j8 and 655 after the serial rerun. The three remaining failures are known on clean main on this machine, each with only its known check lines: `control_receive_test` (#6248), `peripheral_auth_dialog_test` (#6008) and `unified_title_bar_test` (#6224). `vkamp_connection_test` (#5254) failed at -j8 and passed serially. This PR touches none of their files. The figures in the table above were re-read from `hl2_rx_panel_gain_test` on this head and are unchanged; the main-column figures and the two mutations are from before the rebase. The rebase brought in #5973 (RTL wide FM, inside `WdspChannel`) and #5975 (HL2 CW carrier level, transmit side); neither touches the panel gain or `Hl2RxDsp`. On the second mutation `vkamp_connection_test` also failed at -j8 and passed serially (#5254). The level figures and the mutations below were taken at `e9a0c396`; `594d96cf` changes comments and one doc line, no executable line. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…II. (#6293) Refs #6285. This covers **item 3 only** (Hermes-Lite 2 discovery). Items 1, 2 and 4 are not touched. ## Why `Hl2Discovery.cpp` has no log statements. When `bind()` fails in `refreshSockets()`, the socket is dropped without a word. So a host where every interface fails to bind ends up with no sockets, and in a support bundle that looks the same as "radio switched off". ## What it logs Four lines on `lcDiscovery` (`aether.discovery`): three `qCWarning`, and the no-reply line at `qCInfo`, because it fires once per session on every install without an HL2. `refreshSockets()` runs on every sweep (default interval 5000 ms), so each line is logged when its condition starts, and again only after the condition has cleared and come back: | line | when | |---|---| | `HL2 discovery: cannot bind <interface> <address>: <error>` | a bind fails. Logged once per (interface, address), and again only after that pair has bound | | `HL2 discovery: no usable IPv4 interface; no probe can be sent` | no socket is left after a refresh | | `HL2 discovery: cannot send a probe from <address>: <error>` | neither the directed nor the global broadcast left that socket (`writeDatagram()` failed). Logged once per local address, and again only after a probe has left it | | `HL2 discovery: no Hermes-Lite 2 answered 3 sweeps on K interfaces` (`interface` when K is 1) | `DiscoveryNotices::kSilentSweepsBeforeWarning` (3) sweeps in a row get no HL2 reply, i.e. 15 s at the default interval. Any HL2 reply resets the count | A sweep with no socket to send on is not counted as silent, because the no-interface line already covers it. Rejected datagrams (non-HL2 or unparseable) are still not logged, since that would be noisy on a busy subnet. The default `aether.*.debug=false` rule hides neither Warning nor Info (`lcDiscovery` is declared `QtDebugMsg`), so no `LogManager` change is needed. **Out of scope: no gateware version floor.** As the triage noted, a log line for "unexpected gateware" first needs a minimum version, and that changes behaviour. It belongs in a separate change. ## How - `DiscoveryNotices`, a small class in `Hl2Discovery.h/.cpp` with no sockets, holds the once-per-condition policy. `Hl2Discovery` feeds it from `refreshSockets()` (each failed and each successful bind, then the socket and interface counts), from `sweepNow()` (whether a probe left each socket), from `onSweepTimer()` (whether the previous sweep got an answer) and from `stop()` (reset). - `app_settings_safety_test` compiles `Hl2Discovery.cpp`, so it now also links the shared `aether_test_log_manager` and `aether_test_async_log_writer` objects, which provide `lcDiscovery`. ## Tests New: `hl2_discovery_logging_test`. It needs no network and no radio. It drives `DiscoveryNotices` and captures the real `aether.discovery` category with the product's default `aether.*.debug=false` rule installed. The checks: - a control: a warning on the category is captured, and its debug line is filtered out - each of the three lines matches its exact text the first time its condition occurs - each is not repeated while the condition persists, and is logged again after it clears and recurs - an intermittent reply never reaches the silence threshold - `reset()` (called by `stop()`) makes all three log again What the test does not cover: the calls from `Hl2Discovery` into `DiscoveryNotices` (`refreshSockets`, `sweepNow`, `onSweepTimer`, `onReadyRead`). Testing those would need real interfaces and a broadcast on the LAN. Results on macOS with Qt 6.12.0: - `hl2_discovery_logging_test`: 21 of 21 checks pass - full ctest at `5f11af00`: 665 tests, 663 pass. `peripheral_auth_dialog_test` and `unified_title_bar_test` fail at `-j8` and serially; both also fail on clean main on this machine (#6008, #6224), and this diff touches none of their files - every `app_settings_safety_*` scenario passes 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…6347) Refs #5911 **Correction to #5911 first.** The issue put its onset figures (a 14 ms rise, about 12 ms of pre-ringing) down to the 2048-tap bandpass. That is wrong. At current main the key-up onset comes from `WdspChannel`'s mute envelope. WDSP holds the first 10 ms of an over's first non-zero audio at zero, then fades it in over 25 ms (raised cosine, `muteDelayUpSec` / `muteSlewUpSec`). This test measures a 14.77 ms 10-90 % rise; that ramp predicts 14.76 ms. The shape does not change with TXA's `bp0` at 8192 taps or with the TXA ALC switched off. Only the delay moves. The bandpass's own onset, measured once the envelope has run, is a 0.73 ms rise. ## Limits - The test reads the IQ `Hl2TxDsp` emits, and nothing else. No radio, no simulator, no socket, no keying. - Times are on the IQ stream's sample clock: output sample n is n / 48 kHz, counted from the tone's first audio sample. Block accumulation on the wall clock, the EP2 queue and the gateware are not in them. - TXA build only. The phasing build exits 77, because no CI job builds it. Nothing here compares TXA with the phasing modulator, and #5911's phasing column is still not re-taken. - The ALC is off and the levels are fixed: 0.015 per tone in the multitone, 0.1 for the onset tone. Each passband leg runs once and each onset leg twice. Repeated runs (serial and under `-j8` load) gave the same figures to 0.01. - Measured on one Mac (macOS, Qt 6.12.0). A response 142 dB or more down sits on float32's numerical floor and is unresolved, not a measurement. The checks stop at 100 dB. ## What it checks `hl2_tx_passband_onset_test` is a separate target and takes about 7 s; `hl2_txdsp_test` already takes about 180 s. - **Passband.** A 25-tone multitone (50..3500 Hz on a 50 Hz grid, Schroeder phases) goes through the real TXA chain. The test reads it on whole 960-sample periods, so every tone's bin and image bin are orthogonal. Each wanted bin is compared with the 1 kHz one, for USB and LSB (300..2700) and DIGU (150..3000), taking the edges from `defaultTxPassbandForModeName()`. The expected edges are written out in the test, so a narrower shipped passband fails. The checks: flat to 0.1 dB from edge+100 to edge-100 Hz, -6 ± 1 dB at each edge, at least 50 dB down 100 Hz outside, at least 100 dB down from 150 Hz outside. - **Onset.** A 1 kHz tone as the over's first audio (key-up), and the same tone after a 200 ms burst and 300 ms of silence (mid-over, with the mute envelope already spent). Key-up: the 50 % point comes no later than 106 ms, and the 10-90 % rise is 10..20 ms. It is the anti-click ramp, so it is bounded from below as well. Mid-over: the 50 % point comes no later than 84 ms, and the envelope crosses -60 dB no more than 12 ms before the 50 % point. Each latency bound is today's figure plus half a 1024-sample DSP block. ## Measured at `4e547347` Every figure below is the same as at `25de97c3`, before #6245 moved the HL2 ALC into `Hl2TxAlc.h`. The test runs with that ALC off. Passband (the same for USB and LSB; DIGU has the same shape at its own edges): | tone | 100 | 150 | 200 | 250 | 300 | 350 | 400 | 500..2500 | 2600 | 2650 | 2700 | 2750 | 2800 | 2900..3500 | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| | dB re 1 kHz | floor | floor | -59.38 | -22.97 | -6.02 | -0.64 | -0.009 | 0.000 | -0.009 | -0.64 | -6.02 | -22.97 | -59.38 | floor | The -59.38 dB at 200 and 2800 Hz matches the figure the withdrawn table in #5911 gave for TXA. Onset: | | key-up | mid-over | |---|---|---| | first IQ at -60 dB | 83.56 ms | 64.08 ms | | 50 % point | 95.54 ms | 72.92 ms | | 10-90 % rise | 14.77 ms | 0.73 ms | | envelope 1 / 4 / 10 ms before the 50 % point | -7.2 / -11.7 / -32.2 dB | -26.7 / -41.6 / -64.8 dB | The two columns differ by 22.6 ms. The mute envelope accounts for that: 10 ms held at zero plus half of the 25 ms slew. Two probes, never pushed, taken at `25de97c3` and not re-taken since, account for part of the 72.92 ms. With the TXA ALC off, the 50 % point comes 6.0 ms earlier (its 6-τ look-ahead at 1 ms). With `bp0` at 8192 taps it comes 64.0 ms later, which is (8192 - 2048) / 2 samples at 48 kHz, and the mid-over -60 dB lead grows from 8.84 to 25.04 ms. #5911's "+96 ms" observation therefore matches the key-up 50 % point here (95.54 ms). ## Red on mutation (scratch commits on `25de97c3`, never pushed, not re-taken; the full suite each time) | mutation | this test | `hl2_txdsp_test` | |---|---|---| | voice default 300..2400 in `defaultTxPassbandForModeName` | RED: flatness and edge, USB and LSB | RED: its baseline pins the pair | | high edge 100 Hz lower inside `Hl2TxDsp` only (mapping unchanged) | RED: flatness and edge, USB, LSB and DIGU | **green** | | TXA `bp0` at 8192 taps | RED: key-up and mid-over 50 % point, mid-over lead | green | `hl2_dsp_readback_test` also fails on the second mutation, because it reads back the edges the channel was given. A narrowing inside WDSP itself would get past it. At `9c715b47`, 755 of 757 tests pass on this Mac, and this test ran inside that `-j8` suite (6.79 s, not skipped). **PR CI only compiles this test**: it is in no `ctest -R` list in `ci.yml`, so its first run upstream is `full-suite.yml` after merge. The two that fail are known failures here, and this commit touches neither: `peripheral_auth_dialog_interaction_test` (the #6008 clipped-page check, in the per-case test #6156 introduced) and `unified_title_bar_test` (its two #6224 checks). ## What this does not change The chain stays as it is. The tap count, the 300..2700 default and the mute envelope are untouched. Whether 300..2700 is the intended shipped voice passband is still #5911's ask 1, and it is yours to decide. If the tap count comes into it: TX's 2048 taps are `create_txa`'s own `max(2048, dsp_size)`. `WdspChannel::Config::filterTaps` reaches receive channels only (the "Receive only" clause in `WdspChannel.cpp`), so setting it on the TX config would change nothing. If you would rather the 10 ms hold at the start of every over were shorter, that is a separate decision: it drops the first 10 ms of the first syllable, and the test pins only the ramp's 10..20 ms rise, not the hold. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #6293 Answers the non-blocking note left on the approving review of #6293. `Hl2Discovery::sweepNow` marked a sweep as sent from the socket count, before the writes. A sweep whose every `writeDatagram` failed therefore closed as silence, and the third one logged the Info line "HL2 discovery: no Hermes-Lite 2 answered 3 sweeps…", although `sendFailed` had already logged the real cause. From this PR's review (`c818031e`): the silence line now counts the interfaces a probe actually left in that sweep, not the bound ones, and the constant's comment says "at least 15 s". `hl2_discovery_logging_test` has 29 checks. ## Limits - Socket-free test only. No radio, no network, no failing socket was provoked: the test drives `DiscoveryNotices` with the calls `Hl2Discovery` makes. That `sweepNow` makes them is read from the code, not tested. - Log policy only. Probing, ageing and the picker signals are unchanged. - A sweep no probe left neither adds to the silent count nor clears it. That is a choice: silence before and after a send outage adds up. - Built and run on one Mac (macOS, Qt 6.12.0). ## Change The rule lives in `DiscoveryNotices`, so it is testable without a socket. `sendSucceeded` marks the open sweep. `sweepClosed` counts the sweep as unanswered only when it is marked, and clears the mark. One socket sending is enough. `Hl2Discovery` reports every closed sweep and no longer keeps `m_probeSent`. A sweep with no socket at all is still not counted, by the same rule. ## Tests `hl2_discovery_logging_test`, new function `sweepWithNoProbeSentIsNotSilence`; the existing silence checks now send a probe before each closed sweep, as the product does. | check | mutation that turns it red | |---|---| | three all-failed sweeps log no "no Hermes-Lite 2 answered" line (and each address's send warning once) | M1: remove the `if (!probeLeft) return;` guard | | one socket sent, one failed: the sweep counts as sent | M3: `sendFailed` clears the mark | | one sent sweep, then failed ones, never reach the threshold | M1, and M2: read the mark without clearing it | | failed sweeps do not clear the count | M4: an unsent sweep zeroes the count (also M1, M2) | Each mutant was built as a scratch copy of `Hl2Discovery.cpp` linked into the test; the unmutated control passed with 0 failures. M1 failed 3 checks, M2 2, M3 1, M4 1. "Three sweeps that sent and got no answer log the line" is the existing third-silent-sweep check; no mutant was run against it here. Full ctest at `c818031e`, `-j8`: 756 of 758 passed, 2 failed, both also on the serial rerun. - `peripheral_auth_dialog_interaction_test`: fails on clean main on this Mac (#6008); this diff touches no dialog file. - `unified_title_bar_test`: fails on clean main on this Mac (#6224); only its two known checks failed; this diff touches no title-bar file. `tools/check_*.py --strict` and `gen_touchpoint_manifest.py --check` pass. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #6347 Answers the two non-blocking notes left on the approving review of #6347. `tests/hl2_tx_passband_onset_test.cpp` only. ## Limits - Test only. No product code, no tolerance and no bound changes; the measured figures are the same before and after. - Neither path occurs on a healthy run. Both were reached by scratch variants of the test that are not in this PR. - AddressSanitizer instrumented the test's own translation unit only, not the library it links. The out-of-range read it reports is a BUS on a wild address, not a redzone hit. - Built and run on one Mac (macOS, Qt 6.12.0), TXA build. ## Change 1. `run()`: when the modulator reports a fault that is not a stall drop, the check failed with ": no fault but a stall drop". The FAIL line now states the expectation that broke: "every modulator fault is a stall drop", as the comment above `run()` describes it. 2. `onset()`: `firstAbove()` returns 1e9 when a level is never reached. `halfAt` is then far past the capture, and `envDb` indexed `c.iq` out of range before `checkKeyUp` or `checkMidOver` could fail. `envDb` now returns the -400 dB floor for any index outside `[t0, c.iq.size())`. 3. `onset()`, from this PR's own review: when the 50 % point is never reached, `leadMs` is the 1e9 sentinel too, not `1e9 - 1e9 = 0`, and the short-capture return sets it as well. So the mid-over lead check fails with the others, where it passed for the wrong reason. ## Evidence Four scratch variants, the test file compiled with `-fsanitize=address` and linked against the tree's library at `5a8a6eb3`: | variant | result | |---|---| | before this PR, 50 % level made unreachable | `AddressSanitizer: BUS on unknown address`, in `envDb` called from `onset()`; no check ran | | this PR, 50 % level made unreachable | no sanitizer report; envelope printed as -400.0 dB; 6 checks failed cleanly, exit 1 | | this PR, one non-stall fault forced | `FAIL: USB: every modulator fault is a stall drop` (and per leg), exit 1 | | this PR, unmodified | all checks passed, no sanitizer report; key-up 50 % at 95.54 ms, rise 14.77 ms; mid-over 50 % at 72.92 ms | Full ctest at `e726c404`, `-j8`: 755 of 758 passed, 3 failed, all three also on the serial rerun. Besides the two below, `vkamp_connection_test` failed with only one of its two known checks (#5254). - `peripheral_auth_dialog_interaction_test`: fails on clean main on this Mac (#6008); this diff touches no dialog file. - `unified_title_bar_test`: fails on clean main on this Mac (#6224); only its two known checks failed; this diff touches no title-bar file. `hl2_tx_passband_onset_test` passed in 6.69 s. `check_test_registration.py --strict`, `check_wdsp_test_links.py --strict` and `gen_touchpoint_manifest.py --check` pass. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…6348) Refs #5463. Not a fix for #5463. **Limits first.** Comments only: no build output, no reading, no transmit level changes. It does not answer the open question on #5463 (a gate ahead of the level chain, or a different mic-slider law; that is yours), and it is not the keyed run we owe there. The ALC Gain gauge's face stays −20..+40 dB, so its upper two thirds, including the yellow (+20) and red (+30) zones, are still never reached. Rescaling it is a visible change to the panel, so it is not bundled here. **What it corrects.** The HL2 ALC caps its target at unity (`Hl2TxAlc::process`, `std::min(settings.targetPeak / levels.micPeak, 1.0)`), and `Hl2TxDsp::Config` has no `alcMaxGainDb`. Five comments still said otherwise: - `PhoneCwApplet.cpp`, at `kAlcGainGaugeMinDb`/`MaxDb` and in `buildPhonePanel()`: the +40 dB top was "the HL2 modulator's makeup ceiling (`Hl2TxDsp::Config::alcMaxGainDb`)" and the yellow/red zones were "makeup spent". They now say readings stay at or below 0 dB, as `Hl2Backend` declares `TX:ALCGAIN` (−20..0), and that −20 is a presentation floor. - `PhoneCwApplet.cpp`, the hover formatter: "the ALC both adds makeup and takes level away". Now: a reduction reads negative. - `PhoneCwApplet::updateAlcGain()`: "a gain pressed against the modulator's ceiling". Now: `HGauge` clamps, so a reduction past −20 dB shows as an empty bar. - `MainWindow_DspApplets.cpp`, the speech-processor presets: "the modulator's ALC, which applies its own makeup gain on top". Now: it only reduces. The preset values are unchanged. Their old reason, that Flex numbers "would compress twice", rested on that makeup, and this PR does not offer a new one. **Tests.** None added, since no behaviour changes. Static checks (`check_*.py --strict`) are clean, and `check_a11y.py` reports the same two warnings on these files as on `main`. Full ctest on `06e78d8a` (on `main` `4e547347`): 754 of 756 pass on this Mac. The two that fail are known failures here, and this change touches neither: `peripheral_auth_dialog_interaction_test` (the #6008 clipped-page check, in the per-case test #6156 introduced) and `unified_title_bar_test` (its two #6224 checks). 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#6255) Refs #5678 (HL2 parity rows), #5263 (the drop notice). No `Fixes` line: I found no issue for this. ## Limits first - **Nothing here was seen in the running app or on a radio.** The defect is a source reading of `main`; the fix is tested on `RadioModel` with an injected backend, and the four `MainWindow` call sites are pinned as text only. - **This is not an M4 conversion.** The `display panafall set` text is not replaced by a typed intent. It moves out of `MainWindow_Wiring.cpp` into the two wire sites `RadioModel` already has. `tools/check_command_plane.py`: `MainWindow_Wiring.cpp` 20 to 14, `RadioModel.cpp` unchanged at 135. - **One design choice is yours: the Black Level gate.** It reads `RadioCapabilities::panBinsAbsolute()`, the same proxy for the row unit the renderer reads (`radio-capabilities-map.md` calls it a proxy). If the row unit gets its own signal later, this gate should follow it. An undeclared `panAmplitude` reads as not absolute, so a backend that computes dB rows and forgets the declaration gets a false notice, not a missing one. - **On Icom, Black Level keeps its notice, also when it comes from Clone to all Pans or Reset display defaults.** Reset turns auto black on, so the value it writes is not in use at that moment; the notice is still raised there, as it is on `main`. I did not make that case quieter. - **ANAN and RTL-SDR are reached** (both declare `binsAbsolute`), and the test does not run with their capabilities: only Hermes-Lite 2, Icom and Flex. - **Two other false notices on a Hermes-Lite 2 are not touched:** the dBm scale (`sendDbmRangeCommand`, beside the dBm-range work of my #6140, now merged) and `cw break_in` (left open by #6111). - **The test ends in a source-text pin** (six checks). It names its claim. - The branch is rebased onto `121340c0`, with no conflict; head `182bb99c` adds two review commits on `1935624d`. Two open PRs edit the same files: #6241 merges with this branch without conflict; #6199 conflicts with it only on adjacent new lines in the `tests/tests.cmake` test list (it also conflicts with `main` itself in `RadioModel.cpp`, independently of this PR). Neither touches the four sites, the two setters, or the gain and black text. Neither merged tree was built. ## What was wrong on main On a backend with no Flex command plane, four Display-panel controls set their value on the `SpectrumWidget`, which draws it, and then also sent Flex wire text: | control | site in `src/gui/MainWindow_Wiring.cpp` | text | |---|---|---| | WtrFall Gain | `wirePanadapter`, `SpectrumOverlayMenu::wfColorGainChanged` handler | `display panafall set <wf> color_gain=N` | | Black Level | `wirePanadapter`, `SpectrumOverlayMenu::wfBlackLevelChanged` handler | `display panafall set <wf> black_level=N` | | Clone to all Pans | `MainWindow::cloneDisplaySettingsToAllPans` | both, per target pan | | Reset display defaults | `wirePanadapter`, `SpectrumOverlayMenu::displaySettingsReset` handler | `color_gain=50`, `black_level=15` | Each went through `RadioModel::sendCommand()` to `sendCmd()`, which has no command plane to write to, logs the drop and emits `commandDropped`. `MainWindow` shows that once per session as "nothing was sent to the radio". So the notice was raised for a control that had acted, and the one-shot was spent. `RadioModel::setWaterfallColorGain()` and `setWaterfallBlackLevel()` already return early without a command plane. The four sites did not use them: those setters address the active pan, the sites address a named one. Manual Black Level acts on a Hermes-Lite 2 since #6115 (`WaterfallLevelMap::manualBlackThreshold` on absolute-dB rows). ## The change - `RadioModel` gains `setWaterfallColorGainFor(panId, gain, autoBlack)` and `setWaterfallBlackLevelFor(panId, level)`. All four setters go through two private senders, `sendWaterfallColorGain()` and `sendWaterfallBlackLevel()`, which hold the only wire site and the only command-plane gate for each key. - The four sites call those setters after the widget call, and pass the pan's `wfAutoBlack()` with the gain. They no longer build wire text. - **WtrFall Gain** is withheld without a command plane where the client draws it: auto black on, or `panBinsAbsolute()`. With manual black on any other row (Icom), every row is black whatever the gain, so it goes to `sendCmd()` and is reported. - **Black Level** is withheld without a command plane only where `panBinsAbsolute()` is true (Hermes-Lite 2, ANAN, RTL-SDR). Elsewhere it goes to `sendCmd()` and is reported. On Icom the manual black point is still in tile units against a dB row, so every position draws black: the slider does nothing there and says so. - The connect-time replay (`setWaterfallBlackLevel()` from `panadapterInfoChanged`) stays quiet on every backend with no command plane, as on `main`. No operator moved anything. - No capability is added and no family is named. - `docs/architecture/radio-capabilities-map.md` records the flag's new consumer. **A Flex is sent what it is sent today**: same text, same pan, same order. The sites used `sendCommand()` and the setters use `sendCmd()`; the only thing `sendCommand()` adds is the pan-ownership gate, which matches `display pan set ` and not `display panafall set `. ## How this sits with the #5919 review That review kept the notice for an operator-moved control the backend cannot apply, because nothing happened and the notice says so. It was about receive controls; nobody has ruled on display text, and I am not reading it as covering this. The rule used here is narrower than "no command plane, no notice": the send is withheld only where the control has acted in the client. Where it has not (Black Level on Icom, and WtrFall Gain on Icom with manual black), the notice stays and the test pins it. If you would rather keep the notice on these four until pan commands get a typed intent, close this and I will leave them. ## Tests `pan_display_drop_notice_test` (new, socket-free, 41 checks). The injected backend carries `capabilities()` read off a real `Hl2Backend`, `IcomCivBackend` and `FlexBackend`; panes are made the way a backend without Flex status makes them. - **Hermes-Lite 2 capabilities, no command plane (12 checks):** the renderer's law changes with gain (auto and manual black) and with the manual black level for a dB row; none of the four raises `commandDropped`; the same gain text sent raw is still reported, so the alarm is live in the fixture. - **Icom capabilities, no command plane (13 checks):** with auto black gain raises nothing; with manual black no gain changes a dB row and gain raises `commandDropped`, with the exact text; every Black Level position draws a dB row black and the slider still raises `commandDropped`; Clone reports the black level only with auto black, and both keys with manual black; Reset reports the black level only; the connect-time replay raises nothing. - **A command plane (10 checks):** the wire text compared whole through a command sink on an unopened `RadioConnection`, for each control, for the target pan and not the active one, gain before black. With absolute-dB rows and a command plane the black level is still sent. - **Source-text pin (6 checks).** The claim: each of the four sites sets the widget and then calls the model setter, and `MainWindow_Wiring.cpp` builds no gain or black wire text of its own. No behavioural seam reaches it: the sites are `MainWindow` lambdas and no test target constructs a `MainWindow`. It shows how the sites are written, not that they run. **Mutations**, each as a scratch commit on `182bb99c` through the full suite: | mutation | failing checks of 41 | other tests | |---|---|---| | both gates removed, the four sites as on `main` | 15 | `icom_family_test` | | Black Level gated on `hasCommandPlane()` alone | 4 | none | | colour gain ignores auto black (row unit only) | 4 | none | | colour gain ignores the row unit (auto black only) | 4 | none | | colour gain always withheld (the `1935624d` rule) | 2 | none | | the connect-time replay reports its drop | 1 | `icom_family_test` | The legs share one capture of dropped commands, so a wrong drop is also counted by the checks after it in that leg. Full ctest on macOS (Qt 6.12.0, Ninja, RelWithDebInfo) at `182bb99c`: 654 of 658 pass, at -j8 and on the serial rerun. The four failures are known on clean `main` on this machine, each with only its known check lines: `control_receive_test` ("RTL adjustable filter qualification excludes legacy WFM", #6248), `vkamp_connection_test` ("commands after a disconnect are dropped, not queued", #5254), `peripheral_auth_dialog_test` (#6008, a serial-port name of this host) and `unified_title_bar_test` (its two menu checks, #6224). This PR changes `MainWindow_Wiring.cpp`, but only the four display sites; the same two title-bar checks fail identically on two other branches at the same base that do not touch it. Nothing else this PR touches is built by those tests. Every script step of the `Static checks` job was run locally at `182bb99c` with stdin from `/dev/null` and is clean: colour ratchet +0, `gen_touchpoint_manifest.py --check` up to date with no change, `check_a11y.py` on the one changed GUI file with no findings. The `Sanitizer option configures` job needs the CI image and was not run. ## Not verified - Nothing in the running app; nothing on a Hermes-Lite 2, an Icom or a Flex. - Whether each of the four controls is enabled and reachable on a Hermes-Lite 2 today. - That the Icom Black Level slider is dead, and WtrFall Gain with it under manual black, is the renderer's arithmetic plus `IcomCivBackend` sending its scope rows through `toDbm`; I have no Icom. - That the three sites pass the pan's own `wfAutoBlack()` is pinned by source text only. - ANAN and RTL-SDR, beyond their `binsAbsolute` declaration. - Linux and Windows builds: CI will say. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#6388) Refs #5482, #6353 This is the follow-up to the two forward notes in the approving review of #6353. ## Limits, first - **macOS, offscreen platform only** (Qt 6.12.0). The pixels are from `QWidget::grab()` in a test. Nobody looked at the dialog on a screen, and there is no screenshot. - **Two themes rendered: Default Dark and Default Light.** No user theme. - **The Line Out row's styling is touched; its logic is not.** That row is @jcmerg's (#6260). His predicate, his reason text and his `applyLineoutAvailability` lambda are unchanged. Only the three stylesheets of the row gain a `:disabled` rule, the same one the Headphone row gets, so the two rows keep agreeing. - **The Front Speaker row is not touched.** It is never disabled. - **A dimmed Mute looks the same engaged or not** (from this PR's review, `c0bfd883`). An engaged Mute on an unavailable row took the unavailable text on the engaged red background. It now takes the row's own unchecked background (`color.background.1`), so the engaged state is not shown while the row is unavailable. §4a names no look for a control that is unavailable and also has state, so whether the state should stay visible is the maintainer's call. The checked state is still on the widget for accessibility clients. - **Contrast is what the token gives.** `color.control.unavailable` is 2.09:1 (dark) and 1.77:1 (light) by the style guide's own table (§4a). This PR uses the token and does not change it. - **The Line Out reason sentence is not hoisted.** It exists in one place only (`src/gui/RadioSetupDialog.cpp:4774-4775` at `9fabcbf8`); no other source file and no test repeats it. - `Refs`, not `Fixes`: closing #5482 stays your call. ## What was wrong At `9fabcbf8`, in Radio Setup → Audio → "Radio Audio Outputs", a disabled row shows it on the slider only. The other three widgets of each row carry their own stylesheet, and none has a `:disabled` rule: | widget | stylesheet | applied at (Line Out / Headphone) | |---|---|---| | caption label | `kLabelStyleTemplate`, `QLabel { color: {{color.text.primary}}; … }` (`src/gui/RadioSetupDialogCommon.h:31-32`) | `src/gui/RadioSetupDialog.cpp:4740` / `:4794` | | value label | `kValueStyleTemplate`, `QLabel { color: {{color.accent.bright}}; … }` (`RadioSetupDialog.cpp:129-130`) | `:4745` / `:4799` | | Mute button | inline `QPushButton { … color: {{color.text.primary}}; … }` plus `:checked` | `:4751-4753` / `:4805-4807` | | slider | none of its own | `:4742` / `:4796` | An explicit `color` with no `:disabled` rule also applies when the widget is disabled, so the label, the value and the Mute button render the same in both states. The review of #6353 measured 0 differing pixels on them; the test here reproduces that (below). The two headphone reason sentences were written out three times: `src/gui/TitleBar.cpp:1297-1302`, `src/gui/RadioSetupDialog.cpp:4826-4834`, and `tests/radio_setup_headphone_output_gate_test.cpp:134-139`. ## The change **The look.** A disabled label, value or Mute button of the two rows takes `color.control.unavailable`, the token §4a of `docs/style/theme-style-guide.md` names for the unavailable state: ``` QLabel:disabled { color: {{color.control.unavailable}}; } QPushButton:disabled { color: {{color.control.unavailable}}; } ``` This is the pattern already in the tree for the TX timing fields (#6166, `RadioSetupDialog.cpp:2565-2567`, `QLineEdit:disabled`) and for `WfmApplet.cpp:58`. The rules go through `ThemeManager::applyStyleSheet`, so they follow a theme switch like their neighbours. No colour literal is added. The enabled look is unchanged: the rule is appended to the existing template, and only for these six widgets. `makeValueLabel` takes an optional extra rule so `kValueStyleTemplate` keeps its single reference. The shared `applyLabelStyle` is not changed, so no other page of the dialog moves. **The wording.** `src/gui/MixerControlAvailability.h` now holds the two sentences next to the predicate, as `kHeadphoneLevelUnavailableReason` and `kHeadphoneMuteUnavailableReason`. They are `constexpr char[]`, so the header stays free of Qt. `TitleBar::setHeadphoneAvailable`, the Radio Setup lambda and both tests read them from there. The text itself is unchanged, character for character. No predicate, no capability field, no default and no user-visible string changes. `docs/user/docs/audio-settings.md` already says the controls "are dimmed with the reason in the tooltip", which stays true. ## Tests `tests/radio_setup_headphone_output_gate_test.cpp` gains `dimmedRowsLookUnavailableInBothThemes` (offscreen, no socket). For each of Default Dark and Default Light it builds the dialog on the HL2 capabilities (both rows dimmed) and on the Demo (both rows live), and for the label, value and Mute button of each row: - renders the widget's own region with `grab()` and counts differing pixels, exactly, no tolerance. It requires more than 0 between dimmed and live; - first asserts the two renders are comparable: same size, same value text, same mute state. So the enabled state is the only difference; - control 1: the same widget rendered twice differs in 0 pixels; - control 2: after a disconnect the same HL2 widgets are live again and render pixel-identical to the Demo's, and different from their own dimmed render; - requires the resolved stylesheet to carry the colour `color.control.unavailable` has in the active theme. With the change, differing pixels (measured at `6c83ac5a`, the same diff before it was rebased onto current `main`; the test that asserts them passes at the head), dimmed vs live, identical in both themes: | region | Headphone | Line Out | |---|---|---| | label | 1329 / 6840 | 852 / 6840 | | value | 410 / 2280 | 410 / 2280 | | Mute | 512 / 3800 | 512 / 3800 | **Fail-before.** Named mutation: this commit with both `:disabled` rule strings emptied, which is main's styling. Full build and ctest. All twelve regions read 0 differing pixels, as in the review: ``` PIXELS Default Dark | Headphone: label: dimmed vs live 0 / 6840 (same widget twice: 0) PIXELS Default Dark | Headphone: value: dimmed vs live 0 / 2280 (same widget twice: 0) PIXELS Default Dark | Headphone: mute: dimmed vs live 0 / 3800 (same widget twice: 0) PIXELS Default Dark | Line Out: label: dimmed vs live 0 / 6840 (same widget twice: 0) PIXELS Default Dark | Line Out: value: dimmed vs live 0 / 2280 (same widget twice: 0) PIXELS Default Dark | Line Out: mute: dimmed vs live 0 / 3800 (same widget twice: 0) (the same six lines for Default Light) FAIL! : RadioSetupHeadphoneOutputGateTest::dimmedRowsLookUnavailableInBothThemes() 'failures.isEmpty()' returned FALSE. (Default Dark | Headphone: label: dimmed renders as live Default Dark | Headphone: label: not the unavailable token #3a4a5a … Totals: 5 passed, 1 failed, 0 skipped, 0 blacklisted ``` The test could not be run on unmodified main as it stands, because it reads the sentences from the header. That is why the fail-before is a mutation and not "the test alone". **The wording.** `radio_setup_headphone_output_gate_test` compares the row against the header's two constants. `tests/mixer_control_availability_test.cpp` gains two rows: the title bar's mute and slider descriptions equal the header's sentences. Its existing rows still check the content independently ("Unavailable:", "no headphone output", "speaker button", "master volume"). ## ctest macOS (Apple Silicon), Qt 6.12.0, `ENABLE_ASR=OFF`, full suite at `-j8`, failures rerun serially, on `c0bfd883` (this PR's head): - **765 tests, 763 passed, 2 failed**, both also on the serial rerun: the dialog test below, and `vkamp_connection_test` with only its two known checks (#5254). - `peripheral_auth_dialog_interaction_test` fails, at `-j8` and serially: "Clear code or Status is clipped at the default dialog size", `dialog 960x680, viewport 658, page minimum 784`. This is the host-dependent failure of #6008 (diagnostic in #6134): the Peripherals page is as wide as the longest serial-port name on this Mac. The page minimum is 784, the same figure as on clean main here. This PR does not touch the Peripherals page. - `radio_setup_headphone_output_gate_test` (6 passed), `mixer_control_availability_test`, `titlebar_headphone_mute_test`, `unified_title_bar_test` and `radio_setup_label_theme_token_test` pass. Static checks, run locally: `tools/check_a11y.py` on `RadioSetupDialog.cpp` 9 findings before and 9 after, `TitleBar.cpp` 0; `tools/audit_colours.py` summary identical before and after; `gen_touchpoint_manifest.py --check` and `docs/gen_reference.py --check` up to date; the `tools/check_*.py --strict` set passes (`check_codeql_coverage.py` needs a build directory and was not run). 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…6404) Refs #6377 Answers rfoust's optional note on the approving review of #6377, `tests/hl2_tx_passband_onset_test.cpp:286`: > the unreachable-onset and short-capture paths are demonstrated by scratch > variants, but remain absent from committed regression coverage. Consider > retaining socket-free injected-capture cases so removing this bound or the > leadMs sentinel guard fails automatically. ## Limits - Test only, one file. No product code, no tolerance and no bound changes. - Built and run on one Mac (macOS, Qt 6.12.0), TXA build. Per-PR CI compiles this test but does not run it. - "A level is never reached" is injected as a capture whose settled level is NaN. With finite samples it cannot be reached: the scan runs to the end of the capture, so it includes the settled tail, whose largest sample is at least its mean. - **Found while writing these cases, and fixed in the second commit (`683e6302`):** a silent capture is not that path either. Its settled level is 0, so every level was "reached" at the first sample and it read a 0 ms onset; an all-zero mid-over capture passed both mid-over bounds. The analysis now returns the sentinel when the settled level is zero, and an injected silent capture pins that every onset bound fails, at both `t0`. The passband legs already failed on a silent modulator; this closes the onset legs. - Without the envelope-read bound the test fails by crashing, not by a FAIL line: the read is at a wild address. - AddressSanitizer instrumented the test's own translation unit only, not the library it links. ## Change 1. `onset()` is split. `analyseOnset()` reads an IQ vector and a `t0`; `onset()` runs the modulator and hands it the capture. The short-capture return stays in the analysis and sets a `tooShort` flag; `onset()` turns that into the failed check it was. 2. The four onset bounds are predicates, so a case can assert that a result fails them without failing the test. `checkKeyUp` and `checkMidOver` call them with the same messages. 3. `checkInjectedCaptures()` runs with no modulator, at the key-up `t0` (0) and at the mid-over `t0`: - a step, as the control: the 50 % point reads 10 ms and passes; - a level never reached: `halfMs` and `leadMs` are the 1e9 sentinel, all six envelope reads are the -400 dB floor, all four bounds fail; - a capture one sample too short: `halfMs` and `leadMs` are the sentinel, all four bounds fail. ## Evidence The healthy run prints the same before and after: with the 16 lines of the injected cases left out, the test's output at `bbded449` and at this head is identical line for line (121 lines), exit 0 both times. | | key-up 50 % | key-up rise 10-90 % | mid-over 50 % | |---|---|---|---| | before (`bbded449`) | 95.54 ms | 14.77 ms | 72.92 ms | | this PR | 95.54 ms | 14.77 ms | 72.92 ms | Mutations, each a scratch build of the test file only, linked against the tree's library at this head; nothing of them is in this PR: | mutation | result | |---|---| | none | all checks passed, exit 0; under AddressSanitizer too, no report | | upper bound dropped from the envelope read | crash (SIGSEGV) in the first never-reached case, no check after it runs; under AddressSanitizer `BUS on unknown address` in the envelope read inside `analyseOnset` | | `leadMs = halfMs - firstMs` again | 4 checks failed, exit 1: "level never reached: halfMs and leadMs are the sentinel" and "every onset bound fails", at both `t0` | | `o.leadMs = 1e9` dropped from the short-capture return | 4 checks failed, exit 1: "too short: halfMs and leadMs are the sentinel" and "every onset bound fails", at both `t0` | From this PR's review (`46d88876`): `rise1090Ms` is the sentinel on every failure path too, and the checks assert it; the injected cases run before the modulator gate, so every build runs them, and the gate keeps a failure instead of skipping over it. Full ctest at `46d88876`, `-j8`: 770 of 772 passed (4 skipped: the `nr_stereo_independence_*` set), 2 failed: - `vkamp_connection_test`: failed at `-j8`, passed on the serial rerun (#5254). - `peripheral_auth_dialog_interaction_test`: "dialog 960x680, viewport 658, page minimum 784" (#6008); fails on clean main on this Mac, and this diff touches no dialog file. `hl2_tx_passband_onset_test` passed in 6.82 s. `check_test_registration.py --strict`, `check_wdsp_test_links.py --strict` and `gen_touchpoint_manifest.py --check` pass. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…6402) Refs #6185. ## Limits, first - **Behaviour-preserving, "wide".** Every backend that stores the waterfall rate on `main` today (HL2, ANAN, Icom, RTL, the demo radio) declares the client its owner. Flex declares none. The same radios store and restore the same value as before; only the shape of the gate changes. - **The wide/narrow ruling is not made here.** The triage on #6185 recommends wide and leaves it to the maintainer. If a family should stop storing the rate, that is one edit in that backend's `capabilities()` (`true` to `false`, or `std::nullopt`), and its row in `client_display_pan_settings_test` flips with it. - **The identity predicate is not touched** (ruling (b) in the triage). The waterfall rate still gates on `scope.hasRadioIdentity()`, so a serial-less RTL dongle still writes the family-wide `rtl` row, where FFT averaging refuses. That is why this is `Refs` and not `Fixes`. - **The `shapedLocally` parameter of `ClientDisplaySettings::waterfallRate` / `saveWaterfallRate` keeps its name.** The triage suggests renaming it to match the averaging pair. #6139 inserts code one line from it, so here only the call sites change what they pass. The rename is a follow-up once #6139 has landed. - **#6139:** a trial merge of this branch with its head (`cf0006c5`) has no conflict (`git merge-tree --write-tree`). That merged tree was not built. #6255, which touched the same files, is in `main` and under this branch. - **#5996** (IC-9700 waterfall rate resets after a restart) is not changed by this PR in either direction: the Icom backend declares the client the owner, so the store and restore that `main` already has for Icom keep running. - No radio was used. Nothing here was run against hardware. ## What was wrong Read on `main` at `16cfa80b`, before this change; the branch now sits on `aa0dd0e4`: - Save: `MainWindow::scheduleClientWaterfallRateSave`, `src/gui/MainWindow_Wiring.cpp:3145-3157`. The only owner gate is `m_radioModel.shapesDisplayRatesLocally()` (`:3148`). - Restore: `src/gui/MainWindow_Wiring.cpp:3333-3336`, under the `if (m_radioModel.shapesDisplayRatesLocally())` at `:3321`, with a literal `true` passed to the store. - The gate, `src/models/RadioModel.h:1366-1368`, is `m_backend != nullptr && m_flexBackend == nullptr`: "a backend exists and it is not a Flex". So ownership of the waterfall rate was inferred from the family, where `docs/agents/settings.md` asks for a declared capability. FFT averaging, FFT FPS and the dBm range each already have a declared owner. ## The rule now - `PanWaterfallRateShaping { bool clientPersistsWaterfallRate; }` in `RadioCapabilities.h`, an optional record `panWaterfallRateShaping`, and the accessor `RadioCapabilities::clientPersistsPanWaterfallRate()`. Absent means not declared, and an undeclared backend owns nothing. - `MainWindow::clientPersistsWaterfallRate()` is `shapesDisplayRatesLocally() && clientPersistsPanWaterfallRate()`, the same shape as `clientPersistsFftFps()`. The outer term stays, as the triage asked. - The save and the restore both read that one predicate. A separate record, not a second field in `PanFrameRateShaping`: four backends leave that record `std::nullopt` for FFT FPS, and engaging it to carry the waterfall rate would turn their "not declared" into "declared false". No new top-level bool in `RadioCapabilities`: `tools/check_capability_records.py --strict` stays at 70. ## Per family | family | `panWaterfallRateShaping` | stores and restores the rate, before | after | |---|---|---|---| | Flex | absent | no (the radio stores and reports it) | no | | HL2 | engaged, `true` | yes | yes | | ANAN | engaged, `true` | yes | yes | | Icom | engaged, `true` | yes | yes | | RTL | engaged, `true` | yes | yes | | Sim (demo) | engaged, `true` | yes | yes | ## Tests `client_display_pan_settings_test` is extended; no new test target. - Each real backend (`Hl2Backend`, `IcomCivBackend`, `AnanBackend`, `SimBackend`, `RtlSdrBackend` where the build has it, `FlexBackend`) is constructed, and its declaration is read through `clientPersistsPanWaterfallRate()` and run against the real settings store: an owner stores 63 and reads it back, and reads and rewrites a rate already in the document; a non-owner does neither and leaves a held document byte for byte. - A record engaged with `false` stores and restores nothing. An absent record does not either. A record engaged with `true` on otherwise empty capabilities does (control). - Owning the waterfall rate leaves the FFT FPS record absent (Icom). - Source text, because `MainWindow` links into no test: the predicate reads the outer term and the accessor; the save asks the predicate and hands its answer to the store; the restore asks the predicate; there is exactly one stored read and one stored write of the rate in the wiring. The RTL declaration is also pinned as text, for builds without librtlsdr. **Mutation evidence.** One scratch commit with these mutations was built and run through the full suite. `client_display_pan_settings_test` reported 29 failed checks, and every check added here is among them: | mutation | checks that turned red | |---|---| | restore reads `shapesDisplayRatesLocally()` alone | "and so does the waterfall rate restore" | | save reads `shapesDisplayRatesLocally()` alone | "the waterfall rate save asks that predicate" | | save passes a literal `true` to the store | "and hands the store that answer" | | predicate drops the capability term | "the waterfall rate predicate reads the backend's declaration" | | a second direct `ClientDisplaySettings::waterfallRate(` read in the wiring | "the waterfall rate has one stored read and one stored write" | | accessor ignores the bool (`has_value()` only) | all six `rate-declined:` rows | | Icom leaves the record absent | five `icom:` rows, and "the waterfall rate owner does not engage the FFT FPS record" | | Icom engages `PanFrameRateShaping{false}` | the same "does not engage the FFT FPS record" check; "icom: FFT FPS owner as declared" stayed green, which is the distinction the separate record protects | | RTL leaves the record absent | five `rtl:` rows, and "the RTL backend declares the client the waterfall rate owner" | | Flex engages the record with `true` | six `flex:` rows | Two limits of that evidence. The "does not engage the FFT FPS record" check is one conjunction and two of the mutations each break one half of it, so the run does not separate them. And one mutation was not run: an accessor that treats an absent record as an owner, which is what the `undeclared:` rows guard. ## Results macOS, Qt 6.12.0, full `ctest`: - At `40398b89`: 771 tests, 770 pass, 1 fails, at `-j8` and again on a serial rerun. It also fails on clean `main` on this machine: - `peripheral_auth_dialog_interaction_test`: "Clear code or Status is clipped at the default dialog size" (#6008, a long USB serial-port name on this host widens the page). No dialog file is touched. - `client_display_pan_settings_test`, `client_display_settings_test` and `pan_dimension_routing_test` pass. The RTL backend is built in this configuration, so the `rtl:` rows ran. Static checks, all `--strict`: `check_capability_records` (70 of the frozen 70), `check_command_plane`, `check_engine_boundary`, `check_ci_test_gate`, `check_test_registration`, `check_wdsp_test_links`, `check_qsettings`, `check_network_timeouts`, `check_build_options`, `check_qt_pin`, `check_shader_dialects`, `check_theme_seed` pass. `gen_touchpoint_manifest.py --check` reports the manifest up to date. Docs updated to the new rule: `docs/HERMES.md` §15.2.2, `docs/architecture/radio-capabilities-map.md` (a `panWaterfallRateShaping` row), `docs/architecture/aetherd-icom-civ-backend-design.md`. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…VIII. (#6403) Refs #6387, #6388 Answers rfoust's three optional notes on the approving reviews of #6387 and #6388. ## Limits - One documentation sentence, one comment and five include lines. No behaviour change: no statement, declaration or test registration is touched, and the five headers were already reached through `<QtTest>` and `<QApplication>`. - Built and run on one Mac (macOS, Qt 6.12.0). Per-PR CI compiles these tests but does not run them. The Linux rule the sentence describes is not run on Linux here; it is exercised by `audio_device_preference_policy_test` on this host. - The documentation now names the five ID patterns used by the Linux hot-plug filter. The source matches substrings or a prefix, including custom names; it does not establish any audio server's default device names. - The include pass covers the Qt classes #6387 and #6388 named first in the files they touched. Transitive includes that were there before them are listed below and left alone. ## Change 1. #6387, `docs/user/docs/audio-settings.md:28`: > this reads as applying to all audio-server virtual devices, but the > exception depends on five ID patterns. A custom-named virtual device > outside those patterns restores live. The reviewer's suggested sentence was the starting point. The follow-up commit names all five matching ID patterns, including monitor sources and custom names. `isLinuxTransientAudioDeviceId` leaves those ids out of the arrival list, so a saved device matching one is never seen arriving; at startup the saved id is looked up among all enumerable devices. 2. #6387, `src/core/AudioDevicePreferencePolicy.h:54`: > this comment should describe test portability without claiming every > lane runs it. The suggested wording is taken as written. 3. #6388, `tests/radio_setup_headphone_output_gate_test.cpp:38`: > Include it directly rather than relying on QtTest to supply it > transitively, per the repository explicit-include convention. `<QSignalBlocker>` is included. The same pass over both PRs found four more Qt classes #6388 named first in that file without their header: `QColor`, `QCoreApplication`, `QList`, `QStringList`. They are included too. Found by the same pass and not changed, because the use predates both PRs: `QString` and `QWidget` in that test; `QString` in `mixer_control_availability_test.cpp`, `RadioSetupDialog.cpp` and `TitleBar.cpp`; `QByteArray` and `QList` in `MainWindow.cpp`. The other files of #6387 include what they name. ## Evidence Full ctest before the documentation-only follow-up, at `ac0dd8f6`, `-j8`: 770 of 772 passed (4 skipped: the `nr_stereo_independence_*` set), 2 failed: - `peripheral_auth_dialog_interaction_test`: "dialog 960x680, viewport 658, page minimum 784" (#6008); fails on clean main on this Mac, also on the serial rerun. This diff touches no dialog file. - `vkamp_connection_test`: one of its two known checks, "commands after a disconnect are dropped, not queued" (#5254), at `-j8`; it passed on the serial rerun. `radio_setup_headphone_output_gate_test`, `audio_device_preference_policy_test` and `mixer_control_availability_test` passed. `tools/docs/gen_reference.py --check`, `check_test_registration.py --strict` and `gen_touchpoint_manifest.py --check` pass. At the follow-up commit `f7dd95c9`, `npm ci && npm run build` in `docs/user/` passed, and the source checks remained green. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
…I. (#6139) **Update after review, `ea2cf144` (2026-10-05):** ownership of the flag is now declared, not inferred. Only the Hermes-Lite 2 declares the client its owner (`RadioCapabilities::txWaterfallClientFlag`, `clientPersistsFlag` true). The demo, ANAN, Icom and RTL-SDR declare nothing and keep `main`'s behaviour: the toggle sends the wire text, the model stays false, nothing is stored or restored. That is narrower than this PR was. On the demo, `txwaterfall on` answers with the radio-echo note again. Full suite at this head: 644 of 645; the one failure is `peripheral_auth_dialog_test` (#6008), which fails on clean `main` too. Source line numbers below are `main` at `85f2a124`. Refs #5678 (HL2 parity; "Show TX in Waterfall", row P30 of our status table). No issue exists for this. ## Limits, first - **Nothing was keyed.** On an HL2 the toggle now sets the flag. What the waterfall draws during an over on an HL2 has not been seen, and what that radio's receive path reads while it transmits has not been measured. A `true` model flag shows neither the colour law nor real receive IQ while keyed; the demo cannot key at all. - **The fix has not been seen in a running app.** The dead toggle was (2026-10-01, bridge verb, 6 of 6 polls false). The fix is tested socket-free on the real models and dialog. - **It reaches the Hermes-Lite 2 only.** The request, the store and the restore are gated on one accessor, `RadioCapabilities::clientPersistsShowTxInWaterfall()`, which reads a declared per-feature record. Flex, Icom, ANAN, RTL-SDR and the demo leave the record absent. On Icom, ANAN, RTL-SDR and the demo the toggle is as dead as on `main`. Engaging another family is one declaration, for that family's owner or the maintainer to make. - **Yours to move:** the keyed rows take the colour law a Flex's keyed rows use (`dbmToWaterfallLevel`). On an HL2 they are absolute dB (dBFS under a dBm label); whether the law suits them is unknown until someone keys one. - **Persistence is in the radio's settings document**, not a flat key: one optional bool, `showTxInWaterfall`, in the existing per-radio `ClientDisplay` feature document. No schema bump. Written and read only where the backend declares the client its owner. ## What was wrong on `main` On a Flex the flag is the radio's: `transmit set show_tx_in_waterfall=` tells it, and its transmit status echoes it (`FlexBackend.cpp:1193`). That echo is the only writer of `TransmitModel::showTxInWaterfall()`. - The Radio Setup button sends the wire text unconditionally (`RadioSetupDialog.cpp:2717`), and so does the bridge verb (`AutomationServer.cpp:3599`). - A radio with no command plane drops it (`RadioModel.cpp:9764`) and nothing echoes, so the flag can never become true. The demo has a command plane and no handler for the flag, so nothing echoes there either. - While keyed, the pane holding the TX passband draws rows only when the flag is true (`SpectrumWidget.cpp:8488`). With it false that pane's waterfall stands still for the whole over. There is something to draw: the HL2 mutes its demodulator while keyed and the spectrum keeps running on real IQ (`Hl2RxDsp::setAudioMuted`). ## The change - `RadioModel::requestLocalShowTxInWaterfall(bool)`, the shape of the existing `requestLocalPanWeightedAverage`: where the backend declares the client the owner of the flag it applies the flag and returns true; everywhere else it returns false and the caller sends the wire text, which only a Flex answers. No raw command is added above the seam (`check_command_plane`: 400, baseline 400). - It writes the model through `TransmitModel::applyChanges`, the path an echo takes. - It stores the value per radio, on the same declaration. A disconnect clears the transmit model (`TransmitModel.cpp:43`) and no status puts the flag back, so `RadioModel::onConnected()` restores it, on the same declaration. - The Radio Setup button and the `txwaterfall` verb ask the model first. - `RadioCapabilities::txWaterfallClientFlag`: a new optional per-feature record with one field, `clientPersistsFlag` (no default). Engaged by the HL2 only. No top-level capability boolean is added (`check_capability_records`: 70 of 70). `SpectrumWidget` is not changed. Default unchanged: off. ## Tests `show_tx_in_waterfall_client_flag_test` (new, socket-free, real backends): on the real `Hl2Backend`, request, store per radio, restore on connect, and the bridge verb; on the real Icom, ANAN, RTL-SDR and demo backends, each with a `ClientDisplay` document that already holds the flag, nothing is restored, the request declines and the document is untouched; the same on a real `FlexBackend` (the echo still works) and on a backend that engages the record with the field false; the bridge verb's radio-echo note on Flex and on the demo; the store's refusals. `radio_setup_show_tx_waterfall_test` (new, production dialog, real backends): on an HL2 a click sets the model and sends no wire text; on an Icom, which declares no owner, and on a Flex the click sends the wire text and the model is not set. No source-text pin. Fail-before, one build and one run per row, failing checks in the two tests: | mutation | client flag test | dialog test | |---|---|---| | this branch | 0 | 0 | | `main`'s behaviour (request declines, connect restores nothing) | 11 | 1 slot | | the request does not write the model | 5 | 1 slot | | the connect edge does not restore | 1 | 0 | | the request is taken on a Flex too | 4 | 1 slot (Flex) | | the request does not store | 3 | 0 | | the button sends the wire text unguarded | 0 | 1 slot | | the bridge verb skips the local route | 2 | 0 | | the store ignores who owns the flag on read | 1 | 0 | | the ownership accessor is true for every backend | 40 | 2 slots | | the ownership accessor is false for every backend | 15 | 1 slot | The two accessor rows were measured at `ea2cf144`, one build and one full run each. The other rows were measured at earlier heads, before the opt-out checks existed, and were not re-run. Full ctest, macOS, Qt 6.12.0, at `ea2cf144`: 644 of 645 pass. The one failure is not ours; see below. Static checks (`static-checks.yml`, stdin from `/dev/null`): clean. `check_a11y` on `RadioSetupDialog.cpp`: 9 warnings, the same 9 as `main`, none on lines this PR touches. `check_capability_records`: 70 booleans, frozen at 70. The steps were run from this branch's `static-checks.yml`; `main`'s newer `check_build_options` steps are not on this branch and were not run. ## With the open PRs - #5924, #6117 and #6115 are merged and in this branch. #5919 is merged into `main` and is not in this branch. - #6140 (open, `7a5ca748`) adds its own records to `RadioCapabilities.h`; a trial merge with this head is clean and the merged header still counts 70 booleans. A trial merge with `main` `85f2a124` is clean. Neither merge result was built. ## Not verified - Anything keyed; see Limits. - Linux and Windows at this head. ANAN, Icom and RTL-SDR on a radio: they keep `main`'s behaviour by declaration, checked on their real backends, never connected. - A running app at this head, the demo included. - That the main window hands a restored flag to the spectrum widget. **Known red on `main`:** `peripheral_auth_dialog_test` (#6008) fails on this Mac at `ea2cf144` with the diagnostic it prints on clean `main` at `22507580` (pasted on #6134): "Clear code or Status is clipped at the default dialog size", `dialog 960x680, viewport 658, page minimum 784, horizontal scroll max 126` (a serial-port name on this host widens the page). Clean `main` was not re-run at `85f2a124`. `hl2_pan_create_async_test` and `vkamp_connection_test` passed here. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
Merge order for the PGXL / 4O3A PRs (the same map is in each of them; #6030 has merged):
Summary
Fixes #2313. Authenticate TGXL, PGXL, and Antenna Genius direct TCP sessions when a device's greeting requests
AUTH; keep ordinary commands gated until the device accepts the code. Use the existing manual host/port fields in Setup → Peripherals and restore distinct, endpoint-bound codes from QtKeychain on reconnect. Builds without QtKeychain retain a code for the current session only. Keep TGXL/PGXL radio-relay fallback available when the direct path cannot authenticate, and show the actual connection path in the accessory applets.Target
mainatv26.9.5. This branch started at the preceding commit; the sole overlapping release file isCMakeLists.txt, and this branch's CMake change applies cleanly to the release version.docs/PR-WORKFLOW.mddoes not require a rebase for a stale PR branch.The auth command and success-reply forms are device-specific. A live probe confirmed TGXL and PGXL forms on the firmware listed in
docs/4o3a-remote-auth-hardware-evidence.md; the AG syntax follows 4O3A's public API documentation and uses CRLF, which the contributor capture confirms reaches the AG auth parser. New code is saved only after an AUTH success reply. A code typed for another target, rejected, or never challenged cannot silently replace a working saved code.Implementation details
tools/probe_4o3a_auth.py, a permanent one-shot, non-TX hardware handshake probe; it prompts for the code without echo and records no credentials.Constitution principle honored
docs/4o3a-remote-auth-hardware-evidence.md. The report separates observed hardware behavior from test-only and still-unverified behavior; CI and operator confirmation remain gates after submission.UI Changes
● DIRECT/● RADIO/● OFFLINEindicator in docked and floating layouts. DIRECT uses the theme success color, RADIO the warning color, and OFFLINE the disabled-text color. A blocked direct-connection reason appears in the tooltip and accessible description, and clears on manual retry or successful direct connection.● DIRECT/● OFFLINEindicator with success/disabled theme colors and accessible text. It remains OFFLINE before a direct AG connection; the shared ShackSwitch model does not make it display DIRECT.Maintainer rulings: the central Peripherals tab (rather than per-applet fields), the compact Setup layout, the applet source indicators, and credential binding (configured host+port for names, peer IP+port for literal addresses) are accepted as built. So is a saved manual host taking precedence over the radio-reported endpoint, with the fallback described under Automatic connection behavior.
Test plan
cmake --build build --parallel 6) on macOS 27.0/arm64 using the branch's 26.9.4 source snapshot.docs/4o3a-remote-auth-hardware-evidence.md.Earlier submission verification: 37 passed, 1 skipped across the peripheral auth dialog, handshake, and Keychain tests; automation redaction tests; TGXL/PGXL applet tests; and AppSettings safety scenarios.
pgxl_panel_testskipped because this sandbox could not bind loopback; its result is not a pass claim. PR-REVIEW preflight identified that the earlier dialog regression clicked Connect against reserved IPs and opened outboundQTcpSockets. That check now uses an injected callback at the production connect-ordering seam and its focused test passes socket-free. A mutation that kept the old pending flag set during the callback made the revised test fail; restoring the fix made it pass. The modifiedpgxl_panel_testis an existing default-graph loopbackQTcpServertest; it was not rerun during this review after operator notification. Its bind failure returns 77.git diff --check, test registration, the aetherd touchpoint manifest, engine-boundary, capability-record, and command-plane baselines completed with no new blocker; the accessibility scan reports existing warning-only findings. An isolated, TX-disabled offscreen app launched, but its automation bridge did not expose a socket, so no demo GUI claim is based on bridge output.Live evidence and limits: the one-command TGXL and PGXL probes received accepted AUTH replies on firmware 1.2.17 and 3.9.1, respectively. A later app build established direct TCP sockets and showed bidirectional traffic, but that socket snapshot did not capture the in-app AUTH exchange. AG hardware AUTH and the full in-app Keychain restore/clear cycle remain unverified.
Checklist
docs/COMMIT-SIGNING.md) — feature commit120ed439has a locally verified SSH signature registered tow5jwp.AppSettingscalls — existing manual IP/port keys are reused and credentials use endpoint-bound Keychain records (Principle V).MeterSmoother— not applicable to this PR; it changes connection labels/layout, with no new meter data or smoothing path.docs/4o3a-remote-auth-hardware-evidence.md;CHANGELOG.mdremains untouched.Review routing: source/tests/docs need Tier-3 code-owner review;
CMakeLists.txtneeds Tier-2 infrastructure review. The issue carriesmaintainer-review, and the UI/UX decisions above need the project maintainer's approval. The required issue-claim attempt was rejected by GitHub becausew5jwplacks upstream assignment permission; no placeholder assignee was created.Current review dispositions
OKbody. Unknown/rejection bodies and extra fields cannot accept a credential. The test writer is private test-access plumbing; the production constructor accepts only its QObject parent.V<version> AG AUTH\n, LF from the vendor utility, and CRLF from AetherSDR reaching the auth parser. AUTH now uses CRLF. The report also says the utility silently truncates passwords to eight characters; operators must enter the actual device code. AetherSDR does not silently truncate passwords. This is attributed contributor evidence, not a new successful AG hardware test; the report names no firmware version.Automatic connection behavior: saved TGXL/PGXL manual host/port settings take precedence over radio-discovered endpoints. Loss of radio-reported accessory presence does not disconnect the direct session while a manual override is configured. This also affects LAN users with a saved override; clearing it restores discovered-address selection. When an automatic connect (discovery, presence, startup, or their reconnects) to the saved address never reaches the device over TCP, the app tries the radio-reported address once, on the default port, as an alternate attempt. Reconnects keep aiming at the saved host, a code saved for the saved host is never sent to the alternate address, and a missing code there fails that try without blocking reconnects. An explicit Setup → Connect, a deliberate disconnect, or an attempt whose socket connected never triggers it.
checkUnreachableAndAlternateinperipheral_auth_handshake_testpins each rule.Credential identity (
907a88e8): a saved code is bound to the name the operator typed. A hostname (typically DDNS) binds the lowercased name and port, so a change of the residential IP behind it no longer asks for the code again, which is the main remote use case of #2313. A literal IP binds the connected peer IP and port, as before. Nothing binds until a socket has a real peer address, and a different name, port, or a literal IP is a different identity, so a code saved for one is never offered to another. The trade is deliberate: whatever the name resolves to receives the saved code, so a code should only be saved for a name the operator controls; the store header anddocs/4o3a-remote-auth-hardware-evidence.mdsay so. A code saved earlier for a hostname was stored in the IP form and is not reused, so those operators enter it once more. Tests cover name equality across IPs, case and whitespace, the empty-peer and zero-port refusals, and that another name, port or literal IP does not match. This was raised as a question on the review; if the maintainer prefers peer-IP-only binding, it reverts by dropping this one commit. Central-tab placement, the compact layout, and applet indicator design still await maintainer direction.Latest local verification (2026-09-29): the desktop app builds, and all eight focused auth, automation-redaction, TGXL and amp applet tests passed. Mutation checks removed TGXL/PGXL auth-block reconnect guards, reverted AG AUTH to bare CR, and restored the wide Setup table: each failed at its intended regression assertion. All fixes were restored and the eight tests passed again. Test registration, touchpoint manifest, engine-boundary, capability-record, command-plane, colour ratchet, and whitespace checks found no new blocker. No new synthetic device peer, live-hardware, OS-vault, or TX test was introduced or run. The existing loopback
pgxl_panel_testwas not rerun.Verification of the credential-identity commit
907a88e8(2026-09-30): the desktop app builds, and the ten focused tests (peripheral auth dialog, keychain and handshake; automation redaction; TGXL and amp applet) pass. The new handshake-test assertions cover name equality across IPs, case and whitespace, the empty-peer and zero-port refusals, and that another name, port or literal IP does not match. I did not run a mutation check against it, and it has not been tried against a live device or a real DDNS name.Self-assignment was attempted again; GitHub denied it because
w5jwplacks upstream assignment permission. Review threads are left for the reviewers to resolve.Review follow-up (2026-10-03)
setAuthCodeForAttemptpassesblockReconnect=falsewhen the credential store is unavailable (TGXL, PGXL, Antenna Genius). A rejected or missing code still blocks.setupappendsauthcode=only whensetup readcontained theauthcodekey and it was empty. The firmware 3.9.8R4|50000013|probe result is indocs/4o3a-remote-auth-hardware-evidence.md.authcode.peripheral_auth_handshake_test, through a command-writer seam onPgxlConnection.origin/main(732b99a9).