Skip to content

feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections - #6008

Merged
ten9876 merged 14 commits into
aethersdr:mainfrom
w5jwp:tgxl-pgxl-remote-auth
Oct 3, 2026
Merged

ten9876 merged 14 commits into
aethersdr:mainfrom
w5jwp:tgxl-pgxl-remote-auth

Conversation

@w5jwp

@w5jwp w5jwp commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Merge order for the PGXL / 4O3A PRs (the same map is in each of them; #6030 has merged):

Stage 1:
  #6008  authenticate TGXL, PGXL and Antenna Genius
Stage 2 (either order, after what each needs):
  #6027  Peripherals settings UI          needs #6008
  #6033  PGXL drain current (ID meter)    needs #6008
Stage 3:
  #6036  PGXL docked layout               needs #6008 and #6033

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 main at v26.9.5. This branch started at the preceding commit; the sole overlapping release file is CMakeLists.txt, and this branch's CMake change applies cleanly to the release version. docs/PR-WORKFLOW.md does 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

  • Gate the TGXL, PGXL, and AG initialization/status paths on AUTH completion; reject malformed/oversized lines and invalid code characters before constructing a line-oriented command.
  • Keep one Keychain record per device type with the accepted IP/port and code together. Never write a new plaintext password field into AppSettings; retain the existing manual IP/port settings. A Keychain read failure stays visible, and a failed Clear operation does not claim the code was deleted.
  • Bound authentication attempts and suppress automatic retries after a blocking failure. Manual AG/ShackSwitch retries reset only the selected target's failure budget. An attempt token ignores late Keychain replies from an abandoned connection.
  • Redact credentials from PGXL setup logging, sensitive automation text extraction, and UI grabs. Register the new auth logging category and add focused socket-free credential, handshake, automation, and applet coverage.
  • Regenerate the aetherd touchpoint manifest for the new peripheral validation header; add the credential-store source to the desktop-support target. The new GUI→engine validator touchpoint keeps one input rule in the Keychain adapter and TCP clients before a line-oriented command is built. The manifest's AppSettings includer count grows by one for the session-only credential fallback in the Keychain adapter. ThemeManager grows by one because the extracted dialog connect-flow header uses the same token-based error style as the dialog; that header exposes a socket-free regression seam for target-switch ordering.
  • Add 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

  • Principle IV — Every Contribution Is Clean-Room: the TGXL and PGXL handshake code is grounded in a redacted live probe and published 4O3A documents; AG follows the published 4O3A API plus the contributor capture cited below. No proprietary binary was disassembled or translated.
  • Principle V — Each Feature Owns Its Configuration As A Single Object: no new flat AppSettings credential writes were added. Existing manual connection settings remain as they were; each stored credential is a single Keychain record for its device type, bound to the hostname and port the operator typed, or to the connected peer IP and port for a literal address.
  • Principle VII — Untrusted Input Is Validated At The Boundary: codes are checked before being interpolated into commands, incoming line buffers are bounded, and malformed/failed AUTH replies do not make a direct connection available.
  • Principles VIII and XI — Evidence Over Assertion / Fixes Are Demonstrated: the probe output, focused tests, and the pre-change Tune routing record are in 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

  • Setup → Peripherals, TGXL/PGXL/AG rows: added separate masked Authorization code fields, Show/Hide buttons, Clear code buttons, endpoint-specific reuse/session-only placeholder text, and screen-reader names and descriptions. A blank field reuses the matching saved code when an AUTH greeting arrives; a new accepted code replaces that device type's saved record.
  • Setup status cells: show connecting, rejected/required code, Keychain deletion failure, and session-only save warnings. They distinguish an unchallenged connection (code not saved) from a code discarded before verification, and clear stale errors after a recovered connection. The AG and ShackSwitch rows display their own connection errors.
  • TGXL applet: added a bottom-right ● DIRECT / ● RADIO / ● OFFLINE indicator 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.
  • PGXL applet: moved its connection indicator below the controls to the same bottom-right placement and control-to-indicator spacing as TGXL. It uses the same three states, colors, tooltip, and accessibility behavior; RADIO requires a connected radio and PGXL relay handle.
  • Antenna Genius applet: added a bottom-right ● DIRECT / ● OFFLINE indicator 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.
  • Setup layout: each device keeps address/port/connect on its first line, authorization controls on the next, and wrapped status across the available width. Clear code and errors fit at the default 960×680 size without horizontal scrolling. ShackSwitch Web UI occupies its own line; its authentication errors are named as ShackSwitch in the main status bar.
  • Automation-visible UI: masked the code field's value and its grab output so remote automation does not expose a shown code.

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

  • Local build passes (cmake --build build --parallel 6) on macOS 27.0/arm64 using the branch's 26.9.4 source snapshot.
  • Behavior verified on a real radio if applicable — a prior operator Tune action showed the FlexRadio accepting a relayed TGXL autotune command; a full in-app saved-code restore has not yet been observed.
  • Existing tests pass (CI) — rerun required for the latest review-fix commit.
  • Reproduction steps documented for the pre-change direct-TGXL Tune routing problem in 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_test skipped 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 outbound QTcpSockets. 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 modified pgxl_panel_test is an existing default-graph loopback QTcpServer test; 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

  • Commits are signed (docs/COMMIT-SIGNING.md) — feature commit 120ed439 has a locally verified SSH signature registered to w5jwp.
  • No new flat-key AppSettings calls — existing manual IP/port keys are reused and credentials use endpoint-bound Keychain records (Principle V).
  • Code is clean-room — vendor/public API documents and observed wire behavior, with no decompiled or disassembled proprietary code (Principle IV).
  • All meter UI uses MeterSmoother — not applicable to this PR; it changes connection labels/layout, with no new meter data or smoothing path.
  • Documentation updated for user-visible behavior in docs/4o3a-remote-auth-hardware-evidence.md; CHANGELOG.md remains untouched.
  • Security-sensitive changes reference a GHSA if applicable — no GHSA applies to this feature request.

Review routing: source/tests/docs need Tier-3 code-owner review; CMakeLists.txt needs Tier-2 infrastructure review. The issue carries maintainer-review, and the UI/UX decisions above need the project maintainer's approval. The required issue-claim attempt was rejected by GitHub because w5jwp lacks upstream assignment permission; no placeholder assignee was created.

Current review dispositions

  • TGXL/PGXL socket-free regression coverage now enables automatic reconnect at a nonempty target and asserts that the named reconnect timers stop after explicit AUTH rejection or three timeouts/handshake disconnects. Late disconnect/error delivery must not re-arm them.
  • AG requires sequence 1, zero result, and an omitted/empty or exactly OK body. 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.
  • The contributor capture on #2313 reports 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.
  • Failed Keychain deletion retains the cache; a completed older deletion cannot erase a newer save. AG retry history ignores empty targets and is bounded to 128 targets without evicting existing blocks.
  • The earlier six CodeGuard advisories concern a protocol comment and unchanged MainWindow code; they do not identify a new path operation requiring a fix.

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. checkUnreachableAndAlternate in peripheral_auth_handshake_test pins 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 and docs/4o3a-remote-auth-hardware-evidence.md say 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_test was 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 w5jwp lacks upstream assignment permission. Review threads are left for the reviewers to resolve.

Review follow-up (2026-10-03)

  • A keychain outage no longer blocks auto-reconnect: setAuthCodeForAttempt passes blockReconnect=false when the credential store is unavailable (TGXL, PGXL, Antenna Genius). A rejected or missing code still blocks.
  • setup appends authcode= only when setup read contained the authcode key and it was empty. The firmware 3.9.8 R4|50000013| probe result is in docs/4o3a-remote-auth-hardware-evidence.md.
  • The PGXL status debug log omits authcode.
  • The authcode-omission assertion is a socket-free case in peripheral_auth_handshake_test, through a command-writer seam on PgxlConnection.
  • Manual-address fallback: see Automatic connection behavior above.
  • Merged origin/main (732b99a9).

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue fit

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, not tests.cmake: the dialog test only clicks Connect on the invalid-code path (which returns before connectFn) and otherwise drives beginAttemptAt / processLine / processTcpBytes through the Q_INVOKABLE seams; the keychain test uses an in-memory QKeychain double via tests/fakes/qt6keychain/. No bind(), listen() or connectToHost() in any of them. All three are registered and run by default.
  • pgxl_panel_test — modified, and it is a pre-existing registered loopback QTcpServer test. The PR did not add the socket; it added a tests.cmake comment 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|Unauthorized for 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:516 still requires line.contains("AG"), and the new AUTH branch sits inside it. The only AUTH greeting this PR actually observed is TGXL's V1.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 feed V4.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 named m_authTimeouts while onDisconnected also 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() clears g_entries[...] before queuing the DeletePasswordJob, so after a failed Clear the session reports Missing while 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, and failAuthentication will then m_authBlockedTargets.insert(""), which makes isAuthBlockedFor() true for every other malformed target. Unreachable through the current call sites; cheap to guard.
  • m_authTimeoutsByTarget / m_authBlockedTargets grow 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.cmake agree.

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 OK and PGXL's on R1|0|Authorized both survive R1|0|Unauthorized; AG does not. That is the blocker above.
  • abort() re-entrancy. failAuthentication clears m_authPending before m_socket.abort(), so the synchronous onDisconnected cannot double-count the attempt as a "closed during authentication" failure, and the !m_reconnectTimer.isActive() guard prevents the double re-arm. connectToTgxl/connectToPgxl correctly wrap the abort in m_deliberateDisconnect and emit disconnected() 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_authAttempt in beginAttempt() plus the attempt == m_authAttempt && m_waitingForAuthCode gate in setAuthCodeForAttempt closes it, and PeripheralAuthStore::load holds a QPointer context so a torn-down window drops the callback. Held.
  • Carrying a typed code to the wrong host. beginAttemptAt compares the normalised target and discards + signals; sendAuthentication re-checks m_userAuthEndpoint == m_attemptEndpoint for 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 are m_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. No sendCommand() was added, and the unconditional emit connectionFailed removed from TgxlConnection::onError has no consumer on main outside this PR's new wiring — I grepped.
  • Credential leakage. widgetValue now routes through automationLineEditValue, the grab mask flips revealed fields to Password for the duration of the capture and restores via QPointer, and sendCommand redacts any authcode= setup write. The mask's double-negative (!sensitive && echoMode == Normal → skip) is correct. No plaintext path into AppSettings — the six reserved names are in SettingsCredentialPolicy.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

Comment thread src/models/AntennaGeniusModel.cpp
Comment thread src/models/AntennaGeniusModel.cpp
Comment thread tests/peripheral_auth_handshake_test.cpp
Comment thread src/core/PeripheralAuthCode.h Outdated
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (6 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/core/TgxlConnection.h /tmp/aetherclaude/pr-6008/src/core/TgxlConnection.h:19
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6008/src/gui/MainWindow.cpp:9549
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6008/src/gui/MainWindow.cpp:9661
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6008/src/gui/MainWindow.cpp:9780
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6008/src/gui/MainWindow.cpp:9781
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6008/src/gui/MainWindow.cpp:9782

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

@w5jwp

w5jwp commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

@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 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Issue fit

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 in PgxlConnection.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_reconnectTimer an object name
  • call setAutoReconnect(true)
  • inject V1.2.17 AUTH + R1|0|Unauthorized, then assert isAuthBlocked() 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)

  1. AG auth uses a bare \r terminator, but the only live AG capture on #2313 shows \n (inline on src/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 shows C<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 real R<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\n satisfies 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 at peripheral_auth_handshake_test.cpp:54,435,611 with it.
  2. The same capture settles the previous review's contains("AG") concern. It records the WAN greeting as V<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.
  3. 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.
  4. Undisclosed auto-connect change (inline on src/gui/MainWindow_Wiring.cpp:6452). Tuner/amp presence now connects to TGXL_ManualIp/PGXL_ManualIp before 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.
  5. 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.
  6. AntennaGeniusModel's production constructor gains a std::function whose only purpose is to support tests. The TGXL/PGXL tests reach the same seam through Q_INVOKABLE without 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, and amp_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
  • 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) on main and on the merge. They show identical finding sets apart from line-number shifts in AutomationServer.cpp (102 findings each). The manifest diff is exactly the three rows the body justifies.
  • Bridge session: isolated settings, offscreen, attached to DEMO-0001 and confirmed get radio → AetherSDR Demo. I opened Setup → Peripherals, typed a TGXL code, and clicked Show. dump_tree still returned "value": "<hidden>", and grab_widget rendered 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, PGXL setup writes 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).

Comment thread src/core/TgxlConnection.cpp
Comment thread src/core/PeripheralAuthCode.h Outdated
Comment thread src/gui/MainWindow_Wiring.cpp
Comment thread src/gui/PeripheralAuthStore.cpp Outdated
Comment thread src/gui/RadioSetupDialog.cpp
@w5jwp

w5jwp commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@aetherbot @ten9876 Please re-review PR #6008 at 601b4b4. The September 29 blocker and actionable nits are addressed:

  • Retry coverage: TGXL/PGXL now expose named reconnect timers to the socket-free test. With auto-reconnect enabled and a nonempty target, explicit rejection and the third timeout/handshake disconnect must leave the timer inactive. Late disconnect/error delivery cannot re-arm it. Removing the auth-block reconnect guards now fails the test.
  • AG framing/evidence: AUTH uses CRLF. The evidence doc cites the contributor capture for AG AUTH, vendor LF, AetherSDR CRLF reaching the parser, and the utility's eight-character truncation. This is attributed existing evidence, not a newly verified AG handshake. The production constructor no longer accepts the test-only writer.
  • Clipping: address/connect, authorization controls, and wrapped status now occupy separate lines. The default 960×680 dialog regression includes all peripheral rows and verifies Clear/status fit without horizontal scrolling, including a failed-clear error. Reverting the layout fails that assertion.
  • Behavior disclosure: the PR body now explicitly states manual host/port precedence and that presence loss retains a manually configured direct connection.

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 w5jwp lacks upstream assignment permission. Review threads remain open for the reviewers to resolve.

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

w5jwp commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

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. AmpModel::writeSetupGroup writes back the whole setup group, including the authcode it read from setup read. With authorization enabled on firmware 3.9.8, setup read returns the real code, and the PGXL refuses any setup that carries it. The app log showed six command … refused, code "50000013" in a row, one per fan change.

Probe (PGXL firmware 3.9.8, authorization enabled, authenticated session). Each write used the amplifier's current values, so an accepted write changed nothing:

Command Reply
setup fanmode=CONTEST `R2
setup nickname=… meffa=AUTO ledintens=74 fanmode=CONTEST `R3
same + authcode=<code> (what the app sent) `R4

Afterwards status was unchanged, and a fresh session authenticated with the same code, with setup read reporting it unchanged. Leaving authcode out does not clear or alter the stored code.

Fix. The write leaves out authcode once a code is set. With no code set, it still sends authcode= empty, byte for byte what the vendor utility sends to firmware without authorization.

Tests. pgxl_direct_protocol_test now feeds a setup read carrying a code, and checks that a fan-mode change and a MEffA toggle both go out without authcode. Restoring the old write makes it fail 4 checks. pgxl_direct_protocol_test, amp_model_test, pgxl_panel_test, amp_applet_test and peripheral_auth_handshake_test pass.

Verified in the app. After the fix, no refusals were logged, and the PGXL reported the fan mode selected in AetherSDR (fanmode=BROADCAST, from CONTEST).

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.

w5jwp added a commit to w5jwp/AetherSDR that referenced this pull request Sep 30, 2026
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>
w5jwp added a commit to w5jwp/AetherSDR that referenced this pull request Sep 30, 2026
…inciple VII.

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

# Conflicts:
#	tests/fakes/PeripheralAuthStoreFake.cpp
w5jwp added a commit to w5jwp/AetherSDR that referenced this pull request Sep 30, 2026
…) into the docked layout. Principle XI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
w5jwp and others added 4 commits October 1, 2026 11:16
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…Principle XI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rge. Principle XI.

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

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 write setup nickname=%1 meffa=%2 ledintens=%3 fanmode=%4 authcode=%5 with m_setupAuthCode. Take this PR's side. Taking main's side re-sends the code and brings back the 50000013 refusal 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_test fails 5 checks.
  • src/gui/MainWindow_Wiring.cpp (~L6212). Keep both sides: main's #6044 TunerModel::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. 983d743c already 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-6476 for TGXL, :6622-6634 for PGXL). At the merge base, presence connected to tunerModel().tgxlIp() and presence loss disconnected. Now a saved TGXL_ManualIp is used whenever present, and presence loss leaves that session up. The body discloses this. Two concrete consequences:

    1. A LAN user who once saved 192.168.1.50 loses direct control when DHCP moves the TGXL to .51, and nothing falls back to the address the radio reports.
    2. 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.

  • 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.
    • setAuthCodeForAttempt calls failAuthentication("Stored authorization code unavailable") with the default blockReconnect = true (src/core/TgxlConnection.cpp:65, .h:112; same in PgxlConnection.cpp:65 and AntennaGeniusModel.cpp:111).
    • Failure: if the keyring is locked or denied when the AUTH greeting arrives, m_authBlocked is set. Every auto path checks isAuthBlocked(), so the station never recovers after the keyring unlocks; only Setup → Connect does.
    • That is the unattended WAN case #2313 is about. Suggest passing false on the unavailable path. Reasoned from code.
  • N2. src/models/AmpModel.cpp:250 treats a missing authcode key like an empty one. kvs.value("authcode") is empty either way, so a firmware that leaves the key out of setup read while auth is enabled gets authcode= on every write. Appending it only when kvs.contains("authcode") would follow what the device actually said. Also, the 3.9.8 R4|50000013| probe exists only in a PR comment. docs/4o3a-remote-auth-hardware-evidence.md should carry it (Principle VIII).
  • N3. src/gui/MainWindow_Wiring.cpp:6638 logs "PGXL status:" << kvs unredacted. On an authenticated session setup read contains authcode=<real code>. Today only the seq match in PgxlConnection keeps it out of this log; dropping authcode before 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. Emitting setupRead and capturing the setup line works without the loopback peer, the same way peripheral_auth_handshake_test does.
  • 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-246 retells 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) and 0ef90af5 (#6027) on Linux.
  • Ran the 13 focused targets on both heads, 13/13 passing: the three peripheral_auth_* tests, both automation_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 made pgxl_direct_protocol_test fail 5 checks, so the guard is real.
  • Gates: check_engine_boundary --strict, gen_touchpoint_manifest --check, check_capability_records --strict and check_command_plane --strict on the merge base and on this head. Both are flat at 102 findings (line shifts in AutomationServer.cpp only). 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-0001 only (get radio → "model":"AetherSDR Demo"). I typed a TGXL code and clicked Show. invoke setText echoed "newValue":"<hidden>", dumpTree never contained the digits, and grab of 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 with merge-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 one readyRead plus the 64 KiB cap, and overflow aborts the socket.
  • 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-PR ctest is -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.

w5jwp and others added 4 commits October 3, 2026 08:37
…. 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>
jensenpat pushed a commit that referenced this pull request Oct 10, 2026
…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>
jensenpat pushed a commit that referenced this pull request Oct 10, 2026
#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>
jensenpat pushed a commit that referenced this pull request Oct 10, 2026
…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>
jensenpat pushed a commit that referenced this pull request Oct 10, 2026
…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>
jensenpat pushed a commit that referenced this pull request Oct 10, 2026
…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>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
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>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
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>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
…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>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
#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>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
#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>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
…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>
rfoust pushed a commit that referenced this pull request Oct 12, 2026
…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>
rfoust added a commit that referenced this pull request Oct 12, 2026
…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>
rfoust added a commit that referenced this pull request Oct 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth Code / password field for remote TGXL, PGXL and Antenna Genius connections without VPN

2 participants