Skip to content

ui(peripherals): organize device settings with Add/Remove - #6027

Merged
ten9876 merged 12 commits into
aethersdr:mainfrom
w5jwp:peripherals-settings-update
Oct 4, 2026
Merged

ten9876 merged 12 commits into
aethersdr:mainfrom
w5jwp:peripherals-settings-update

Conversation

@w5jwp

@w5jwp w5jwp commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Merge order: #6008 has merged (4d5b9893). This PR is replayed onto main (tip 5fae2f86 when pushed) and no longer carries any #6008 commits.

Summary

Closes #6026. Radio Setup → Peripherals shows a persisted device list and a per-device detail page, with Add, Remove, and a per-device automatic-connection toggle (Connect directly when available on TGXL and PGXL, Connect automatically on Antenna Genius and ShackSwitch). This implements the ruling on #6026 ("approved, with conditions"); the checklist below maps each condition to its commit.

Ruling checklist

# Condition Where
1 Per-device builders, no grid, no reparenting, no itemAtPosition; list wording DIRECT / RADIO / OFFLINE 1d96a707
2 Add creates and selects the row, nothing else feaaefe8
3 Remove asks for confirmation; outcome table in this body and in the user doc feaaefe8, a8300ace
4 Per-device automatic-connection toggle; Remove does not dismiss; no DiscoveryDismissed key feaaefe8, da7df5c7
5 PeripheralSettings.h note corrected feaaefe8
6 AG/ShackSwitch endpoint-scoped teardown and tests kept replayed in 06a20738
7 VisibleDevices seeding, AG reconciliation and refresh follow signals; the 250 ms timer is gone 1d96a707
a The detail page's status line is always visible and is the accessible state 1d96a707
b A disabled Add entry shows and speaks its reason ("(already added)") 1d96a707
c Docs describe the feature as it is a8300ace
d Status is carried explicitly (PeripheralDeviceStatus); nothing searches text for Error: or a dash 1d96a707
e The #6026 reply and this PR's state agree: ready for review this body

Also from the 2026-10-02 and 2026-10-03 reviews: the removal lease now stops every route to a socket (explicit, automatic, alternate, reconnect timer), checked before attempt state changes (06a20738, 38931177); a blocked recovery row shows the endpoint that refused the code; an address saved from the AG applet while a deletion is pending survives the Remove; an AG or ShackSwitch row with no address can be removed beside the other device's stored code; a disabled Remove button reads as disabled (37baf0e1).

Connecting automatically

TGXL, PGXL, Antenna Genius and ShackSwitch each have a toggle, on by default, stored as Peripherals.<id>.AutoConnect. Off blocks startup, discovery/radio-presence, alternate-address and reconnect attempts for that device. An explicit Connect always works and does not change the toggle; a session it starts is not reconnected after it drops.

  • TGXL and PGXL: Connect directly when available. Off, the device is controlled through the radio's relay whenever the radio offers one (the applet shows RADIO); a live direct session continues until it drops.
  • Antenna Genius and ShackSwitch: Connect automatically. No relay exists, so off leaves the device offline until Connect.

Every path that opens a socket emits attemptStarted() on TgxlConnection, PgxlConnection and AntennaGeniusModel, so Setup shows Connecting… for automatic attempts as well. The authorization-blocked recovery row shows the endpoint that refused the code unless the operator has edited the field.

Remove outcomes

Outcome Configuration Credential Reconnection Recovery
Success Settings and row removed; toggle back to default Stored code deleted Guard released; discovery may connect the device again unless its row is kept with its toggle off Add re-creates the row
Failed (denied or error) Kept; row stays Saved code remains; error shown on the row Connection stopped, guard released; normal reconnect events may connect it again Retry Remove
Unconfirmed (15 s timeout), then late completion Kept; the row's pending field edits are not saved Deletion unconfirmed Guard held until the request returns, then released; a late completion changes no row or newer setting The Peripherals page stays disabled in that Setup window. Close Setup and reopen it to retry; restart the app if the backend never returns
No credential backend Same as success Session copy cleared; stored-code deletion unconfirmed, with a notice saying so Same as success Remove the code from the OS vault once it is available
Unknown owner (Antenna Genius, ShackSwitch) Kept Untouched; the owning endpoint is not known Connection stopped, guard released Connect the device to establish its address, then retry Remove
An earlier removal still pending Unchanged Unchanged; the earlier request owns the slot Remove and Connect are refused with a notice Retry after the keychain request finishes

Scope

Peripherals UI and the persistence, credential-store and connection-lifecycle changes it needs. No authentication protocol, dependency, thread, radio family or TX command is added. TGXL/PGXL/AG/ShackSwitch endpoints keep their flat keys; no migration. The three applets now take their DIRECT / RADIO / OFFLINE strings from PeripheralConnectionSource.h (strings unchanged). Touchpoint manifest regenerated.

Test plan

  • Desktop build on Linux; peripheral_auth_dialog_test, peripheral_auth_handshake_test, peripheral_auth_keychain_test, both automation-redaction tests, tgxl_*, amp_applet_test and the radio_setup_* tests pass. pgxl_panel_test fails on unchanged main too (a layout height assertion) and is unrelated.
  • Mutations fail their tests, then pass restored: the removal guard removed from the socket-opening path; the Connect automatically gate removed from ShackSwitch discovery; Remove no longer resetting the toggle; the compare-and-clear of a newer endpoint; the shared-slot ownership check.
  • gen_touchpoint_manifest.py --check, check_engine_boundary.py --strict (0 blocking), check_a11y.py on the new file (0 findings); grep itemAtPosition src/gui/RadioSetupDialog*.cpp is empty.
  • CI on the final commit.
  • Driven on the demo backend (isolated settings, DEMO-0001): Add persists across a restart; the toggles read "Connect directly when available" (TGXL, PGXL) and "Connect automatically" (AG, ShackSwitch); with the AG toggle off a discovered Antenna Genius stays OFFLINE through repeated beacons (with it on, it connects); Remove asks first and Cancel keeps the row.
  • Not verified: a real OS vault, ShackSwitch/AG hardware, native accessibility on Windows/macOS. The focused tests are not in any ci.yml -R filter.

CI: the focused tests above are not in any ci.yml -R filter, so green CI does not mean they ran; that filter set is frozen per PR and is left to the maintainer.

🤖 Generated with Claude Code

w5jwp added a commit to w5jwp/AetherSDR that referenced this pull request Sep 29, 2026
…ciple XI.

Address aethersdr#6026 triage for aethersdr#6027: allow closing after a 15-second unconfirmed deletion timeout, retain the vault-owned reconnect lease, and ignore late UI completion. Document that legacy endpoint-key migration is out of scope.
@w5jwp
w5jwp marked this pull request as ready for review September 29, 2026 20:45
@w5jwp
w5jwp requested review from a team as code owners September 29, 2026 20:45

@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 — against RFC #6026 and #6026's acceptance criteria, the UI-only commits (0063b943, d8aad51e) do what they claim, and I could not break the two hardest parts: the reconnect lease and the bounded close. Every connectTo* entry point is gated (three funnels, no bypass — see "What I tried to break"), and checkRemovalTimeout is a real regression, not a tautology. One acceptance criterion does not hold as written: "Remove reports pending, successful, failed and unconfirmed credential cleanup accurately" and "Removing ShackSwitch preserves a live AG session" — the ShackSwitch row never asks the vault to delete anything, and the AG row deletes a slot the ShackSwitch may own (finding 1).

The layout/workflow decision itself is correctly routed: #6026 carries maintainer-review and states approval is outstanding. I am not treating the redesign of the existing Peripherals table as smuggled preference — it went through an RFC, which is exactly what GOVERNANCE.md asks for. It is a maintainer call, not a blocker.

Scope

Group What it changes Claimed by this PR? Verdict
docs/peripherals-settings-ui.md, docs/architecture/aetherd-touchpoint* new workflow doc; manifest 230→232, AppSettings 103→105, ThemeManager 156→157 yes In scope; counts match the tree
src/core/PeripheralSettings.h VisibleDevices, DiscoveryDismissed, clearDeviceConnection in the existing nested Peripherals doc yes In scope. No new flat keys — Principle V satisfied
src/core/PeripheralRemovalGuard.h, guard checks in TgxlConnection.cpp:82 / PgxlConnection.cpp:81 / AntennaGeniusModel.cpp:316, m_connectTransport seams transient reconnect lease + socket-free injection yes In scope
src/gui/RadioSetupDialog.{cpp,h} (+1139 lines) list/detail, Add/Remove, pending + 15 s timeout, AG reconciliation yes In scope, pending the #6026 ruling
src/gui/PeripheralAuthStore.* delete-completion / cache-retention handling yes In scope
src/models/AntennaGeniusModel.* deferred ShackSwitch request/retry yes In scope
src/gui/MainWindow_Wiring.cpp discoveredTarget() on the three discovery fallbacks yes In scope
tests/peripheral_auth_dialog_test.cpp (+1349), peripheral_auth_keychain_test.cpp, fakes/PeripheralAuthStoreFake.* socket-free coverage yes In scope
Inherited #6008 commits 120ed439…2e58e6f4: .gitignore, CMakeLists.txt, AutomationSensitiveLineEdit.h, AutomationServer.cpp, LogManager.cpp, PeripheralAuthCode*.h, the TGXL/PGXL/AG auth state machines, AmpApplet/AntennaGeniusApplet/TunerApplet/ShackSwitchApplet, tools/probe_4o3a_auth.py, 6 test files no — disclosed in the body as the prerequisite Needs maintainer sequencing. 7,179 insertions / 49 files land if this branch merges as-is. The disclosure is honest and unusually clear, but the unbundling is "merge #6008 first", not something the diff does
AmpApplet source-indicator moved to bottom-right + tests/pgxl_panel_test.cpp assertions rewritten visual relocation of an existing applet widget neither title claims it Belongs to #6008's review, not this one. Flagging so it does not slip through on a UI-titled PR

No CHANGELOG.md edit — correct.

Socket tests (surfaced, not a finding): this PR adds no new socket-owning test; the eight focused tests are all injection-based (I grepped the added sources for QTcpServer/QUdpSocket/listen/connectToHost — clean). It does modify one existing loopback test, tests/pgxl_panel_test.cpp (binds QTcpServer::listen(QHostAddress::LocalHost, 0) at line 121), inside the inherited auth commits; the change is layout assertions only. Neither that test nor any of the eight focused targets appears in ci.yml's -R filters, so see the CI note below.

Blockers

None that I can confirm at this severity. One finding is a genuine contradiction of a stated invariant but its blast radius depends on firmware I cannot observe, so I have labelled it plausible rather than certain — see inline comment 1 and the request below.

Findings

  1. Remove is asymmetric across the shared AG credential slot (inline, RadioSetupDialog.cpp:10749). device.row >= 1 && device.row <= 3 puts ShackSwitch (row 4) on the else { finishRemoval(device); } path — no PeripheralAuthStore::clear, no PeripheralRemovalGuard. There is exactly one AG slot (SettingsCredentialPolicy.h:39, antenna_genius_auth_code) and MainWindow_Wiring.cpp:6524 saves authCodeAccepted under Device::AntennaGenius regardless of target, while the AUTH trigger at AntennaGeniusModel.cpp:581 keys only on the greeting, not the device name. So removing ShackSwitch leaves its code in the vault, and removing AG deletes whichever target owns the slot. The #6026 invariant holds for the socket, not for the credential. Reasoned from code — I cannot confirm ShackSwitch firmware sends AG AUTH. Either gate row 4 the same way, or state in the doc that ShackSwitch never authenticates.

  2. The removal timeout discards unrelated pending edits (inline, RadioSetupDialog.cpp:10789). m_peripheralRowSavers.clear() drops every row's saver, not the removed row's. Clear the ACOM address, then Remove TGXL, then time out and close: the ACOM clear is silently lost. The doc says "closing does not save abandoned field edits" — this drops edits that were never part of the abandoned Remove.

Nits (non-blocking)

  • listGroup->setFixedWidth(250) / deviceList->setFixedHeight(340) (RadioSetupDialog.cpp:10244, :10266) are hard pixels. The evidence doc records a prior clipped-control problem at the default dialog size; fixed extents at 125 % scaling or a larger UI font are how that comes back. setMinimumWidth + a stretch would be safer.
  • docs/peripherals-settings-ui.md:7 lists seven supported pages and omits LP-100A Meter, which devices includes (row 9, RadioSetupDialog.cpp:10232) and which finishRemoval handles.
  • Two id conventions now coexist in the same Peripherals JSON root: VisibleDevices/DiscoveryDismissed use lowercase ("tgxl", "acom"), while clearDeviceConnection/deviceString use "Acom"/"Vkamp"/"Lp100a". Functional, but a future endpoint migration will have to reconcile them.
  • PeripheralRemovalGuard.h:33: std::array<unsigned, 3> non-atomic, with the main-thread requirement carried only in a comment. Nothing enforces it and the header sits in core/.

What I tried to break

  • Guard bypass. Grepped every connect path: connectToTgxl, connectToPgxl, AntennaGeniusModel::connectToDevice are the only three places reaching connectToHost, and both connectToAddress overloads funnel through connectToDevice. The gate is at the funnel, not at optional call-site wrappers. No bypass found.
  • Guard leak on teardown. The clear() callback is registered against qApp, captures removal by value, and releases on lambda destruction on every exit — including if (!self || *timedOut) return; and the request.context-null branch in startNextWrite. Destroying the parent window mid-delete does not free the lease early. Held.
  • Double Remove after timeout. The timeout sets m_peripheralRemovalPending = false, which looked like it re-opened Remove — but removeButton is a child of content, which stays setEnabled(false), and PeripheralRemovalGuard::pending() early-returns anyway. Two independent stops. Held.
  • Test quality. checkRemovalTimeout (tests/peripheral_auth_dialog_test.cpp:433) asserts connects == 0 while the lease is held and connects == 1 after late completion, across 3 devices × success/failure × dialog-alive/destroyed, with the deadline stopped and its timeout injected rather than slept. Emptying the gate or the release would fail it. It is not asserting the implementation back to itself.
  • finishRemoval ordering. Checked that SS_ManualIp is removed before disconnectFromDevice(), so the MainWindow.cpp:2266 handler sees an empty key and hides the SS button. Correct order. Also verified kPortKeys[4] == "SS_ControlPort" matches buildRow(4, …) at :9297 — no stale-port leak.
  • Could not check: anything at runtime. No build, no test run, no GUI. Every finding above is reasoned from the head checkout at /tmp/aetherclaude/pr-6027, not reproduced. Windows/Linux behavior, native accessibility, real OS-vault semantics and ShackSwitch AUTH behavior are all unverified here — as the PR body itself states.

CI note: 5/5 green on d8aad51e (build, check-macos, check-windows, sanitizer configure, static checks). But none of the eight focused tests — peripheral_auth_dialog_test, peripheral_auth_handshake_test, peripheral_auth_keychain_test, the two automation-redaction tests, tgxl_docked_parity_test, tgxl_applet_ports_test, amp_applet_test — appears in any ctest -R filter in ci.yml. Green means the tree compiles on three platforms; it does not mean this PR's coverage ran. The contributor's local run is currently the only evidence they pass.

Recommendation

Needs maintainer decision, on two independent axes, then request changes on finding 1.

The sequencing is the first call: as it stands this branch carries all of #6008, including an AmpApplet indicator relocation that neither PR title mentions. The contributor has been scrupulous about disclosing this and is not asking for a merge — the ask is to review 0063b943 + d8aad51e on top of #6008, which is a reasonable request and what I did. The second call is #6026 itself: layout, Remove semantics, TGXL/PGXL discovery dismissal, and the blocking-close behavior are all yours to approve.

Concrete next step: rule on #6026, then ask for finding 1 (gate row 4 the same as rows 1–3, or document that ShackSwitch never authenticates) and finding 2 (scope the saver clear to the removed row) before the post-#6008 replay. Findings 3–6 are cleanup that can ride along. This is careful, well-instrumented work with an unusually honest evidence boundary — the credential asymmetry is the one place the implementation and the stated invariant have drifted apart.


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

Comment thread src/gui/RadioSetupDialog.cpp Outdated
Comment thread src/gui/RadioSetupDialog.cpp Outdated
Comment thread src/gui/RadioSetupDialog.cpp Outdated
Comment thread src/core/PeripheralRemovalGuard.h
Comment thread docs/peripherals-settings-ui.md 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-6027/src/core/TgxlConnection.h:20
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6027/src/gui/MainWindow.cpp:9549
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6027/src/gui/MainWindow.cpp:9661
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6027/src/gui/MainWindow.cpp:9780
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6027/src/gui/MainWindow.cpp:9781
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6027/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: $13.9818 · model: claude-opus-5

w5jwp added a commit to w5jwp/AetherSDR that referenced this pull request Sep 29, 2026
… XI.

Address PR aethersdr#6027 review: endpoint-scoped AG/ShackSwitch removal, queued replacement protection, row-scoped timeout cleanup, flexible list layout, and documented settings/thread contracts. Add socket-free regressions and mutation evidence.
@w5jwp

w5jwp commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@aetherbot @aethersdr-agent — pushed 5c07925 addressing this review.

  1. Shared AG/ShackSwitch credential ownership: both Remove paths now acquire the shared reconnect lease and use the bounded deletion workflow. clearForEndpoint reads the existing slot and deletes only a matching endpoint; the other endpoint's code survives. A queued conditional delete rechecks endpoint/revision before it starts, preserving a replacement save. An unknown offline hostname fails closed with an explicit instruction to connect the selected device to establish its peer address before retrying. This makes no claim about whether ShackSwitch firmware emits AUTH. Tests cover each row, owned/other credentials, unknown identity, queued replacement and the live shared-model isolation already covered.
  2. Unrelated pending edits: timeout now suppresses only the selected row's pending clear-on-close edit. It no longer clears the saver vector. The regression clears an ACOM endpoint before timing out another device's Remove and confirms the ACOM clear still persists.
  3. Layout: replaced the fixed list width/height with minimum extents and expanding layout space. Dialog tests pass at 125% and 150%; a 125% offscreen demo screenshot shows the TGXL auth fields and Show/Clear/Add/Remove controls fitting without clipping.
  4. Documentation/contracts: added LP-100A Meter to the user guide. Explicitly documented stable lowercase UI IDs versus the existing case-sensitive connection-object names, preserving compatibility instead of silently migrating operator data. Sharpened the removal guard contract: every non-atomic acquire, release and query must run on the main thread.
  5. CodeGuard advisories checked: the TgxlConnection.h location is a protocol-format comment; MainWindow.cpp:9549 and :9661 are fixed widget/status text. The three :9780–9782 locations walk upward from Qt's own application executable path to its bundle for the existing relaunch code. Those locations contain no untrusted path component reaching a file read/write operation. I found no actionable path-traversal defect and made no unrelated relaunch changes or scanner suppressions.

Validation on the restored final code: desktop build, all eight focused tests (17.81 s), scaled dialog runs, registration, generated manifest and strict engine/capability/command-plane checks passed. Mutations that restored whole-page saver clearing, bypassed endpoint ownership, or excluded ShackSwitch from removal guarding each failed their regression test. The demo reported one slice, one panadapter, TX disabled/transmitting=false; the review-owned instance was stopped. No live firmware or actual OS-vault evidence is claimed.

#6027 remains ready for review, must merge after #6008, and still needs the maintainer's #6026 UI ruling. The final post-#6008 replay and CI are still required; these fixes do not imply approval or request automatic merge.

@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

Partially. This was reviewed as the increment over #6008 (git diff 71d5bcd7 0ef90af5, 22 files), against RFC #6026. The list/detail page, Add/Remove, the bounded close, and the reconnect lease all work as described. The previous review's two findings are fixed in 5c079250: the shared AG/ShackSwitch credential is now endpoint-scoped, and the timeout no longer drops unrelated edits.

Two #6026 acceptance points fail at this head:

  • "Remove reports … failed and unconfirmed credential cleanup accurately" fails for AG and ShackSwitch when no keychain backend is available. I reproduced it (blocker 1).
  • 5c079250 regressed ShackSwitch removal (blocker 2).

The RFC itself has no maintainer ruling yet, and #6008 has not merged. The body discloses both, so I list them as sequencing, not code findings.

Scope

File / group What it changes Claimed? Verdict
src/gui/RadioSetupDialog.{cpp,h} list/detail page, Add/Remove, 15 s bounded wait, AG reconciliation yes (#6026) in scope, pending the #6026 ruling
src/core/PeripheralRemovalGuard.h + gates in Tgxl/PgxlConnection.cpp, AntennaGeniusModel.cpp transient reconnect lease yes in scope
src/core/PeripheralSettings.h VisibleDevices, per-device DiscoveryDismissed in the existing Peripherals blob yes in scope; no new flat keys
src/gui/PeripheralAuthStore.*, PeripheralAuthConnectFlow.h endpoint-scoped delete, configuredEndpoint, delete-completion handling yes in scope; see blocker 1
src/gui/MainWindow_Wiring.cpp discoveredTarget() honours dismissal yes in scope; see M1
docs/peripherals-settings-ui.md, touchpoint manifest (+PeripheralRemovalGuard.h, peripheral(4o3a); AppSettings 104→105) doc + manifest yes in scope; additions only
docs/4o3a-remote-auth-hardware-evidence.md (+181/−10) rewrites part of #6008's evidence doc partly nit N5
tests: peripheral_auth_dialog_test (+1430), peripheral_auth_keychain_test, fake store socket-free coverage yes in scope; see blocker 1 on the fake
everything else (≈28 files) inherited from #6008 disclosed reviewed on #6008

No CHANGELOG.md edit. Against main this branch is still 50 files, +7586/−268, until #6008 lands and it is replayed.

Socket tests: none added. The dialog test's Connect clicks are all refused before connecting (invalid code, empty address), and lifecycle tests inject m_connectTransport. The two pre-existing loopback PGXL tests are inherited from #6008, and the operator approved running them for this review.

Blockers

1. AG and ShackSwitch can never be removed when the keychain backend is unavailable, and the error says a code "remains in keychain" when none may exist.

src/gui/PeripheralAuthStore.cpp:397, in clearForEndpoint:

if (!entry.loaded || entry.status == LoadStatus::Unavailable) {
    callback(ClearResult::Failed);

load() leaves loaded = false and sets Unavailable for every read error except EntryNotFound (:270-283), including NoBackendAvailable and NotImplemented.

TGXL and PGXL go through clear(), which maps the same backend loss to SessionCleared and lets removal proceed. Your own keychain test states the invariant: "No OS backend must not prevent local removal, even if this device never had a stored code."

Reproduced. I added a temporary probe to tests/peripheral_auth_keychain_test.cpp, which uses the production adapter with the QtKeychain double. It calls clearForEndpoint(AntennaGenius, …) and fails the read with NoBackendAvailable. Then it retries the same call.

aether.peripheral.auth: keychain read failed: "no backend"
PROBE called=1 result=2 (Cleared=0 SessionCleared=1 Failed=2)
aether.peripheral.auth: keychain read failed: "no backend"
PROBE retry called=1 result=2

So Linux without Secret Service/KWallet, on a QtKeychain build, can never remove AG or ShackSwitch. "Retry Remove" fails forever.

The dialog test can't see this. Its fake (tests/fakes/PeripheralAuthStoreFake.cpp:144-155) falls through to clear() whenever the endpoint matches, so production's Unavailable → Failed branch is never exercised.

Fix: on Unavailable with NoBackendAvailable/NotImplemented, return SessionCleared as clear() does (keep Failed for AccessDenied). Add a keychain-test case through clearForEndpoint, plus a fake that models the read failure.

2. Removing a connected ShackSwitch leaves its applet button on screen (regression from 5c079250).

  • RadioSetupDialog.cpp:10789 calls m_ag->disconnectFromDevice() when Remove starts, while SS_ManualIp is still saved.
  • disconnectFromDevice() emits disconnected synchronously. The handler at MainWindow.cpp:2276-2285 hides the SS button only if SS_ManualIp is already empty, so it keeps the button.
  • finishRemoval case 4 (:10661-10665) now only calls setAuthCode({}). At d8aad51e it called disconnectFromDevice() after the keys were removed, and that second disconnected is what hid the button. Nothing emits it now.
  • The button for a removed device stays until restart or rediscovery.

Traced in source, not driven: the demo has no ShackSwitch. A fix is to hide the SS button explicitly in finishRemoval for row 4, or to re-emit through a model call once the keys are gone.

Maintainer decisions (#6026)

  • M1. Remove used as "undo" permanently dismisses discovery.
    • Bridge evidence: on this head, in demo mode with an isolated store, I ran Add → Tuner Genius XL, then Remove without entering anything. The store then held:
      Peripherals|{"VisibleDevices":[],"tgxl":{"DiscoveryDismissed":true}}
      
    • RadioSetupDialog.cpp:10639 sets the flag unconditionally for TGXL/PGXL. From then on, discoveredTarget() suppresses direct auto-connect to a discovered TGXL/PGXL on every radio connect, across restarts. The applets stay visible on the relay, so nothing tells the operator.
    • TGXL loses manual relay control (#469) and PGXL loses direct telemetry (Vac/Vdd/HL/fan/MEffA). Add or an explicit Connect undoes it, but only an operator who knows the flag exists will think to.
    • At minimum, only dismiss when the device was actually configured or discovered.
  • M2. The RFC ruling itself: the layout, Remove semantics, discovery dismissal, and the blocking close.

Nits (non-blocking)

  • N1. ShackSwitch credential can be orphaned while Remove reports success. The SS connect path saves under port 9007, but offline Remove computes configuredEndpoint(SS_ManualIp, SS_ControlPort) (:10772-10776). If the port spin ever differed, the endpoints don't match and clearForEndpoint returns Cleared without deleting (PeripheralAuthStore.cpp:405-407). The doc's "fails closed" covers only an empty host. Reasoned from code.
  • N2. a11y (#4896).
    • None of the Removing / timed-out / failed / succeeded notices calls QAccessible::updateAccessibility, so screen readers don't announce them (docs/a11y.md, live value updates).
    • Observed on the bridge: after typing a code, the field's accessibleDescription still reads "Code blank. A saved code will be checked when connecting." :11092-11098 keys only on the saved status, never on the field's text.
    • Accessible names are inconsistent on one page (TGXL authorization code / Tuner Genius XL show authorization code / TGXL clear saved authorization code), and the port spin box has no accessible name.
  • N3. i18n. :11026 compares detailStatus.startsWith(tr("Connecting")) against the untranslated setText("Connecting...") at :8871. In a translated build that shows "Offline" while connecting. Accessible names are also built by concatenation (device.label + tr(" address")), which breaks word order in translation.
  • N4. After a timeout, close and reopen, Remove and Connect do nothing while the lease is still held. Remove returns silently at :10759, and Connect leaves "Connecting..." stuck. A message naming the pending deletion would help.
  • N5. This PR rewrites #6008's own evidence-doc sentences (+181/−10), which will conflict with any further #6008 edit. It also still says Setup "prevents editing or closing until the vault completes", which contradicts the 15 s bound. A separate evidence doc for #6027 would avoid both.

What I verified vs. read

  • Built 0ef90af5 on Linux and ran the 13 focused targets: 13/13 pass, including peripheral_auth_dialog_test (6.7 s) and the two inherited loopback PGXL tests (operator-approved).
  • Reproduced blocker 1 with the production adapter (output above). The temporary probe was reverted.
  • Bridge: offscreen, isolated AETHER_SETTINGS_DIR/HOME, explicit socket, AETHER_AUTOMATION_NO_TX=1, attached to DEMO-0001 only (get radio → "model":"AetherSDR Demo","serial":"DEMO-0001"). LAN discovery listed the operator's FLEX, which was never connected.
    • Verbs: menu list, invoke "Radio Setup..." trigger, invoke radioSetupNavigation setCurrentText Peripherals, invoke peripheralAddButton click, invoke "Tuner Genius XL" trigger, invoke peripheralAuth_tgxl_4 setText, Show, peripheralRemoveButton click, dumpTree, grab, then a sqlite read of the store.
    • The empty-state page, Add menu (8 devices), detail page, redaction and Remove all behaved as described, apart from M1 and the N2 description. The instance was closed.
  • Gates: all four checks are flat versus #6008 (102 findings; EB3 rows in RadioSetupDialog.cpp shift lines only). The manifest check passes, and the tag sidecar has one addition.
  • Attacks that held (reasoned from code, read-only second pass):
    • every connectTo* funnel is gated by the lease;
    • the lease is released on every path, including owner teardown;
    • a dialog destroyed mid-delete is a safe no-op;
    • Remove twice, and Add during a pending delete, are both blocked;
    • late completion after a timeout never applies the abandoned Remove;
    • keychain callbacks stay on the main thread, as the non-atomic guard requires.
  • CI is stale: the earliest run on this head was created 2026-10-01 22:26Z, before main's 2026-10-02 19:07Z tip. The branch now conflicts with main in the same three files as #6008, all inherited. The focused tests are not in any ci.yml -R filter.
  • Not verifiable here: a real OS vault, ShackSwitch/AG hardware, and Windows/macOS accessibility.

w5jwp added a commit to w5jwp/AetherSDR that referenced this pull request Oct 3, 2026
… state the 15 s removal bound. Principle XI.

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

w5jwp commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed 74bf2e89. This branch merges #6008 (and through it origin/main) with no force-push.

Blocker 1 (d0780723): clearForEndpoint records the read error on the entry. On NoBackendAvailable / NotImplemented it clears the session value and returns SessionCleared, as clear() does; AccessDenied and other errors still return Failed. A later load retries the vault. A new peripheral_auth_keychain_test case goes through clearForEndpoint with a NoBackendAvailable read, including the retry. The fake store can now model a read failure, and a dialog case uses it. With the fix disabled, the new keychain case fails.

Blocker 2 (b1fa3ef1): RadioSetupDialog emits peripheralRemoved(id) at the end of finishRemoval. MainWindow hides the ShackSwitch button for that row, and hides the AG button for the AG row unless another AG is connected or discovered. A dialog test asserts the signal fires.

M1 mitigation (b1fa3ef1): Remove captures, before it disconnects, whether the device had a saved manual IP or a live or connecting session, and finishRemoval sets DiscoveryDismissed only in that case. Add → Remove of a never-configured TGXL/PGXL leaves it unset. Left for your ruling (M2/#6026): the layout, Remove semantics, whether Remove should ever dismiss discovery, and the blocking close.

Nits

  • N1 (b1fa3ef1): one kShackSwitchControlPort (9007) is used by the connect path and the removal endpoint.
  • N2 (b1fa3ef1): removal notices update the accessibility tree. The code field's description follows whether it has text. Row names use one tr("%1 …").arg(label) pattern, and the port spin boxes are named.
  • N3 (b1fa3ef1): the connecting state is a property instead of startsWith(tr("Connecting")); the status text goes through tr(); names use tr("%1 address").arg(label).
  • N4 (b1fa3ef1): Remove and Connect show a notice naming the pending deletion when a lease is still held after a timeout.
  • N5 (f41882ba): this PR's evidence moved to the new docs/peripherals-removal-evidence.md, feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections #6008's sentences are restored, and the "until the vault completes" sentence describes the 15 s bound.
  • Mixed id casing (tgxl vs Acom, from the earlier bot review): known, left unchanged.

One test change you should look at: #6008's N1 means a keychain outage no longer blocks, so peripheral_auth_dialog_test modelled the blocked-device case wrong. 74bf2e89 makes it use a missing saved code instead. checkRemovedDiscovery also now sets a saved manual IP first, since a never-configured device no longer dismisses.

Built and ran the focused targets locally; peripheral_auth_* pass. Not verified: a real OS vault, ShackSwitch/AG hardware, native accessibility on Windows/macOS.

@w5jwp

w5jwp commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

@ten9876 the updates from your review are pushed (74bf2e89). The itemized reply is above. Ready for another look when you have time.

ten9876 added a commit that referenced this pull request Oct 3, 2026
… connections (#6008)

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

- [x] 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.
- [x] 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 `QTcpSocket`s. 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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
ten9876 added a commit that referenced this pull request Oct 3, 2026
**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
```

**How to review:** this PR's own commits are
[`ec0d432a`](ec0d432)
and
[`590a798e`](590a798).
`d473b183` merges #6008 in (one conflict in `AmpApplet.cpp`, resolved as
described under "Merging with #6008") and `b41ed522` merges #6030 in (no
change to the tree; this branch used to carry #6030's last commit as a
cherry-pick, which conflicted with the original). `92fd1aa5` merges
#6008 in again for its hostname-bound credential (`907a88e8`).
Everything else is carried from #6008 and #6030, which are reviewed in
their own PRs.

## Summary

Fixes #6032.

**Stacked on #6008 and #6030, not a replacement for either.** It merges
both in until they merge; this PR will then be rebased onto `main` and
show only its own.

When PGXL status is relayed through a FlexRadio, the Id gauge did not
move. The radio sends drain current as its `ID` meter, which AetherSDR
never decoded, and the relay path instead waited for amplifier-status
keys the radio never sends. This PR reads each PGXL reading from where
the radio actually sends it, and picks between the radio and the PGXL by
measured refresh rate.

**Verified on a FLEX-8600 (SmartSDR 4.2.20.41343) and a PowerGeniusXL
running firmware 3.9.8.** The captures, rates and method are in
`docs/pgxl-telemetry-source-evidence.md`.

**The rule for each reading**
- Available only from the radio: use the radio (drive).
- Available only from the PGXL: use the PGXL (Harmonic Load heatsink
temperature, Vdd, Vac, fan mode, MEffA).
- Available from both: use the faster source. The radio is faster for
forward power and SWR. Drain current and PA heatsink temperature are a
tie, and a tie goes to the radio. In every case the radio's value is
used while fresh (1500 ms), and the PGXL's value otherwise.

**Changes**
- `MeterModel` decodes the PGXL's `ID` meter. `RadioModel` passes the
amplifier's handle to `MeterModel::setAmpHandle`; once it is known, an
AMP meter (FWD, RL, DRV, TEMP, ID) reaches the PGXL only when its source
index matches, as FlexLib `Radio.FindMetersByAmplifier` does, so neither
a tuner's nor a second amplifier's meters land on it. Before the handle
is known, any AMP meter that is not the tuner's goes to the PGXL, and a
handle change re-routes the meters and withdraws Id and PA temperature
until the next sample. A new `ampVitalsChanged` signal carries drain
current and PA heatsink temperature, each with a valid flag.
- `AmpApplet` gains `setRadioDrainCurrent` and `setRadioPaHeatsinkTemp`.
The radio's values win while fresh, the same rule forward power and SWR
already use.
- Removed the radio amplifier-status handler in `MainWindow_Wiring`.
None of the keys it read (`temp`, `id`, `vdd`, `vac`, `meffa`) arrive.
Also removed `AmpApplet::setMeff`, which only that handler called, and
its test.
- Corrected the `applyMeters` comment: it said the PGXL is polled at 5
Hz, and now cites the measured rates.
- Added `tools/probe_pgxl_telemetry_sources.py`, the read-only probe
that produced the evidence, and
`docs/pgxl-telemetry-source-evidence.md`.

**Merging with #6008:** `src/gui/AmpApplet.cpp` conflicts in one place.
#6008 adds `setRadioConnected`, `updateSourceIndicator`,
`setDirectFailureReason` and `hasRadioRelay` just before `setMeff`,
which this PR removes. Keep #6008's four functions and drop `setMeff`.
Tested that way (see below).

## Constitution principle honored

Principle VIII (Evidence Over Assertion). The relay handler rested on an
untested claim about what the radio sends (#2444). Every choice here is
taken from captures of the radio and the PGXL, and the evidence page
records how to repeat them.

## Test plan

- [x] Local build passes
- [x] Behavior verified on a real radio: FLEX-8600 (SmartSDR
4.2.20.41343) with a PowerGeniusXL on firmware 3.9.8
- When PGXL status is relayed through a FlexRadio: Id read 19 A during a
TUNE and dropped after unkey; PA temperature updated; HL, Vdd and Vac
blank.
- Direct connection (built together with #6008, which this PGXL firmware
needs for authentication): Id 19 A, Vdd 52.0 V, Vac 117 V, both heatsink
temperatures; no flicker between sources.
- [x] Tests pass: `amp_applet_test`, `meter_model_test`,
`health_applet_test`, `pgxl_panel_test`, `amp_model_test`
- [x] New tests: `testAmplifierVitalsAreRouted` and
`testTunerDrainCurrentIsNotRoutedToTheAmplifier` (`MeterModel`),
`testRadioVitalsWinOverTheDeviceWhileFresh` (`AmpApplet`)
- [x] Reproduction steps documented in the linked issue

## Checklist

- [x] Commits are signed (`docs/COMMIT-SIGNING.md`)
- [x] No new flat-key `AppSettings` calls
- [x] Protocol behavior comes from captures of the radio and the PGXL;
the meter-to-amplifier match follows FlexLib
`Radio.FindMetersByAmplifier` (Principle I)
- [x] All meter UI uses `MeterSmoother` (no meter UI changed)
- [x] Documentation updated: `docs/pgxl-telemetry-source-evidence.md`
- [x] Security-sensitive changes reference a GHSA if applicable (not
applicable; the probe reads the authorization code without echoing it
and never records it)

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

## Review follow-up (2026-10-03)

- `ampVitalsChanged` carries a per-reading "updated in this packet"
flag, so an `ID` packet no longer restamps `TEMP`'s freshness and the
reverse. A new `meter_model_test` case sends only `ID` and checks `TEMP`
is not marked updated.
- `testAmpAndTunerMetersSplitByHandleAfterALateHandle` now includes a
tuner `ID`/`TEMP`, pinning the rescan after a late handle.
- AMP meters match the amplifier's handle (maintainer ruling on N3).
`testOnlyTheAmplifiersOwnMetersReachIt` and
`radiomodel_amp_meter_routing_test` cover it.
- Merged #6008's branch.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
ten9876 added a commit that referenced this pull request Oct 3, 2026
#6036)

**Merge order: #6008 and #6033 must be merged before this PR.** This
branch is built on #6008 and merges in #6033 (with the conflicts
resolved), so it carries their commits until they merge; it will then be
rebased onto `main`. #6030 has merged.

**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
```

**How to review:** this PR's own work is
[`b4dcd8c9`](b4dcd8c)
(the layout change) and
[`4cc897c2`](4cc897c2)
(the review findings: a neutral OPERATE placeholder with an accessible
name, the temperature column measured in the drawn font, and two test
fixes). `1b0de46e` merges #6030 in, and `3827ffea`, `4ef42eae` and
`2186ef8e` merge #6033 in (the last also brings #6008's hostname-bound
credential); open them only to check the resolutions. Everything else in
the diff is carried from #6008, #6030 and #6033, which are reviewed in
their own PRs.

## Summary

Fixes #5903.
Fixes #4885.

#6008 puts the connection indicator on its own line and provides the
direct connection that shows all of the readings and controls.

In the docked (rail) view, the PGXL panel clipped its controls and
readings: the 260 px rail had one row holding a stacked column of
readings plus the MEffA, Fan and OPERATE controls. This PR gives the
controls and the readings their own lines, and keeps the panel's shape
whether the amplifier is offline or connected.

Docked, the panel now reads:

```
[ MEffA ]  [ Fan: Standard ]  [ OPERATE ]
PA 89.9 F    Vac 122 V
HL 89.2 F    Vdd 51.9 V            ● DIRECT
```

**Changes**
- **Controls.** MEffA, Fan and OPERATE have the row to themselves and
share its width, at one height (22 px). MEffA keeps its three state
colours but takes OPERATE's 10 px bold lettering.
- **Readings in a 2×2 grid**, in both presentations: PA over HL, and Vac
over Vdd in a second column 10 px to the right. Docked, the connection
indicator ends the HL/Vdd row; expanded, it keeps its own line below.
Each is its own readout (`label value unit`) with its own screen-reader
name.
- **Vac and Vdd never move.** A grid column is as wide as its widest
cell, so they start at one x; and the temperature column is held at the
width of its widest possible reading, measured in the face it is drawn
in, so they also stay put as the temperatures change. Fixed-length text
alone was not enough: where no true fixed-width face is installed the
fallback is proportional, and `9.9` and `106.8` differ in width.
- **Temperature units.** PA and HL are both clickable, and a click on
either switches both between Celsius and Fahrenheit and saves the
choice, as the single readout did. HL shows `HL —` in grey until the
direct connection sends a value, rather than disappearing, so the rows
never move. The hover outline is gone (the pointing-hand cursor already
says it can be clicked); the keyboard-focus outline stays.
- **The connection indicator shares Vdd's line exactly.** Docked, it
takes Vdd's height, so both centre the same way. At one pixel shorter,
centring it needed a half-pixel offset that widget geometry rounds away,
which lifted its text above Vdd's.
- **Padding.** Every readout keeps a fixed length, but the spare spaces
go at the end instead of in front of the number (`HL 89.8`, not `HL
89.8`). The temperature readouts have no left padding, so they line up
with the controls above them.
- **Spacing.** Docked, an 8 px pad separates the controls from the Id
gauge, and the readings sit with equal space above and below.
- **Fan labels** are spelled out: `Fan: Standard`, `Fan: Contest`, `Fan:
Broadcast`. The pull-down reserves room for the longest.
- **OPERATE's colours come from the theme.** It used a hardcoded green
(`#6030`) under the theme's primary text colour, which is near-black
under Default Light and unreadable there (#5903, item 4). It now uses
the lit MEffA key's tokens, so the dark theme looks as before and the
light theme gets its own green with white text.
- **Before the amplifier reports.** The docked controls stay in place
instead of hiding: MEffA is grey and disabled, OPERATE reads `—` and is
grey and disabled (with the accessible name "Amplifier state not
reported"), and Fan shows `Fan: —`, disabled. None of them claims a
state. Clearing the fan selection is done with signals blocked, so it
never sends a fan-mode command. PA and HL are grey until a reading
arrives, like Vac and Vdd. The expanded panel's keys still wait for a
state before appearing.

**Maintainer rulings:** accepted as built. That covers the expanded
(floating) panel's 2×2 grid, the removed hover outline, the
right-justified readouts, and showing the controls disabled before the
amplifier reports instead of hiding them. On a station without a direct
PGXL connection, MEffA and fan stay dimmed, and their descriptions say
they need a direct PGXL connection.

**Against #5903's triage:** items 1 (controls on their own line), 2 (the
rail MEffA button at 10 px) and 4 (OPERATE contrast) are done as
proposed. Item 3 (the fan pull-down too wide when docked) is solved the
other way: the controls share the full row, so the full labels fit, and
they are spelled out rather than shortened to Std/Bcast. The
docked-width test it asks for is added (below).

**#4885** was left open until the docked `Fan: Contest` label could be
checked on a current build: it reads in full here, and the new test
checks all three fan labels at the rail's width.

**Merging with #6030, #6033 and #5984:** #6030 is merged into this
branch (commit 1b0de46), because the two conflicted in 10 places across
`AmpApplet.cpp`, `AmpApplet.h` and `tests/amp_applet_test.cpp`: #6030
renames the temperature setters and members (`setTemp` →
`setPaHeatsinkTemp`, `m_hasTempA` → `m_hasPaHeatsinkTemp`, …) and
changes `updateTempLabel` and `setDirectConnected`, all code this PR
restructures into two readouts. The resolution keeps this PR's structure
with #6030's names and behaviour: HL clears (and turns grey) when the
direct connection drops, a late HL write is ignored, and each readout's
tooltip names only its own label. #6030's fallback test is rewritten for
the two readouts and now also checks the grey tone. #6033 is merged in
too (`3827ffea`, `4ef42eae`, `2186ef8e`). It deletes the
amplifier-status relay handler and `AmpApplet::setMeff`, and adds
`setRadioPaHeatsinkTemp` and `setRadioDrainCurrent` beside the PA setter
this PR restructures, so it conflicted here in three files. The
resolution keeps this PR's two-readout layout and tooltips, takes
#6033's deletions and evidence comment, and keeps the grey-to-live style
change on the PGXL's own first PA reading. Its single-button tooltip
assertions are dropped in favour of this PR's per-readout ones. #5984
(spelling out MEffA on the expanded panel's key) edits panel-key code
this PR leaves alone.

## Constitution principle honored

Principle XI (Fixes Are Demonstrated). `pgxl_panel_test` lays the panel
out at the rail's real width (260 px) and checks that no control or
readout is narrower than its text needs, and
`testDockedCaptionsFitTheRail` repeats that for every caption the
controls can show. Both fail against the old layout. The layout was also
checked in the running app on the hardware below, offline and connected.

## Test plan

- [x] Local build passes
- [x] Behavior verified on a real radio: FLEX-8600 (SmartSDR
4.2.20.41343) with a PowerGeniusXL on firmware 3.9.8. Docked, offline
and connected with the direct connection up; `Fan: Broadcast`, the
longest label, fits beside MEffA and OPERATE.
- [x] Tests pass: `pgxl_panel_test`, `amp_applet_test`,
`pgxl_direct_protocol_test`, `amp_model_test`,
`peripheral_auth_handshake_test`, `health_applet_test`
- [x] Test changes:
- `pgxl_panel_test`: in both presentations, PA sits over HL and Vac over
Vdd, with Vac and Vdd at one x. Docked at 260 px, the indicator ends the
HL/Vdd row at the right edge, at Vdd's height and top edge; nothing is
narrower than its text; the controls share one height; the space above
and below the readings matches; the controls clear the Id gauge.
Expanded keeps the indicator on its own line below, and the layout
survives docked → expanded → docked.
- `amp_applet_test`: new `testDockedCaptionsFitTheRail` shows every
caption the rail's controls can take (each operate state word, all three
fan labels, all three MEffA states, and the before-report placeholders)
at 260 and 300 px, and checks each control is at least as wide as its
caption needs. It compares against the control's own size hint, so a CI
host that falls back to other fonts cannot fail it on a caption that
fits. Against the old layout it fails at both widths: the fan pull-down
got 70 px of the 130 its label needs. New
`testVoltagesStayAlignedWithTheTemperatures` runs PA and HL through 9.9,
106.8, 100.4, 120.0 and -5.0 and checks that Vac and Vdd stay at one x
and never move; without the pinned column width they moved by up to 7
px. `testDualSensorToggle` checks that a click on either temperature
switches both and that the unit chosen from HL is saved. Also the new
readout text; fixed length across digit changes; the before-state
controls are shown but disabled with no mode; clearing the fan selection
sends no command; the PA placeholder tone.
- [x] Reproduction steps documented in the linked issues

## Checklist

- [x] Commits are signed (`docs/COMMIT-SIGNING.md`)
- [x] No new flat-key `AppSettings` calls
- [x] Code is clean-room
- [x] All meter UI uses `MeterSmoother` (no meter changed)
- [x] Documentation updated if user-visible behavior changed (no `docs/`
page describes this panel)
- [x] Security-sensitive changes reference a GHSA if applicable (not
applicable)

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

## Review follow-up (2026-10-03)

- The column-pin test forces a proportional face on the temperature
buttons, drives 9.9 → 106.8, and asserts the column minimum covers the
widest reading plus the border. With the `setColumnMinimumWidth(0, ...)`
line removed it fails.
- **Justification (see #5983):** only the numeric field is
right-justified (`label + rightJustified(value, 5) + unit`), so neither
the unit nor the number's right edge moves as the value changes.
- When there is no direct connection, the MEffA and fan descriptions say
they need a direct PGXL connection.
- OPERATE gets an accessible description while disabled; Vac/Vdd say
"not reported" before the first reading; each readout's name change is
announced once (`setAccessibleName` posts `NameChanged` itself); with a
direct connection and no fan mode yet, the fan pull-down says the
amplifier has not reported one; `placeReadings()` skips re-flow when
nothing changed.
- Comments describe current behaviour; the caption check uses the
combo's edit-field rect; placeholder-tone checks compare against the
resolved disabled colour.
- **Known:** `pgxl_panel_test` fails at `:553-554` (`source->height() ==
vdd->height()`) in my sandbox, where the theme resources fail to load.
It fails identically at the previous head `f5eafa06`, so it predates
this change. It passes for you per your review.
- Merged #6033's branch.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
w5jwp and others added 7 commits October 3, 2026 17:57
…6008. Principle III.

Also stops every route to a socket while a removal is pending: the guard now
sits in openSocket(), so automatic, alternate and reconnect-timer attempts
are covered, not only the explicit Connect.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er dismisses. Principle VI.

Remove asks for confirmation, then clears the row's configuration and resets
its toggle. Stopping auto-connect is a separate, visible per-device toggle
for TGXL, PGXL, AG and ShackSwitch that blocks startup, presence/discovery,
alternate and reconnect attempts; an explicit Connect always works. The
DiscoveryDismissed key is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s; isolate toggle keys in tests. Principle VIII.

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

Moves the Peripherals tab into RadioSetupDialog_Peripherals.cpp. Each kind of
device (TGXL/PGXL/AG/ShackSwitch, ACOM/SPE/LP-100A, VK3AMP) has its own
builder that lays out its own detail page; the hidden grid, reparenting,
itemAtPosition and the row-number switches are gone.

Status is carried explicitly (PeripheralDeviceStatus) and the list reads
DIRECT / RADIO / OFFLINE through the helper the applets now share. The detail
status label is always visible and is the accessible state. Nothing searches
text for "Error:" or a dash. The 250 ms presentation timer is replaced by the
connection and model signals that change what is shown. A disabled Add entry
says "(already added)".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ture as it is. Principle XI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…early returns; trim docs. Principle XI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ple 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

Partially. #6026 is approved with conditions (ruling). The list/detail direction, the 15 s bound and lease, the excluded flat-key migration and AG/ShackSwitch isolation all stand.

At 74bf2e89 the branch doesn't meet the ruling's conditions, and it can't merge as is:

  • #6008 has landed on main (4d5b9893), with a fallback rework that this branch hasn't seen.
  • git merge-tree origin/main 74bf2e89 conflicts in about 15 files.
  • CI ran at 15:25Z, before #6008 merged, so the green describes a merge that no longer exists.

Reviewed as the increment over #6008's old head: git diff 22b1496f...74bf2e89, 24 files.

Scope

File / group What it changes Claimed? Verdict
src/gui/RadioSetupDialog.{h,cpp} (+1123/−157) device list, detail stack, Add/Remove, bounded close, recovery rows yes in scope; layout does not meet ruling item 1
src/core/PeripheralSettings.h VisibleDevices, DiscoveryDismissed in the Peripherals document yes in scope; DiscoveryDismissed is superseded by ruling item 4
src/core/PeripheralRemovalGuard.h, {Tgxl,Pgxl}Connection.*, AntennaGeniusModel.* removal lease checked at connect, m_connectTransport test seam yes in scope; see blocker 2
src/gui/PeripheralAuthStore.*, PeripheralAuthConnectFlow.h, MainWindow{,_Wiring}.cpp configuredEndpoint, endpoint-scoped clear, deletion completion, discovery wiring yes in scope
docs/architecture/aetherd-touchpoint* +PeripheralRemovalGuard.h (2 GUI includers), AppSettings.h 105→106 via PeripheralAuthConnectFlow.h yes (numbers stale: body says 231→232, now 235→236) in scope; additions only, no retag
docs/peripherals-settings-ui.md, docs/peripherals-removal-evidence.md user doc + evidence yes in scope; see nit N3
tests/peripheral_auth_{dialog,keychain}_test.cpp, tests/fakes/PeripheralAuthStoreFake.* 1,690 test lines yes in scope

Nothing in the diff is unexplained. CHANGELOG.md is untouched.

Socket preflight: the new dialog cases call connectToTgxl("192.0.2.9x"), connectToPgxl and connectToDevice, but every one goes through the injected m_connectTransport (installed via PeripheralConnectionTestAccess::injectConnect). No socket opens, and the tests are socket-free as the body says.

Blockers

1. Replay onto main, and keep the removal lease on every connect path.
On main, TgxlConnection::connectToTgxl is one of three thin wrappers. autoConnectToTgxl and tryAlternateTgxl call openSocket() without going through it (same in PgxlConnection). This branch puts the PeripheralRemovalGuard::pending() check and the m_connectTransport seam only inside connectToTgxl (src/core/TgxlConnection.cpp:85-87, src/core/PgxlConnection.cpp:84-86). Kept there through the merge, the lease no longer covers:

  • the presence and startup handlers, which call autoConnectTo* on main;
  • the reconnect timer when its target came from an automatic attempt;
  • the queued unreachable → tryAlternate* fallback. An unreachable queued before the Remove click is delivered after it, and reconnects while the keychain delete is still running.

checkPendingRemoval drives only connectToTgxl, so it stays green while those three paths reconnect.

Fix: check the guard before begin*AttemptAt in all three entry points, or in one shared pre-check, and move m_connectTransport into openSocket(). Then add a case per entry point. Also, src/gui/RadioSetupDialog.cpp:11107 fills the recovery row from lastHost()/lastPort(). On main, an alternate attempt deliberately leaves the reconnect target (reconnectHost()) on the manual host while auth was refused at the alternate address, so the "authorization blocked" row would show the wrong endpoint. Use attemptHost(), and drop lastHost() in favour of main's reconnectHost().

2. Ruling item 1: the detail pages are the old grid, reparented.
buildPeripheralsTab still builds the nine-row grid and moves its cells into detail pages (src/gui/RadioSetupDialog.cpp:10353), with six itemAtPosition lookups left in place. The ruling asks for a per-device builder. The list also doesn't use the accepted wording. Driven on the demo backend after Add:

→ cell peripheralDeviceList 0 0
← "text": "Tuner Genius XL\nAddress not set\nOffline"

Use DIRECT / RADIO / OFFLINE, as the applets do.

3. Ruling item 2: Add writes connection intent.
src/gui/RadioSetupDialog.cpp:10733 calls setDiscoveryDismissed(device.id, false) on Add. Driven on the demo backend: an Add with nothing else done persisted

app_settings Peripherals = {"VisibleDevices":["tgxl"],"tgxl":{"DiscoveryDismissed":false}}

Add should create and select the row and write nothing about intent. Only Connect restores it.

4. Ruling item 3: Remove has no confirmation and no outcome table.
Driven on the demo backend: I selected the TGXL row and clicked peripheralRemoveButton. The device went immediately, with no confirmation window, and the list read rows: 0. The destructive credential delete (src/gui/RadioSetupDialog.cpp:10889) needs a confirmation first. The body and docs/peripherals-settings-ui.md also need a table giving the configuration, credential, reconnect and recovery result for success, failure, unconfirmed/timeout and no backend.

5. Ruling item 4: replace DiscoveryDismissed with a per-device "Stop auto-connecting" toggle.
src/core/PeripheralSettings.h keys dismissal by device type only ("tgxl"/"pgxl"), so removing a TGXL on one radio suppresses TGXLs on every radio, and an AG is never dismissed. Remove should no longer imply dismissal. Drop the key in favour of a visible per-device setting.

6. Ruling item 7: drive the presentation from model signals, not the 250 ms timer.
presentationTimer->start(250) (src/gui/RadioSetupDialog.cpp:11268) re-derives the list, re-sets accessible descriptions and writes the Peripherals document from the timer. Driving it from model signals also removes the visibility check that searches the label text for tr("Error:") or an em dash (:11181-11182).

7. A completed AG Remove deletes an address saved meanwhile from the AG applet.
src/gui/RadioSetupDialog.cpp:10795-10800 removes AG_ManualIp/AG_ManualPort without checking them. Sequence:

  1. Remove AG while the keychain delete is pending (for example, a macOS prompt is open).
  2. The AG applet stays usable, so AntennaGeniusApplet::tryManualConnect writes AG_ManualIp=B (src/gui/AntennaGeniusApplet.cpp:272). The guard silently suppresses its connect.
  3. The delete finishes inside the 15 s window, and finishRemoval erases B.

That breaks the body's claim that stale Setup values never overwrite newer settings. Compare-and-clear against the values captured at the Remove click. Reasoned from code; the demo backend has no AG.

Should fix

  • S1. An unconfigured AG or ShackSwitch row can't be removed while the other device's code is saved. In the Remove handler (src/gui/RadioSetupDialog.cpp:10933-10947) and src/gui/PeripheralAuthStore.cpp:415-421, the empty-endpoint check returns UnknownOwner before the different-owner check returns Cleared. Add an AG with no address while the ShackSwitch's code is in the shared slot, then Remove the AG: the notice tells the operator to connect a device that has no address, and connecting would tear down the live ShackSwitch. checkSharedCredentialRemoval always sets the IP key, so nothing covers it. Treat "no resolvable endpoint, slot bound to the other device" as Cleared. Reasoned from code.
  • S2. The src/core/PeripheralSettings.h:20-21 header note still says TGXL/PGXL/AG/ShackSwitch "still use their own flat keys, not this class". That's no longer true now that VisibleDevices lives in the document (ruling item 5).

Nits (non-blocking)

  • N1. Disabled Remove looks enabled. With an empty list, peripheralRemoveButton reports enabled: false, but it draws exactly like the enabled Add button (grabbed on the demo backend). Give its disabled state the disabled-text token.
  • N2. a11y.
    • The detail page hides its status label except for errors, so a screen-reader user on the detail form has to go back to the list to hear state.
    • Disabled Add-menu entries give their reason only through setStatusTip (:10750, :10774), and a dialog has no status bar.
    • (docs/a11y.md, #4896.)
  • N3. Docs. docs/peripherals-removal-evidence.md narrates PR history ("PR #6008 reflowed…"). Move that to the PR body and keep the docs to the current behaviour.
  • N4. Body. It still says "UI approval is pending", has the manifest at 231→232 and calls the PR draft-dependent. Update it after the replay.
  • N5. After a timeout, content stays disabled and the late completion returns early, so the page stays dead until Setup is reopened. That's acceptable given the notice, but the user doc should say so.

What I verified vs. read

  • Built 74bf2e89 on Linux. peripheral_auth_dialog_test, peripheral_auth_keychain_test and peripheral_auth_handshake_test pass.

  • Mutations: each of these fails peripheral_auth_dialog_test, then restored green:

    • removing the TGXL removal guard (TgxlConnection.cpp:85-87);
    • removing Add's setDiscoveryDismissed(false);
    • removing Remove's setDiscoveryDismissed(true).

    The tests pin the behaviour as built. Ruling items 2 and 4 change that behaviour, so those assertions change with it.

  • Gates: engine-boundary, manifest, capability-records and command-plane are flat against #6008's head. The manifest change is the additions above, with no retag.

  • Bridge: offscreen, isolated AETHER_SETTINGS_DIR, attached to DEMO-0001 only (get radio → "AetherSDR Demo"; the relaunch connected to nothing).

    • Radio Setup → Peripherals → Add "Tuner Genius XL" → the row read "Offline", and {"VisibleDevices":["tgxl"],"tgxl":{"DiscoveryDismissed":false}} was persisted.
    • The row survived a relaunch (rows: 1).
    • Remove went through with no confirmation (rows: 0).
    • The instance was closed.
  • Attacks that held (read):

    • Removal lease lifecycle: the guard is RAII and owned by one shared_ptr. No path double-decrements it, and QtKeychain's finished fires on the main thread.
    • Bounded close: done() and closeEvent() both check the flag, and a late completion checks a QPointer.
    • Endpoint isolation: clearForEndpoint keeps the other endpoint's record; peripheral_auth_keychain_test covers the production path.
    • Credential exposure: <hidden> holds even after Show, and nothing new is written to AppSettings or logged.
  • Not verified: a live TGXL/PGXL/AG, a real OS vault, and the AG/ShackSwitch paths at runtime (the demo backend has neither).

…an unaddressed shared row; show the refusing endpoint. Principle XI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@w5jwp
w5jwp force-pushed the peripherals-settings-update branch from 74bf2e8 to 37baf0e Compare October 3, 2026 23:16
@w5jwp

w5jwp commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

@ten9876 #6027 is replayed onto main after #6008 and ready for a fresh review. It answers your ruling on #6026 and both of your reviews (2026-10-02 and 2026-10-03); the checklist and outcome table are in the PR body, and the itemized reply is on #6026.

Not verified: the live app through the bridge on this head, a real OS vault, device hardware, native accessibility.

🤖 Generated with Claude Code

@w5jwp

w5jwp commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

@ten9876 separate from this PR, found while replaying it: after the 4d5b9893 squash of #6008, nothing in src/gui connects TunerModel::relayedCommandRefused (src/models/TunerModel.h:152) any more, and git log -S relayedCommandRefused -- src/gui shows 4d5b9893 as the commit that touched it after #6044 (2d571294) added the hookup to showUnsupportedControlNotice(). I have not changed it here; it is outside #6027.

🤖 Generated with Claude Code

…d toggle. Principle XI.

- Remove treats an empty saved address as no newer target, so an AG Remove
  finishes when a ShackSwitch beacon clears AG_ManualIp meanwhile.
- An unaddressed AG or ShackSwitch row checks the other device's live session
  for the shared code, so it can be removed beside a discovered device.
- TGXL and PGXL label the toggle "Connect directly when available"; off uses
  the radio's relay. Antenna Genius and ShackSwitch keep "Connect
  automatically". Help, the confirmation and the user doc follow, and the
  confirmation names only what the device has.
- Tgxl/PgxlConnection and AntennaGeniusModel emit attemptStarted() from every
  path that opens a socket; Setup refreshes on it, so automatic attempts show
  Connecting….
- The authorization-blocked recovery row shows the endpoint that refused the
  code unless the operator has edited the field.
- Drops the DiscoveryDismissed clean-up (the key never reached main), new
  banner dividers and the VK3AMP history note; close savers are cleared with
  the devices they point into.

New and extended cases in peripheral_auth_dialog_test and
peripheral_auth_handshake_test fail with each change reverted.

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

ten9876 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Thanks for catching the dropped TunerModel::relayedCommandRefused hookup. The #6008 fallback rework (071ddd0b) replaced the wiring block that held #6044's connect. #6138 restores it verbatim and adds tgxl_refused_notice_wiring_test, which fails on 4d5b9893's source and passes with the fix.

ten9876 added a commit that referenced this pull request Oct 4, 2026
Since #6008 merged (`4d5b9893`), a TGXL reached by IP only refuses
OPERATE / STANDBY / BYPASS with no notice. `MainWindow` no longer
connects `TunerModel::relayedCommandRefused` to
`showUnsupportedControlNotice()`, the hookup #6044 added.

**Cause.** My fallback rework on #6008 (`071ddd0b`) replaced the wiring
block between two comment markers in `MainWindow_Wiring.cpp`, and
#6044's connect sat inside that block. The #6027 author found it while
replaying their branch.

## Change

- `src/gui/MainWindow_Wiring.cpp`: the #6044 connect is restored
verbatim, right after the fallback block.
- `tests/tgxl_refused_notice_wiring_test.cpp` (new, registered in
`tests/tests.cmake`): a source pin that the connection exists and raises
the notice. `MainWindow` can't be constructed in a test, so this is the
one claim no behavioural seam reaches. TunerModel's emission is already
covered by `tuner_model_test`.

## Proof

- The pin fails on `main`'s current `MainWindow_Wiring.cpp` ("MainWindow
connects TunerModel::relayedCommandRefused", "the refusal raises the
unsupported-control notice") and passes with this change.
- `AetherSDR` builds on Linux.

## Constitution principle honored

- [x] Principle XI (Fixes Are Demonstrated): the regression is
reproduced by the new test and shown fixed.
- [x] Commits are signed.

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

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

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at 6ed6f868.

Ruling on #6026, all conditions met:

  • per-device-kind builders, with no grid and no reparenting;
  • DIRECT / RADIO / OFFLINE wording;
  • Add only creates the row;
  • Remove confirms first and is covered by the outcome table;
  • a per-device toggle replaces DiscoveryDismissed;
  • signal-driven refresh, with no 250 ms timer;
  • the header note and the a11y items.

Fixed in this pass (da7df5c7), each with a test that fails when the fix is reverted:

  • An AG Remove finishes when a ShackSwitch beacon clears AG_ManualIp (checkRemovalIgnoresClearedAddress).
  • An unaddressed shared row checks the other device's live session (checkRemovalBesideLiveSharedDevice).
  • attemptStarted() from every socket-opening path, so automatic attempts show Connecting… (checkRemovalGuardStopsEveryConnectPath).
  • The recovery row shows the endpoint that refused the code (checkRecoveryShowsRefusingEndpoint).
  • Toggle wording per the maintainer's ruling; dead DiscoveryDismissed code, banners and a history note removed; close savers cleared with their devices.

Maintainer rulings:

  • TGXL/PGXL Connect directly when available (off uses the radio's relay), AG/ShackSwitch Connect automatically;
  • app-global per device type;
  • a live session continues until it drops, and explicit Connect still works;
  • the status wording, applet hiding on Remove, Remove resetting the toggle, and the extraction behaviour are accepted;
  • the includer growth from the file split is accepted.

Verified empirically:

  • Linux build of the head merged with main.
  • The 9 focused targets pass.
  • Mutations as listed, plus the earlier guard, Add and Remove mutations.
  • The four static gates match main, and check_a11y reports 0 findings.
  • On the demo backend: Add persists across a restart; the toggle wording is right; with the AG toggle off, a discovered Antenna Genius stayed OFFLINE through repeated beacons; Remove confirms, and Cancel keeps the row.

Not verified: a real OS vault, TGXL/PGXL/AG/ShackSwitch hardware sessions, and native accessibility on Windows/macOS. pgxl_panel_test (loopback) was not run on this PR.

@ten9876
ten9876 enabled auto-merge (squash) October 4, 2026 00:49
@ten9876
ten9876 merged commit 3e4b14e into aethersdr:main Oct 4, 2026
5 checks passed
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 4, 2026
main gained aethersdr#6138, aethersdr#6115 and aethersdr#6027 since this branch was cut. No
textual conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 4, 2026
aethersdr#6027 moved RadioSetupDialog::buildPeripheralsTab() into
RadioSetupDialog_Peripherals.cpp and added that file to every test
that compiles the dialog. radio_setup_show_tx_waterfall_test is new on
this branch, so the merge with main left it without the file and it
failed to link: undefined RadioSetupDialog::buildPeripheralsTab().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 4, 2026
main gained aethersdr#6138, aethersdr#6115 and aethersdr#6027 since this branch was cut. No
textual conflict; the touchpoint manifest is regenerated in the next
commit.

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

## Summary

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

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

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

## Changelog

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

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

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

## Docs refresh, not just version strings

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

## Judgment calls flagged for the maintainer

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

## Scope

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

## Validation

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFC] Peripherals UI: device list and Add/Remove workflow

2 participants