fix(icom): Identify radios over CI-V and add optional wake on connect by jensenpat · Pull Request #5438 · aethersdr/AetherSDR · GitHub
Skip to content

fix(icom): Identify radios over CI-V and add optional wake on connect - #5438

Open
jensenpat wants to merge 6 commits into
aethersdr:mainfrom
jensenpat:aether/5164-icom-civ-identity
Open

fix(icom): Identify radios over CI-V and add optional wake on connect#5438
jensenpat wants to merge 6 commits into
aethersdr:mainfrom
jensenpat:aether/5164-icom-civ-identity

Conversation

@jensenpat

@jensenpat jensenpat commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #5164. Supersedes #5360; related to #5349.

An editable RS-BA1 Network Radio Name could select the wrong Icom capability profile. Late identification also left TX-audio routing disabled, and a missed first identity reply could strand startup polling the wrong address. This change identifies the radio from its CI-V 19 00 model-ID payload, keeps the network name as its nickname, and updates audio routing when capabilities arrive.

The reply source independently selects the command destination: FE FE E0 50 19 00 A4 FD identifies an IC-705 at address 50. Auto discovery broadcasts; a pinned selection accepts only its selected responder. Unknown, malformed, conflicting, and stale replies cannot grant transmit capabilities. Discovery sends at most five queries, one second apart; routine polling waits for verified identity. Generic FB/FA acknowledgements cannot finish identification.

Optional wake on connect

The connection panel now has Wake on connect directly beneath Connect to last radio on start up. It defaults off and persists through the existing Icom settings document. When enabled, exhausted identity discovery can request one wake for the network-advertised radio destination, including Auto mode. A responding radio is not woken, and disconnect never sends power-off.

IC-705, IC-7300MK2, and IC-9700 have explicit wake profiles. IC-705 and IC-7300MK2 send the standard FE FE <address> E0 18 01 FD frame through RS-BA1, based on their official CI-V guides. The MK2 guide's baud-dependent FE fill specifically applies to the serial REMOTE jack. Both begin reconnecting after one second and probe identity once per second until it arrives, with a 20-second deadline. A network interface that is fully off cannot receive the command.

IC-9700 retains its measured 150 additional FE bytes, E1 controller address, and 10-second delay from @w5jwp's original #5360 investigation and hardware evidence. This contribution is credited by a co-author trailer and documentation. None of these profiles infer model identity from an editable nickname. IC-7300MK2 standby/wake is verified on hardware below; IC-705 physical standby/wake remains untested.

Wake owns one delayed reconnect and a bounded identity deadline. Auto reads the CI-V destination from the authenticated RS-BA1 capability record (byte 0x94); the editable nickname and default A4 settings seed cannot select the destination. The network address is only a wake/framing hint: model capabilities still require a wire identity reply. Disconnect or a new connection cancels delayed callbacks. Terminal failure stops automatic retry and preserves the failure in the connection UI. Connection advice/progress stays in the connection panel. Status-bar controls are permanent widgets, so temporary messages cannot hide the connect control. The backend refuses invalid model/address selections, identity or pinned-address mismatches, ambiguous buses, and active transmit/tune. The agent automation bridge also exposes explicit civ wake <model-id-hex> <address-hex> through the same model lifecycle.

Validation of this update

  • Full macOS app build passed with QtKeychain enabled using cmake --build build -j22; final source commit 10a9ee63.
  • 10/10 focused Icom CTests passed: identity, family, CI-V, scheduler, protocol, scope, audio, meters, memory, and power derivation. Runs used QT_QPA_PLATFORM=offscreen, -j22, and --no-tests=error, with executables, settings, and FFTW cache staged under /private/tmp.
  • The identity test injects literal CI-V frames into unstarted session objects; it opens no radio session sockets or firmware peer. New checks cover default-off policy, bounded wake requests, model/address refusal, transmitter refusal, cancellation, terminal failure, and identity completion. The Auto-mode regression uses an A4 settings seed and a B6 network destination, then drives the real backend-to-RadioModel wake path. Post-wake probes continue beyond five seconds and stop on identity. Separate IC-705 and IC-7300MK2 fixtures verify exactly one standard wake frame at a custom address and matching-identity completion. Literal codec checks cover the power-on frame and FE preamble.
  • Mutation check: removing the wake transmitter guard makes the refusal assertion fail. Restoring it returns the final suite to 10/10.
  • The agent automation bridge verified two isolated headless app processes: checkbox unchecked initially, positioned immediately below the startup checkbox, then checked after toggling and restarting with the same disposable settings store. Screenshot inspected. No radio connection or power operation was requested for this UI validation.
  • Engine-boundary, test-registration, frozen CI-gate, bridge-documentation, colour/style ratchet, and whitespace checks pass. The checkbox uses the existing ThemeManager styling path; no new direct stylesheet call is added. Engine-boundary reports its existing 102 tracked warnings and no blocking violations.

IC-7300MK2 hardware wake and reconnect UX

The revised Auto-mode flow was exercised on the test Mac with the radio in standby, TX pinned off, and an isolated settings copy. With wake off, identity remained unanswered. The status-bar connect control stayed visible and opened the connection panel; the checkbox was clickable. Enabling wake and reconnecting sent power-on to B6, identified IC-7300MK2, produced fresh serial/audio data, and disconnected cleanly with transmitting=false. The operator also confirmed wake and autoconnect worked.

The second standby run on final commit 10a9ee63 completed in 9.29 seconds from Connect to verified identity, versus 15.48 seconds with the old ten-second pause. The trace contains exactly one FE FE B6 E0 18 01 FD wake command, followed by identity probes approximately one second apart; the first valid identity arrived 3.939 seconds after the wake command. Fresh serial and audio data were verified, the status-bar control and checkbox remained usable, and cleanup disconnected the radio and released the test lock with TX disabled. The operator confirmed this faster run worked perfectly. The overall time includes approximately five seconds of initial identity discovery before wake. IC-9700 retains its measured delay. IC-705 physical wake remains untested.

Earlier hardware evidence, separate from this update

At cc3b8aa0, three fresh-process receive-only checks on the operator's IC-7300MK2 identified B6, preserved its custom name, initialized the audio route, received fresh audio, and disconnected cleanly. Model/audio readiness was 0.242 s, 0.238 s, and 0.236 s. All three received the first identity reply; dropped-first-reply recovery was covered separately by the injected timer test and its mutation check. Local TX streaming initialization did not mean RF transmission.

The earlier three-start identity checks have not all been repeated on this update; the new MK2 standby/wake evidence is described separately above. No RF transmission was requested. The bridge is sufficient for the checkbox checks; physical standby/wake behavior still requires model-specific hardware validation. CI results are separate from the local evidence above. No merge is requested.

Generated with OpenAI Codex (GPT-6 Astra)

@jensenpat
jensenpat marked this pull request as ready for review September 5, 2026 15:15
@jensenpat
jensenpat requested a review from a team as a code owner September 5, 2026 15:15
@jensenpat jensenpat changed the title Fix Icom model detection for custom network radio names fix(icom): Fix Icom model detection for custom network radio names Sep 5, 2026

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1. Issue fit

Yes, on the core defect. #5164's mechanism — unknownModel() at connect → connectionStateChanged evaluates takesTxAudioOverSeam && canTransmit once → late capabilitiesChanged never re-runs the TX-audio lifecycle → feedDaxTxAudioInternal drops every TCI block — is closed by rebinding to RadioModel::capabilitiesChanged (MainWindow_Session.cpp:753), which RadioModel::publishCapabilities (src/models/RadioModel.cpp:4112) emits on the connect edge, the disconnect edge, and every backend capability revision. The idempotent helper is exactly the "Suggested implementation direction" the issue asked for, and removing modelForName() from the connect path is the issue's "do not infer hardware identity from the nickname" taken literally.

Acceptance criteria mapping: custom name can't alter TX capability ✅ (onSessionConnected now unconditionally &unknownModel()); late false→true re-evaluates without restart ✅; regression covering custom name → unknown → CI-V reply → TCI PCM → submitTxAudio() ✅ (tests/icom_identity_test.cpp:116-142 drives the real AudioEngine::feedDaxTxAudio and asserts zero frames before identity, one after — it would fail against unfixed code); RX-only fail-closed ✅ (icom_identity_test.cpp:202-206); no duplicate capture for Flex/Sim/HL2 ✅ (:193-201, plus the !isTxStreaming() / !isRxStreaming() guards). Live dummy-load proof is claimed nowhere in the body — that criterion is unmet and worth stating.

One confirmed collateral breakage (Blocker 1): tests/icom_backend_test.cpp is a registered CTest target that this PR does not touch and that asserts the now-removed behavior.

2. Scope

File / group What it changes Claimed? Verdict
MainWindow.h, MainWindow_Session.cpp replaces the connectionStateChanged TX-audio lambda with applyTxAudioCapabilities on capabilitiesChanged yes — this is the fix in scope
AudioEngine.{h,cpp} new applyBackendAudioCapabilities, queued to the audio thread yes — the idempotent helper in scope
IcomCivBackend.{h,cpp} adoptReportedCivAddressadoptCivIdentity; name-based seeding/capability grant deleted; 5×1 s retry loop; meter/link polling gated on identification; publishModelControls() extracted partly — the nickname removal is issue-directed, but the identity/destination state machine is rewritten well past the minimum in scope, but the largest and least-covered part of the diff
IcomModels.{h,cpp} modelForCivAddressmodelForId; parseModelIdReply hardened (size, source, destination) yes in scope
IcomSession.h, IcomSettings.h comment-only yes in scope
profileMap() gains modelId; civAddress/healthSnapshot now report the live session address not in the title or the design docs' change list — documented only in docs/automation-bridge.md new bridge surface arriving as a side effect; maintainer call, small
docs ×3 design/bridge/tx-path notes yes in scope
tests/icom_{family,memory,meters,power_derivation}_test.cpp mechanical modelForCivAddressmodelForId, plus a real behavioral update in icom_family_test.cpp:255-265 (name no longer grants GPS; identity frame injected) yes in scope
tests/icom_identity_test.cpp, tests.cmake new socket-free target yes in scope

No CHANGELOG.md entry — correct. No settings-schema, credential, dialog or a11y surface touched.

Socket-test surface (disclosure, not a finding): this PR adds no socket-owning test and no fake peer. icom_identity_test constructs a real IcomSession but never calls start(); frames enter through onCivFrame directly (tests/icom_identity_test.cpp:22-41). It links aethercore and joins AETHER_SETTINGS_CONSUMERS. It is not on the frozen PR gate — it first runs on full-suite.yml after merge, per AGENTS.md.

3. Blockers

1. icom_backend_test asserts behavior this PR deletes, and it is not on the PR gate — so green CI does not cover it

src/core/backends/icom/IcomCivBackend.cpp:1366 now calls publishIdentity() unconditionally at onSessionConnected, before emit connected() at :1396. publishIdentity() always sets r.model (:~5900), and m_model is kUnknown whose name is "Unknown Icom" (IcomModels.cpp:161).

tests/icom_backend_test.cpp:2376 snapshots publishedModel inside the connected() handler and asserts:

check(modelAtConnect.isEmpty(),
      "without misreporting the custom nickname as an early hardware model");

That is now "Unknown Icom", not empty. icom_backend_test is registered at tests/tests.cmake:602-605 and is deliberately absent from the per-PR -R allow-list (.github/workflows/ci.yml:487 even carries a comment about it having fallen off a gate before), so all five green checks on cc3b8aa prove nothing about it — it runs on full-suite.yml after merge.

I read this rather than ran it, but the chain is mechanical: unconditional publishIdentity() before connected(), r.model always assigned, kUnknown.name non-empty.

The surrounding block (:2328-2385) and the "CI-V ADDRESS RESOLUTION" section that follows (:2400+, CivCase, retarget and two-responder cases against FakeIc705) were written for the old state machine. Please rerun that target locally and update it in this PR — either assert "Unknown Icom" as the honest pre-identity placeholder, or, if this is the moment to retire that fake-peer fixture under the #5254 remediation, delete the affected cases and say so in the body. Landing with it stale means the first full-suite run after merge fails on main.

2. A pinned CI-V address that doesn't match the radio now fails silently in the one place the old code was loudest

IcomCivBackend.cpp:1673-1678 drops any 19 00 reply whose source differs from a pinned address before adoptCivIdentity can compare them. The deleted code (old adoptReportedCivAddress) emitted a named warning for exactly this case:

"This radio reports CI-V address %1, not the %2 that was entered. The entered address is being used."

with a comment stating why it was load-bearing: "on a point-to-point radio it means the typed address is simply wrong and the symptom (a connected radio that answers nothing) names no cause at all."

Post-merge, a pinned-address typo produces five dropped replies and then only the generic exhaustion text ("Check its network CI-V settings and selected CI-V address"), which never mentions that the radio did answer and did name a different address. The reply is in hand; naming it costs one branch. This is the removed-guard-whose-symptom-recurs case from §3 — I'd rather see it restored than argued away, but it's a narrower loss than the old code's, so if you disagree, say so in the body and I'll defer.

4. Nits (non-blocking)

  • tests/tests.cmake:574-577icom_identity_test constructs a live AudioEngine, which reads AppSettings (AudioEngine.cpp:1855, :4121, :5117). It is correctly added to AETHER_SETTINGS_CONSUMERS (:4341) but does not use tests/TestSettingsProfile.h, so it reads and can write the developer's real store. anan_settings_test.cpp:35 and antenna_alias_test.cpp:29 show the one-line pattern. I could not find a canon sentence mandating it, so: convention, hence a nit.
  • profileMap()'s modelId is genuine new bridge surface. It's documented and read-only, but it belongs in the PR body's change list, not only in docs/automation-bridge.md.
  • AudioEngine.cpp:1 puts core/backends/RadioCapabilities.h above AudioEngine.h, so the own-header-first convention no longer proves AudioEngine.h is self-sufficient. Cosmetic.
  • The body claims nothing about live proof. #5164's last acceptance bullet asks for dummy-load evidence (fresh TX bytes, nonzero forward power, safe unkey). Worth stating plainly that it wasn't run, rather than leaving it unmentioned.

5. What I tried to break (and failed)

  • "capabilitiesChanged doesn't cover disconnect, so capture leaks." It does: RadioModel.cpp:1991 wires connectionStateChanged → publishCapabilities(connected), and applyBackendAudioCapabilities's (wasSeamAudio || seamAudio) && isTxStreaming() branch is strictly wider than the old seamTxAudio && isTxStreaming() — it closes capture even when the backend has already reset canTransmit, which the old code did not. Genuine improvement, not a regression.
  • "audioStartTx/audioStartRx did more than the raw engine calls." They don't — MainWindow.cpp:4657-4677 are bare QMetaObject::invokeMethod wrappers onto m_audio. Substituting startTxStream/startRxStream inside a lambda already queued to m_audio is equivalent, and pcAudioEnabled/pcAudioRequired reduce algebraically to the same predicate set (hostModulates is the only discriminator, and it's still checked before RX starts).
  • "A silent radio now reconnect-loops for five seconds." It doesn't. The CI-V stall watchdog in onLinkTick runs above the new m_civReported == 0 gate, but it is driven by profileFor(*m_model).civRecovery, and only the IC-9700 declares one (IcomModels.cpp:653); kUnknown has none. Discovery therefore can't trip the reconnect path.
  • "The rejected-identity early return starves the scheduler." onMeterTick still calls pumpCiv(now) before its own gate (IcomCivBackend.cpp:5980), so dispatch keeps draining while unidentified — including the emergency unkey queued by the ambiguity branch. Reads expire on kReadTimeoutMs = 350 anyway, well inside the 1 s retry.
  • "Ambiguity now fires on the radio's own duplicate reply." adoptCivIdentity:1273 treats same-address/same-ID as inert before the conflict branch, so a retry landing after a late first reply is absorbed. icom_identity_test:139-140 pins it.
  • "Withdrawing the model traps a keyed radio." No: setKeying is now gated on key && !hasTransmit (:4591), so unkey always reaches the radio, and the conflict branch calls setKeying(false) before reassigning m_model and without reverting the session address — Principle VI holds through the withdrawal. icom_identity_test:169-178 pins both halves.
  • Sibling call sites: modelForCivAddress has zero remaining references tree-wide; modelForName has zero production callers and survives only in tests, matching its new header comment. AudioEngine::applyBackendAudioCapabilities has exactly two callers (MainWindow_Session.cpp:2746, the new test).
  • Couldn't check: anything runtime. No build, no ctest, no GUI, no radio. Every finding above is reasoned from the head checkout at /tmp/aetherclaude/pr-5438, not reproduced. In particular I could not confirm whether icom_backend_test's CivCase block (:2400+) also breaks — it depends on FakeIc705's broadcast-reply behavior against the hardened parseModelIdReply, which I can only settle by running it.

6. Recommendation

Request changes, narrowly. The fix for #5164 is right, well-reasoned, and the new socket-free test genuinely fails against unfixed code — this is a good change. But it lands with a stale assertion in a registered target that the PR gate does not run, so the breakage surfaces on main rather than here, and it drops a diagnostic whose deleted comment says exactly why it existed. Concrete next step: build and run icom_backend_test locally, update (or knowingly retire) the :2328-2385 block and whatever else in :2400+ moves, and restore the pinned-address mismatch warning inside adoptCivIdentity. The modelId bridge field is a small maintainer call, not a blocker.

Thanks for a clear diff — the comment discipline on the deleted state machine made this much easier to audit than it would otherwise have been.


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

// Network Radio Name is operator-defined presentation text. Even a name
// matching a supported model cannot grant capabilities before CI-V replies.
m_model = &unknownModel();
publishIdentity();

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.

Blocker — breaks an existing registered test that this PR does not update.

publishIdentity() runs here, before emit connected() at :1396, and it always assigns r.model — which is now kUnknown.name = "Unknown Icom" (IcomModels.cpp:161). The old code took the else branch for an unresolved name and emitted nickname only, with no model.

tests/icom_backend_test.cpp:2376 snapshots publishedModel inside its connected() handler and asserts:

check(modelAtConnect.isEmpty(),
      "without misreporting the custom nickname as an early hardware model");

That is now non-empty. icom_backend_test is registered (tests/tests.cmake:602-605) but is not on the frozen per-PR -R allow-list, so the green checks on cc3b8aa say nothing about it — it runs on full-suite.yml after merge, on main.

Reasoned from code, not run. Please build that target and either update the assertion to expect the honest "Unknown Icom" placeholder, or retire the affected FakeIc705 cases under the #5254 remediation and disclose that in the body. The CivCase block at :2400+ exercises the retarget/two-responder paths this PR rewrote and likely needs the same treatment.

Comment on lines +1673 to +1678
// Reject invalid/foreign ID replies before they can retire a scheduler
// transaction. Model ID and source need not match on a customized bus.
if (frame.cmd == cmd::kReadId
&& (!parseModelIdReply(frame)
|| (m_civAddressPinned && m_session
&& frame.from != m_session->civAddress()))) {

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.

Blocker — this drops the reply before the one comparison that could explain the failure.

A pinned address that doesn't match the radio's actual address is rejected here, so adoptCivIdentity's own m_civAddressPinned && address != m_session->civAddress() check at :1270 is unreachable for it and nothing is ever said.

The deleted adoptReportedCivAddress emitted a named warning for precisely this case:

"This radio reports CI-V address %1, not the %2 that was entered. The entered address is being used."

with a comment recording why: "on a point-to-point radio it means the typed address is simply wrong and the symptom (a connected radio that answers nothing) names no cause at all."

After this change the operator gets five silently-dropped replies and then only the generic exhaustion text, which never mentions that the radio answered and named a different address. The information is in frame.from; surfacing it costs one branch before the return. Suggest checking the pinned mismatch and emitting the warning (once) rather than dropping silently.

Comment thread tests/tests.cmake

# Socket-free CI-V identity and late TX-audio lifecycle. IcomSession is never
# started; literal replies enter the existing injected frame-handler seam.
add_executable(icom_identity_test tests/icom_identity_test.cpp)

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.

Nit, non-blocking: this target constructs a live AudioEngine, which reads AppSettings on construction and in the sidetone/NR paths (AudioEngine.cpp:1855, :4121, :5117). Joining AETHER_SETTINGS_CONSUMERS at :4341 is correct, but without tests/TestSettingsProfile.h the test reads — and can write — the developer's real store, which makes it order-dependent with any other settings-touching test in the same run.

One line, matching anan_settings_test.cpp:35 / antenna_alias_test.cpp:29:

TestSettingsProfile profile(QStringLiteral("icom-identity-test"));

(plus #include "TestSettingsProfile.h"). I couldn't find a canon sentence making this mandatory, so it stays a nit.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Antares Detector — candidate vulnerable file(s)

  • src/core/backends/icom/IcomModels.cpp
  • src/core/backends/icom/IcomSession.h
  • src/core/backends/icom/CivCodec.h

The Icom backend implements UDP packet handling and radio-name deserialization; raw UDP payloads are parsed without length validation, enabling unsafe deserialization or out-of-bounds reads (e.g., N1MMSpotParser).

Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting.


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

@jensenpat jensenpat self-assigned this Sep 5, 2026
Integrate the IC-9700 network wake contribution with wire-based identity discovery, a bounded cancellable reconnect, and a persisted connection checkbox. Credit the original wake investigation and framing from PR aethersdr#5360.

Co-authored-by: W5JWP <justin@w5jwp.radio>
@jensenpat jensenpat changed the title fix(icom): Fix Icom model detection for custom network radio names fix(icom): Identify radios over CI-V and add optional wake on connect Sep 5, 2026
@aethersdr-agent

Copy link
Copy Markdown
Contributor

CI on b1a0a507: one job failed, Static checks (run 33979686820), at the Hardcoded-colour ratchet step. Everything else in that job passed, and build / check-macos / check-windows were still running when I looked — this is not a compile failure. It's a one-line fix.

What the gate said

I reproduced it locally by running exactly what CI runs (tools/audit_colours.py --src src --compare-src <merge-base>/src --summary-only --strict, merge base 50d70ebe):

=== ratchet (vs PR base) ===
  OK   unique_colours       613  (base 613, +0)
  OK   total_references    2724  (base 2724, +0)
  OVER setstylesheet       1088  (base 1087, +1)

FAIL: this PR raises the hardcoded-colour count above its base.
  setstylesheet: 1088 > 1087  (+1)

Only the third counter moved, and only by one. The single new call site is the Wake on connect checkbox:

src/gui/ConnectionPanel.cpp:1235

wakeOnConnect->setStyleSheet(lowBandwidthCheckStyle);

Nothing is wrong with the colours — you reused the existing lowBandwidthCheckStyle string, so unique_colours and total_references are both +0. The ratchet counts raw setStyleSheet( call sites, not colours (tools/audit_colours.py:105), so this is purely about which API paints the widget.

Completely understandable how it happened: you copied the shape of the checkbox immediately above it —

m_autoConnectCheck->setStyleSheet(lowBandwidthCheckStyle);   // :1145

— which is a pre-existing, grandfathered call site. The ratchet only forbids new ones, so the neighbour is fine and yours isn't. That asymmetry is invisible from the surrounding code.

The fix

Route it through ThemeManager::applyStyleSheet() instead, which is what four other sites in this same file already do with this exact same style string (:965, :979, :1006, :1020):

AetherSDR::ThemeManager::instance().applyStyleSheet(wakeOnConnect, lowBandwidthCheckStyle);

core/ThemeManager.h is already included at ConnectionPanel.cpp:51, so no new include. I applied just that line in a scratch worktree on your head commit and re-ran the gate:

=== ratchet (vs PR base) ===
  OK   unique_colours       613  (base 613, +0)
  OK   total_references    2724  (base 2724, +0)
  OK   setstylesheet       1087  (base 1087, +0)

Green, exit 0. This is also a small behavioural upgrade rather than a workaround: applyStyleSheet() registers the widget for live theme switching, so the checkbox re-paints on themeChanged instead of keeping its startup colours (ThemeManager.h:158-167). The neighbouring m_autoConnectCheck doesn't — worth converting in the same commit if you like, but that's optional and outside this fix.

You can check it locally before pushing:

python3 tools/audit_colours.py --src src \
  --compare-src <merge-base worktree>/src --summary-only --strict

On the reviewer comments

Separate from CI — none of them caused this failure, and the two blockers won't show up on the PR checks either:

  • icom_backend_test assertion at tests/icom_backend_test.cpp:2376 — worth taking seriously precisely because CI can't tell you about it. That target is registered but isn't on the frozen per-PR -R allow-list, so it only runs on full-suite.yml after merge. If publishIdentity() now always assigns r.model = "Unknown Icom", modelAtConnect.isEmpty() is no longer true. Building that one target locally would settle it either way.
  • Silently dropped pinned-address mismatch (IcomCivBackend.cpp:1695) — a UX point, not a build one. The old named warning told the operator the radio answered from a different address; now they get five silent drops and generic exhaustion text.
  • AudioEngine test and tests/TestSettingsProfile.h — non-blocking nit, but it does keep the test off the developer's real settings store.

Nice work on this one — the identity-from-19 00 rework plus the evidence you wrote up (including being explicit about which hardware checks were not re-run on this update) is a genuinely high bar. It's a colour-ratchet technicality standing between you and a green board, nothing more. Thanks for putting the time in.


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

Use model-specific standard CI-V power-on frames from the official guides over RS-BA1, retaining the IC-9700 hardware-derived profile. Verify both added models at custom addresses and preserve opt-in policy. Route checkbox styling through ThemeManager.
Use the RS-BA1 advertised CI-V destination for wake while preserving wire model identity. Keep connection guidance in the panel and status controls permanent. Replace the standard-profile ten-second pause with early reconnect and bounded per-second identity probing.

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

Reviewed at head 10a9ee63. Built the PR on Windows/MSVC (Ninja, Qt 6.10.3) and ran icom_identity_test with real stdout, plus a mutation check and a targeted probe of the wake framing path.

Two findings from the previous review do not survive contact with tests.cmake — please don't spend time on them. Details in "What I tried to break" below.

1. Issue fit

The #5164 fix is right. Rebinding the TX-audio lifecycle to capabilitiesChanged via the idempotent AudioEngine::applyBackendAudioCapabilities, and identifying from the 19 00 payload instead of modelForName(), is exactly the "one idempotent helper … do not infer hardware identity from the nickname" direction the issue asked for. Every acceptance criterion maps to a hunk except the last — live dummy-load proof (fresh TX bytes, nonzero forward power, safe unkey). The body doesn't claim it; worth saying so plainly rather than leaving it unmentioned.

2. Scope

File / group Change Claimed? Verdict
MainWindow.h/_Session.cpp, AudioEngine.{h,cpp} rebind TX audio to capabilitiesChanged yes — the fix in scope
IcomCivBackend, IcomModels, CivCodec (identity) adoptCivIdentity, modelForId, hardened parseModelIdReply, 5x1 s retry yes in scope
Wake feature (PowerOnProfile, power.wake, wakeIcomRadio, checkbox, wakeOnConnect) ~578 lines / 23 files, added after the last review yes in body, not in #5164 cleared on governance — see below
civ wake bridge verb + profileMap modelId new automation surface verb yes; modelId only in docs/automation-bridge.md nit
addWidget -> addPermanentWidget global, all radio families yes, with rationale disclosed; #3500 interaction checked, holds
configurationWarning -> connection panel Icom-only in practice yes Blocker 2
docs x3, mechanical test renames yes in scope

No CHANGELOG.md entry — correct.

Governance: the wake feature is cleared, and I want to be explicit about why. At first read it looks like a large unratified feature bundled into a bug fix. It isn't: #5349 carries your own ruling — "I support this for wake" as an option, with sleep-on-disconnect explicitly ruled out because "a remote user can lock themselves out of their radio". This PR implements precisely that: opt-in, default off, wake-only, no power-off on disconnect. No RFC needed. The only process note is that the wake half has no linked issue of its own (#5349 is referenced as "related" only), which makes it harder to find later.

3. Blockers

1. An IC-9700 at a non-default CI-V address silently gets the wrong wake frame

populateFamilyParams inserts icom.wakeModelId only when civSelection() == Model. In Auto and Custom it is absent, so modelId == 0, which bypasses the powerOn profile requirement in power.wake. The only 9700 rescue is the hardcoded address == 0xA2 && advertisedCivAddress() == 0xA2, so a 9700 whose CI-V address was changed misses it and falls through to standard{}.

I drove power.wake through the real backend on the PR head. Same radio, only the address differs:

# IC-9700 at DEFAULT 0xA2 — correct
PROBE frame len=470: fe fe fe fe ... (150x fe) ... a2 e1 18 01 fd

# IC-9700 at CUSTOM 0xB0 — wrong
PROBE frame len=20:  fe fe b0 e0 18 01 fd
PROBE started=1 err=

Zero preamble bytes and controller E0 instead of the measured 150xFE / E1. It returns started=1 with no error, the radio never wakes, and the 20 s deadline expires. This defeats the exact hardware evidence @w5jwp contributed in #5360, in a configuration this file's own comments call out as real ("on a shared CI-V bus … the operator SELECTING WHICH DEVICE to talk to").

Suggested direction: carry wakeModelId for Custom/Auto as well, or key framing off the advertised/identified model rather than the literal 0xA2 — and refuse rather than silently sending standard framing when a 9700 is indicated but unprofiled.

2. Mid-session Icom configuration warnings now render into a hidden widget

Moving configurationWarning from statusBar()->showMessage(message, 15000) to m_connPanel->setStatusText(message) is sound for connect-time advice, and the rationale (don't cover the Connect control) is good. But setStatusText is a bare m_statusLabel->setText() with no visibility guarantee, and m_connPanel->hide() runs on the connected edge (MainWindow.cpp:6070).

configurationWarning also carries mid-session warnings — the out-of-band tune refusal (IcomCivBackend.cpp:3594) and the DATA MOD-input advisory (:1636, whose comment says it exists to "NAME THE REMEDY, because this client deliberately will not apply it unasked"). Those now go somewhere the operator cannot see. Routing by connection state, or keeping showMessage while connected, would preserve both goals.

This one is a judgement call — if you'd rather keep it uniform, say so and I'll defer.

4. Nits (non-blocking)

  • profileMap()'s new modelId is genuine bridge surface documented only in docs/automation-bridge.md; it belongs in the body's change list.
  • wakeIcomRadio connects extensionResult as DirectConnection, calls invokeExtension, then disconnects and reads sent — assuming a synchronous reply. IRadioBackend.h:797 documents the opposite ("A synchronous QVariant return would have to block or fabricate a local value against an async backend"). Safe today because the path is gated on m_family == "icom" and that backend replies inline, but it's a trap if it ever defers. An assertion or a comment naming the coupling would pin it.
  • onConnectionError early-returns during wake before the qCWarning(lcProtocol), so wake-path connection errors leave no log line.
  • No test injects a mismatched identity after wake, so the "The radio identified as a different model." branch — which disconnects an otherwise-connected radio — is uncovered. Every completion case injects a matching reply.
  • IC-705 physical wake untested; the body says so, which is the right call.

5. What I tried to break (and failed)

  • The previous review's Blocker 1 (icom_backend_test breaking on main) is refuted. That target sits inside a CMake bracket comment, tests/tests.cmake:584-606, retired under the #5254 fake-peer remediation — and it was already commented out on origin/main before this PR. It is not registered, cannot fail CI, and per AGENTS.md a bracket-commented test isn't coverage either way. The stale assertions are in dead code; at most a nit, and arguably just history.
  • Its Nit 1 (the new test writing the developer's real settings store) is also refuted. icom_identity_test.cpp:108-109 sets AETHER_SETTINGS_DIR from a QTemporaryDir before AudioEngine is constructed, so it is properly isolated.
  • The new test is not a rubber stamp — I inverted it. Replacing the wake TX guard if (m_keyed || m_tuning || m_pendingPttIntent.value_or(false)) with if (false) and rebuilding produced FAIL: wake refuses an active transmitter (2 failures, exit 1). Restored: icom_identity_test: 0 failure(s), exit 0, on Windows/MSVC.
  • Principle VI holds. Wake sends 18 01, never a keying command, and refuses while m_keyed || m_tuning || m_pendingPttIntent. The commit trailers are apt.
  • addPermanentWidget vs #3500. #3500 (a08e66f9) fixed width/minimum-size clipping, not widget placement, and updateStatusBarMinimumWidth() still runs. No regression there.
  • Test-boundary preflight is clean. The PR adds no socket-owning test and no fake peer; icom_identity_test never calls session.start() — frames enter via onCivFrame.
  • Could not verify: anything needing a real Icom. The wake path is unreachable in demo mode (SimBackend isn't Icom and wakeIcomRadio is family-gated), so the checkbox's GUI behaviour is reasoned from code plus your hardware evidence. Blocker 1 is reproduced at the backend level, not on a physical 9700 — if the framing fallback is deliberate for some reason I've missed, I'd rather hear it than have you work around it.

6. Recommendation

Request changes, narrowly — Blocker 1 only; Blocker 2 is yours to rule on.

The #5164 fix is correct and genuinely well-tested, and the wake feature is properly scoped, default-off, TX-safe and maintainer-sanctioned. But it silently sends the wrong frame to the one model whose framing was hardware-measured, in a configuration the code itself describes as common — and the frame dumps are above.


Reviewed by Claude Opus 5 on aurora13 for @nigelfenton. Built and ran the PR head; frame dumps and the mutation check are from that build.

Comment thread src/models/RadioModel.cpp
req.params.insert(QStringLiteral("icom.wakeOnConnect"), IcomSettings::wakeOnConnect());
if (IcomSettings::civSelection() == IcomSettings::CivSelection::Model) {
// An explicit model choice may authorize power framing, never capabilities.
req.params.insert(QStringLiteral("icom.wakeModelId"), IcomSettings::civAddress());

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.

Blocker 1. icom.wakeModelId is inserted only for CivSelection::Model. In Auto and Custom it is absent, so power.wake receives modelId == 0 — which bypasses the powerOn profile requirement and falls through to standard framing.

For an IC-9700 at a non-default CI-V address this sends fe fe <addr> e0 18 01 fd (0 preamble, controller E0) instead of the measured 150xFE / E1. Verified on the PR head:

# 0xA2 (default): fe fe ...150x fe... a2 e1 18 01 fd   (len 470)
# 0xB0 (custom):  fe fe b0 e0 18 01 fd                 (len 20)

Carrying the model id in the other two selection modes would close it — the wire identity still gates capabilities, so this only affects framing:

Suggested change
req.params.insert(QStringLiteral("icom.wakeModelId"), IcomSettings::civAddress());
req.params.insert(QStringLiteral("icom.wakeOnConnect"), IcomSettings::wakeOnConnect());
// Framing hint only: an address never grants capabilities, so carrying it in
// every selection mode cannot widen what the radio is trusted to be. Omitting
// it for Auto/Custom sent an IC-9700 at a non-default address standard framing.
req.params.insert(QStringLiteral("icom.wakeModelId"), IcomSettings::civAddress());

// the later identity reply. An explicit model also handles custom 9700s.
const PowerOnProfile standard{};
const IcomModel* framing = selected;
if (!framing && address == 0xA2 && m_session->advertisedCivAddress() == 0xA2) {

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.

Blocker 1 (the other half). This is the only rescue for the IC-9700's measured framing, and it is keyed to the literal default address. A 9700 whose operator changed its CI-V address advertises the new value (byte 0x94), misses this branch, and gets standard{} — silently, with sent: true.

The comment above says the IC-9700 hint is retained "for its default advertised destination", so the narrowness looks deliberate — but the effect is that the one model with hardware-verified framing is also the one that breaks when its address is customised, which is a supported and (per IcomSettings.h) expected configuration on a shared bus.

Either derive framing from the advertised/identified model rather than the literal 0xA2, or refuse the wake when a 9700 is indicated but no profile can be selected — a refusal the operator can see beats a frame the radio ignores.

this, &MainWindow::onWanCertFingerprintMismatch);
connect(&m_radioModel, &RadioModel::radioWakeProgress, this,
[this](const QString& message, bool active) {
m_connPanel->setStatusText(message);

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.

Blocker 2 (judgement call). Good rationale for connect-time advice, but configurationWarning also carries mid-session warnings, and m_connPanel->hide() runs on the connected edge (MainWindow.cpp:6070). setStatusText is a bare m_statusLabel->setText() with no visibility guarantee.

So the out-of-band tune refusal (IcomCivBackend.cpp:3594) and the DATA MOD-input advisory (:1636) now render into a hidden panel. The latter's own comment says it exists to "NAME THE REMEDY, because this client deliberately will not apply it unasked" — it can't do that unseen.

Routing by connection state keeps both properties:

Suggested change
m_connPanel->setStatusText(message);
qCWarning(lcProtocol).noquote() << "radio configuration:" << message;
// Connect-time advice belongs in the panel, where it cannot cover the
// Connect control. Once connected the panel is hidden, so a warning put
// there would never be seen — mid-session advice stays on the status bar.
if (m_radioModel.isConnected()) {
statusBar()->showMessage(message, 15000);
} else {
m_connPanel->setStatusText(message);
}

Comment thread src/models/RadioModel.cpp
QString failure = tr("Wake is unavailable for this backend.");
// The extension replies synchronously. The reserved local ID is scoped to
// these connections and cannot consume another caller's asynchronous reply.
constexpr quint64 requestId = std::numeric_limits<quint64>::max();

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.

Nit, non-blocking. This assumes invokeExtension replies synchronously — the connections are DirectConnection and both are disconnected before sent is read. IRadioBackend.h:797 documents the opposite contract: "A synchronous QVariant return would have to block or fabricate a local value against an async backend."

It's correct today because the path is gated on m_family == "icom" and that backend emits inline, but nothing here would fail loudly if it ever deferred — sent would simply stay false and wake would report "unavailable for this backend". A one-line comment naming the coupling (or an assert) would keep the next reader out of the trap.

Comment thread src/models/RadioModel.cpp
const bool matches = m_radioWakeModel.isEmpty()
|| m_backend->capabilities().model == m_radioWakeModel;
finishRadioWake(matches ? tr("Radio ready.")
: tr("The radio identified as a different model."), matches);

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.

Nit, non-blocking. This branch disconnects a radio that is connected and identified, purely because it isn't the model the wake targeted — but no test injects a mismatched identity after a wake. Every completion case in icom_identity_test.cpp injects a matching reply, so the failure half of this comparison is uncovered.

Given the branch tears down a working session, it seems worth one fixture: wake as 0xA2, answer 19 00 with B6, assert the disconnect and the message.

@Ozy311 Ozy311 self-assigned this Sep 6, 2026

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

Operator-requested second opinion at 10a9ee63d94a72ca516f7322a950e4b08052f0e9. Request changes for two additional reproduced P2 defects, detailed inline: native CW cancellation is lost after identity withdrawal, and the permanent full-width status container hides transient status messages.

The identity/TX-audio approach addresses #5164: the CI-V model-ID payload selects capabilities independently of the nickname and operating address, and late capability changes reapply audio routing. Live dummy-load acceptance remains unverified in this review. Optional wake is explicitly claimed in the title/body and related to #5349.

Scope

File group Change Claimed? Assessment
IcomCivBackend, IcomSession, IcomModels, CivCodec CI-V identity, parsing, bounded discovery, capability withdrawal Yes Central fix; CW cancellation defect below
AudioEngine and MainWindow capability wiring Late audio-routing updates Yes Relevant to #5164; local tests pass
IcomProtocol, IcomSettings, RadioModel Opt-in wake profiles, reconnect/deadline/cancellation Yes Related #5349 scope
ConnectionPanel and MainWindow session/status UI Wake checkbox, advice, persistent controls Yes Relevant support; status-message regression below
AutomationServer Explicit civ wake and diagnostics Yes Disclosed surface; normal human owner review applies
Tests and tests.cmake Socket-free identity/wake and protocol/model coverage Yes Passing suite misses the CW interleaving
Three documentation files Backend, audio-path and bridge contracts Yes Supporting scope; generated docs check passes

All 32 changed files fit these groups. No governance, changelog, or frozen CI gate changes. Author-reported hardware evidence was not independently repeated.

Verification

  • Built the exact-head macOS application and focused targets with Qt 6.11.1, RelWithDebInfo; ASR, RADE, DSTAR and DFNR disabled locally.
  • All 10 focused Icom CTests passed after restoring the temporary probes: identity, family, CI-V, scheduler, protocol, scope, audio, meters, memory and power derivation (12.68 seconds).
  • A socket-free probe through real RadioModel/CwxModel/backend code failed on missing CW abort after identity conflict. Temporarily aborting before capability withdrawal made the same assertion pass; restored both production and test sources, rebuilt, and reran the original suite.
  • Drove the exact-head application offscreen through an isolated authenticated local bridge, with isolated settings, muted PC output, TX disabled and outbound IP traffic denied. Verified the owned PID, disconnected state and zero slices; clicked the RX step control, observed Step: 250 Hz in QStatusBar.statusMessage, and captured the status bar without that text. Stopped the owned instance afterward.
  • Engine-boundary strict check passed (102 tracked legacy warnings, zero blocking findings); test registration, frozen CI gate, generated bridge docs and whitespace checks passed.
  • All five current CI checks pass, including actual Linux/macOS/Windows build steps. All five commits have valid GitHub signature verification. The Icom identity test is not run by the per-PR CI gate; it was run locally here.

PR base/merge base: 50d70ebe9fdc4d6f01171b5cb17479bc89537be2. Trusted current-main governance snapshot: d58e2b8a94af82f273f60010df17b080e6b2d907; manifest PASS, Constitution mirror matches. Checked intervening main changes for stale overwrites in these paths; none found.

I am not duplicating the existing custom-address IC-9700 wake and hidden connection-panel warning threads. The new status-bar finding affects other showMessage() callers and remains even if the existing warning-routing issue is corrected.

No physical wake, live RF/TX, microphone, forward-power/unkey acceptance, full-suite or sanitizer-runtime test was performed. The CW probe establishes missing cancellation dispatch, not measured continued RF. No additional nits. Please fix the two reproduced paths and cover their regressions; required human CODEOWNERS approval remains separate.

Reviewed with Codex for @Ozy311.

Comment on lines +1284 to +1287
if (m_keyed || m_tuning || m_pendingPttIntent.value_or(false)) {
setKeying(false);
}
m_model = &unknownModel();

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.

[P2] Abort native CW before withdrawing the identified profile

If a conflicting identity arrives after native CW text has been dispatched, this branch withdraws the model without issuing the radio-side CW abort. The PTT/tuning release here does not cancel the separate CI-V text buffer. Publishing unknown capabilities also removes hasRadioSideCwKeyer, so RadioModel.cpp:2432–2436 stops forwarding CwxModel::transmissionCancelled; a subsequent Clear/ESC therefore cannot send 17 FF for text already dispatched.

Reproduced using the existing socket-free identity-test seam and the real RadioModel/CwxModel/backend path: identify IC-705, send TEST, wait for its frame, inject acknowledgement, optionally inject a conflicting Auto-discovery reply, then call clearBuffer():

CW_ABORT_PROBE conflict=0 automatic_aborts=0 after_clear=1
CW_ABORT_PROBE conflict=1 automatic_aborts=0 after_clear=0
FAIL: identity withdrawal or subsequent clear must dispatch 17 FF

Temporarily calling abortCwText() before withdrawing the profile made the same assertion pass (automatic_aborts=1, after_clear=1). Disconnect already sends a native abort; the probe checked before disconnect so cleanup could not mask the missing command. No socket peer or physical RF was used.

Please abort native CW to the previously selected destination before invalidating the profile, and add a regression case for cancellation after identity withdrawal. The existing passing identity suite does not cover this interleaving.

Comment thread src/gui/MainWindow.cpp
hbox->addWidget(timeStack);

statusBar()->addWidget(m_statusBarContainer, 1);
statusBar()->addPermanentWidget(m_statusBarContainer, 1);

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.

[P2] Preserve visible space for transient status messages

Making the entire stretched status container permanent leaves no usable area for Qt to paint temporary messages: those are drawn before permanent widgets, while this container occupies virtually the full bar. Existing showMessage() calls across radio families therefore lose visible feedback, including unsupported-command notices, memory-recall refusals and “TX is owned by another station.” This is separate from the existing configuration-warning/hidden-connection-panel thread.

Confirmed in the exact-head app through its isolated bridge while disconnected and TX-disabled: clicking RxApplet/Step size up changed 100 to 250 and set QStatusBar.statusMessage to Step: 250 Hz, but the captured bar displayed none of that message. The permanent child remained visible at x=2, width 1398, in a 1400-pixel bar. A Qt before/after harness likewise showed zero changed pixels after showMessage() with the permanent container, versus 2645 with the previous normal-widget arrangement.

Please retain visible transient-message space while keeping the required Connect controls available; making the whole full-width container permanent suppresses the existing feedback channel. The Icom unit tests do not exercise this rendering behavior.

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

Reviewed at head 10a9ee63, but the load-bearing part of this review is the one thing the three earlier passes could not have done: I merged this branch with current main and built the result. It does not compile. Everything below the scope table is new; I have deliberately not re-litigated the IC-9700 custom-address wake framing, the hidden connection-panel warning, or the CW-cancellation finding, which belong to their existing threads.

1. Issue fit

Yes on #5164. Rebinding the TX-audio lifecycle to capabilitiesChanged through the idempotent AudioEngine::applyBackendAudioCapabilities, and identifying from the 19 00 model-ID payload instead of modelForName(), is exactly the "one idempotent helper … do not infer hardware identity from the nickname" direction the issue asked for. Every acceptance criterion maps to a hunk except the last (live dummy-load proof of fresh TX bytes / nonzero forward power / safe unkey), which the body correctly does not claim.

The issue thread's two tightening asks are both satisfied, and I checked them rather than assuming:

  • AppSettings::save() per capability edgeMainWindow_Session.cpp:2750-2753 writes only inside connected && pcAudioRequired && !savedPcAudio, so an unchanged value does not re-open the #4273 non-atomic write race.
  • notePcAudioEnabled() repeated with an unchanged value — it does re-enter checkModInput() on every edge, but that path dedupes on m_lastModInputWarning (IcomCivBackend.cpp:1618-1620), so no repeated DATA OFF MOD advisory.

2. Scope

File / group Change Claimed? Verdict
MainWindow.{h,_Session.cpp}, AudioEngine.{h,cpp} TX audio rebound to capabilitiesChanged via applyTxAudioCapabilities / applyBackendAudioCapabilities yes — the fix in scope
IcomCivBackend, IcomModels, CivCodec, IcomProtocol (identity) adoptCivIdentity, modelForId, hardened parseModelIdReply, parseCapabilitiesCivAddress, bounded 5×1 s discovery yes in scope
Wake feature (PowerOnProfile, power.wake, wakeIcomRadio, checkbox, wakeOnConnect) ~578 lines yes in body; not in #5164 cleared on governance under the #5349 ruling (opt-in, wake-only, no sleep-on-disconnect) — already established on this PR
civ wake bridge verb + profileMap modelId new automation surface verb yes; modelId docs-only nit, already raised
addWidgetaddPermanentWidget; configurationWarning → connection panel global / Icom-in-practice yes, with rationale existing threads
docs ×3, mechanical test renames, icom_identity_test yes in scope

No CHANGELOG.md entry — correct. Test-boundary preflight is clean: the PR adds no socket-owning test and no fake peer. icom_identity_test constructs real IcomCivBackend / IcomSession / AudioEngine / RadioModel objects but never calls session.start(); frames enter through onCivFrame (tests/icom_identity_test.cpp:32-41), and AETHER_SETTINGS_DIR is pointed at a QTemporaryDir at :107-108 before AudioEngine is constructed.

3. Blocker

1. Merged with current main, this branch does not compile — and main's own CI would be the first thing to find out

mergeable is CONFLICTING. That alone is routine; what is not routine is what is on the other side of the conflict. Two commits landed on main after this PR's merge base 50d70ebe:

  • 4bf9c5a6fix(icom): preserve finite AX.25 transmit audio over RS-BA1 (#5311)
  • 6797007ffix(icom): Restore durable memories and safe synced recall

#5311 added tests/icom_ptt_authority_test.cpp, a registered CTest (tests/tests.cmake:638-643, not bracket-commented), and it calls the function this PR removes:

tests/icom_ptt_authority_test.cpp:96:    const IcomModel* ic705 = modelForCivAddress(0xA4);
tests/icom_ptt_authority_test.cpp:199:   const IcomModel* ic9700 = modelForCivAddress(0xA2);

I merged pr5438-review into origin/main in a scratch worktree, resolved both conflicts conservatively (combining the TX-audio gate, keeping both verb blocks, keeping both test registrations) and built:

FAILED: CMakeFiles/icom_ptt_authority_test.dir/tests/icom_ptt_authority_test.cpp.o
tests/icom_ptt_authority_test.cpp:96:30: error: 'modelForCivAddress' was not declared in this scope
tests/icom_ptt_authority_test.cpp:199:31: error: 'modelForCivAddress' was not declared in this scope

This PR does the mechanical modelForCivAddressmodelForId rename in icom_family_test, icom_meters_test, icom_memory_test and icom_power_derivation_test — correctly. It could not have renamed the one in icom_ptt_authority_test, because that file did not exist when the branch was cut.

Why the five green checks do not cover this. The Linux build job runs cmake --build build -j$(nproc) (.github/workflows/ci.yml:195) — all default targets, tests included — so this is a build-job failure, not merely a full-suite one. And branch protection deliberately does not require branches to be up to date (ci.yml:22-25 explains why), so nothing forces a re-run against the current tip. The breakage lands on main.

The second conflict is semantic, and resolving it the obvious way silently reverts #5311. Both sides edit the same submitTxAudio gate:

// merge base
if (m_tuning || !m_keyed) {
// this PR
if (m_tuning || !m_keyed || !m_model->hasTransmit) {
// main, after #5311
if (m_tuning || !txAudioGateOpen()) {

txAudioGateOpen() (IcomCivBackend.cpp:3455-3461) is #5311's bounded key-on window — the thing that keeps the AX.25 FCS and postamble from being dropped. Taking "ours" here compiles fine and quietly undoes that fix. I resolved it as if (m_tuning || !txAudioGateOpen() || !m_model->hasTransmit), which is what I believe you want, but it is your call and it needs a test.

Concrete next step: merge main in, update icom_ptt_authority_test's two modelForCivAddress calls to modelForId, combine the TX-audio gate deliberately, and re-run the focused Icom set plus icom_ptt_authority_test on the merged tree. I ran the pre-merge set here and it is clean (below), so this is genuinely the only thing standing between the branch and a build that holds on main.

4. Nits (non-blocking)

  1. IcomSettings.h:32-35 — the two new declarations were inserted between the username() doc comment and username() itself, so "The operator's network username on the radio. NOT a secret…" now reads as documentation for wakeOnConnect(). Suggestion inline.
  2. RadioModel.cpp:1210 — wake completion is defined as canTransmit, not "identity arrived". Every model in kModels transmits today, so this is latent, but an RX-only Icom (an IC-R receiver) would be force-disconnected by the 20 s deadline after a wake that actually succeeded, via finishRadioWake(..., false). A comment naming the coupling, or keying completion off m_civReported != 0, would pin it.
  3. ConnectionPanel.cpp:1153 — "Wake on connect" is visible and enabled for every family. I confirmed it on the bridge in a session where only a FLEX-8600, a Hermes-Lite 2 and the demo simulator were discovered: visible: true, enabled: true. Only the accessible description says "Icom"; the visible label does not. A FLEX-only operator sees a control that cannot do anything.
  4. IcomProtocol.cpp:459 — the comment ("civ at record offset 0x52, after the 0x42-byte envelope header") is arithmetically right but reads as a contradiction next to pkt[0x94], especially since parseCapabilitiesName uses absolute 0x52. Bounds themselves are fine: startsWith pins pkt.size() == 0xA8, and 0x94 < 0xA8.

5. What I tried to break, and could not

  • Ambiguity latch as a permanent TX lockout. m_civAmbiguous = true disables transmit for the session and adoptCivIdentity returns early forever after — but it is cleared on both connectRadio (:842) and session reset (:977), so a shared-bus collision does not survive a reconnect. Held.
  • Repeated capability edges duplicating capture or thrashing settings. Both tightenings from the issue thread are implemented; see §1. Held.
  • OOB read in the new parseCapabilitiesCivAddress. Size-pinned, index in range. Held.
  • The pre-merge Icom suite. Built the PR head on Arch/GCC 15/Qt 6.10 RelWithDebInfo and ran the full icom_ set offscreen: 16/16 passed in 13.79 s, including icom_identity_test (13.48 s). No local failures to attribute.
  • Bridge session, demo simulator only. Isolated AETHER_SETTINGS_DIR + scratch HOME, AETHER_AUTOMATION_NO_TX=1, explicit socket aethersdr-pr5438, own PID. connect list offered DEMO-0001; get radio returned {"model": "AetherSDR Demo", "serial": "DEMO-0001", "transmitting": false}. The operator's FLEX-8600 was discovered and never touched.
    • Checkbox placement claim confirmed: Connect to last radio on start up at y=618, connectionWakeOnConnect at y=652 — directly beneath, as the body says.
    • Persistence claim confirmed across a process boundary: toggled it on, killed the process, relaunched against the same store, checked = true.
    • Sim path not regressed by the rewiring: two disconnect/reconnect cycles, orphanCount: 0, transmitting: false throughout.
  • Corroboration for the existing hidden-warning thread (not a new finding, but the bridge settles the mechanism): while connected, invoke connectionWakeOnConnect returns {"error": "refused: 'connectionWakeOnConnect' is not visible", "hidden": true} — the whole connection panel is hidden on the connected edge, so everything routed to setStatusText is unreachable mid-session. Worth noting that adoptCivIdentity's ambiguity notice, "Conflicting CI-V identification replies. Transmit is disabled; select the radio's CI-V address and reconnect." (IcomCivBackend.cpp:1296-1298), goes through that same configurationWarning path. That one is a transmit-disabled notice, which raises the stakes on that thread beyond the tune-refusal and DATA MOD advisories already cited.
  • Generated-docs drift: tools/gen_bridge_docs.py --checkok: docs verb table matches the registry (71 verbs).
  • Could not verify: anything needing a real Icom — the wake path is family-gated and unreachable in demo mode. I did not independently re-run the IC-9700 frame dumps or the CW-cancellation probe from the other two reviews; I have no evidence against either.

6. Recommendation

Request changes, on Blocker 1 only. The #5164 fix is right, the identity state machine is well-commented and survived the attacks I could mount against it, and the new socket-free test is real coverage rather than a rubber stamp. But the branch is three days stale against a main that grew a registered test for the exact subsystem this PR rewrites, and the merged tree does not compile — with the added trap that the second conflict can be "resolved" into a silent revert of #5311. That is a merge-and-fix, not a redesign.


Reviewed by Claude Opus 5 for @jeremy_fielder. Merged, built and ran the branch locally on Arch Linux; the compiler output and bridge JSON above are from that run.

// has ~130 CI-V addresses and this table has a handful.
[[nodiscard]] const IcomModel* modelForCivAddress(std::uint8_t addr);
// Look up the model ID payload returned by CI-V 19 00. Unknown IDs return
// nullptr; a configured command address is never an input to this lookup.

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.

Blocker 1 — this rename breaks the build once merged with current main.

modelForCivAddress has zero remaining references on this branch, and the four Icom tests that used it are correctly updated here. But main grew a fifth caller after this branch was cut — tests/icom_ptt_authority_test.cpp, added by #5311 (4bf9c5a6) and registered at tests/tests.cmake:638-643:

tests/icom_ptt_authority_test.cpp:96:    const IcomModel* ic705 = modelForCivAddress(0xA4);
tests/icom_ptt_authority_test.cpp:199:   const IcomModel* ic9700 = modelForCivAddress(0xA2);

Merging this branch into origin/main locally and building gives:

FAILED: CMakeFiles/icom_ptt_authority_test.dir/tests/icom_ptt_authority_test.cpp.o
tests/icom_ptt_authority_test.cpp:96:30: error: 'modelForCivAddress' was not declared in this scope
tests/icom_ptt_authority_test.cpp:199:31: error: 'modelForCivAddress' was not declared in this scope

The Linux build job runs cmake --build build -j$(nproc) over all default targets (ci.yml:195), so this is a build failure on main, not just a full-suite one — and branch protection deliberately does not require branches to be up to date, so nothing re-runs CI against the tip.

Both call sites are pure renames (modelForId(0xA4) / modelForId(0xA2); the model IDs equal those factory addresses). Please merge main in and fix them here.

While you are in the merge: the other conflict is in IcomCivBackend::submitTxAudio and is semantic, not textual. main replaced !m_keyed with !txAudioGateOpen()#5311's bounded key-on window that preserves the AX.25 FCS and postamble — while this branch added !m_model->hasTransmit to the same line. Resolving it as "ours" compiles and silently reverts #5311. I built it as if (m_tuning || !txAudioGateOpen() || !m_model->hasTransmit), which I think is what you want, but it is your call and it deserves a test.

// The operator's network username on the radio. NOT a secret — the radio
// pairs it with a password and the username alone grants nothing.
// Client-owned, opt-in connection policy, stored in the Icom document.
static bool wakeOnConnect();

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.

Nit, non-blocking: these two declarations landed between the username() doc comment and username() itself, so "The operator's network username on the radio. NOT a secret…" now reads as documentation for wakeOnConnect().

Suggested change
static bool wakeOnConnect();
static QString username();
static void setUsername(const QString& username);
// Client-owned, opt-in connection policy, stored in the Icom document.
static bool wakeOnConnect();
static void setWakeOnConnect(bool enabled);

(the following static QString username(); / static void setUsername(...) lines then go away — this suggestion replaces the block through them.)

Comment thread src/models/RadioModel.cpp
connect(m_backend.get(), &IRadioBackend::capabilitiesChanged, this,
[this] { publishCapabilities(isConnected()); });
[this] {
publishCapabilities(isConnected());

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.

Nit, non-blocking: wake completion is defined as canTransmit, not as "identity arrived".

Every model in kModels has hasTransmit = true today — only kUnknown does not — so this is latent rather than live. But an RX-only Icom (an IC-R receiver) that woke and identified perfectly would never satisfy this branch, and the 20 s deadline below would then call finishRadioWake(..., false) and disconnect an already-working radio, reporting "Wake did not complete."

Either key completion off identity (m_civReported != 0, which is what "the radio answered" actually means) or leave a comment naming the coupling, so whoever adds the first receive-only Icom sees it.

});
root->addWidget(m_autoConnectCheck);

auto* wakeOnConnect = new QCheckBox(tr("Wake on connect"), this);

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.

Nit, non-blocking: this checkbox is added unconditionally, so it is visible and enabled for every radio family. I confirmed it on the automation bridge in a session where only a FLEX-8600, a Hermes-Lite 2 and the demo simulator were discovered — no Icom anywhere:

{"objectName": "connectionWakeOnConnect", "class": "QCheckBox",
 "text": "Wake on connect", "checked": false,
 "visible": true, "enabled": true}

Only the accessible description mentions Icom; the visible label does not. A FLEX-only operator sees a control that cannot do anything for their radio. Either gate the widget on the selected radio's family, or say "Icom" in the label.

(The a11y work here is otherwise better than its neighbours — accessible name and description where m_autoConnectCheck has neither. Placement matches the body's claim exactly: y=618 for "Connect to last radio on start up", y=652 for this one.)

Resolves the branch against current main, which grew three Icom commits
after this branch was cut: aethersdr#5311 (bounded PTT key-on window and the
txAudioGateOpen TX-audio gate), aethersdr#5436 (native controls and meter
precision) and aethersdr#5435 (scheduler poll fairness).

Two textual conflicts, both in code this branch rewrites:

  * IcomCivBackend::submitTxAudio — main replaced `!m_keyed` with
    `!txAudioGateOpen()`; this branch added `!m_model->hasTransmit`.
    Both terms are kept. txAudioGateOpen() follows PTT intent inside
    aethersdr#5311's bounded window so a finite AX.25 packet's resampler tail is
    not dropped; hasTransmit refuses audio while identity is still
    unresolved and m_model is unknownModel(). Keeping only one of them
    would either submit audio against an unidentified radio or reopen
    aethersdr#5311.
  * The Icom extension verb chain and tests.cmake — pure additions on
    both sides (power.wake beside debug.ax25.capture.*,
    icom_identity_test beside icom_ptt_authority_test and
    icom_control_profile_test). Both sides kept.

Three stale callers of the removed modelForCivAddress() came in with
main and are renamed to modelForId(): icom_ptt_authority_test (x2) and
memory_import_test. Every call passes a factory-default CI-V address,
which equals that model's 19 00 model ID, so the rename is behaviour
preserving.

Two merged-tree test failures fixed, both fixtures that predate the
semantics this branch and aethersdr#5311 introduce:

  * icom_control_profile_test::prepareSession did not mark identity
    verified, so the new "routine polling waits for verified identity"
    gate made onLinkTick() return before queueing. The fixture now sets
    m_civReported/m_civModelId, which is the state a real session
    reaches through a 19 00 reply.
  * icom_identity_test asserted that the ambiguity branch clears
    m_keyed. Since aethersdr#5311 m_keyed is radio state, moved only by a decoded
    1C 00 readback; the client owns the unkey INTENT and the address it
    goes to. The assertion now pins both, plus the literal
    "fe fe 50 e0 1c 00 00 fd" leaving for the already-selected
    destination rather than the conflicting responder — strictly
    stronger than the flag it replaces.

Verified on the merged tree: 19/19 Icom + memory_import CTests pass,
all seven static checks pass (engine boundary, touchpoint manifest,
test registration, frozen CI gate, network timeouts, theme seed, shader
dialects), gen_bridge_docs --check is clean at 72 verbs, and AetherSDR
itself links. Three mutations confirm the rewritten assertions still
pin behaviour: dropping the ambiguity unkey, retargeting that unkey to
the conflicting responder, and forcing the routine-poll gate closed
each fail the corresponding test.

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

ten9876 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

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.

Icom TCI transmit audio is silently dropped when RS-BA1 Network Radio Name is customized

4 participants