Skip to content

feat(ctr2): AetherSDR starts the USB link handshake. Principle VIII. - #6114

Merged
ten9876 merged 4 commits into
mainfrom
ctr2-host-initiated-handshake
Oct 3, 2026
Merged

ten9876 merged 4 commits into
mainfrom
ctr2-host-initiated-handshake

Conversation

@ten9876

@ten9876 ten9876 commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

At the CTR2 firmware author's request, AetherSDR now starts the USB link handshake. The operator presses only Start in the CTR2 Proxy applet; nothing needs pressing on the controller. Link format v0 is otherwise unchanged: same message types and framing, only the side that starts changes. This stays within approved RFC #6091.

Start ─► AetherSDR: HELLO (repeated ~1 s until answered; every 5 s after a failure)
CTR2:   READY        ("I'm here")
AetherSDR: opens a fresh radio connection
AetherSDR: READY (connected)   or   CLOSED (radio unreachable; calls again later)

Behaviour

  • Calling: the host sends HELLO on Start and whenever no link is up. It never queues a HELLO while earlier output to the CTR2 is undelivered, so a controller that isn't reading can't accumulate them, and a pending CLOSED always goes out first.
  • Answering: the CTR2's READY opens a fresh radio connection only as the answer to a HELLO the host has sent since the last link ended. Any other READY is a late answer to an earlier HELLO and is ignored: one during a link, one during the back-off after a radio failure, or one while a CLOSED is still undelivered. It never discards a pending CLOSED or skips the back-off. A READY during a link was previously treated as a fault.
  • Drain first: after the CTR2 sends CLOSED, no HELLO goes out until the old session has finished draining the CTR2's last bytes to the radio.
  • Back-off: after a failed radio connection or any link fault (framing error, incomplete message), every call waits 5 s until a link ends cleanly or Start is pressed again. A stray CLOSED never restarts the call timer.
  • Stop then Start: the old link's CLOSED reaches the CTR2 before the new link's first HELLO. A HID port reopened on the same device waits on its own I/O thread, up to 1 s, until the previous port has written its final reports and closed the device; a port that failed has already closed it. A port the relay refuses is handed back with shutdown(), which never blocks.
  • Restart: a HELLO from the CTR2 still forces a restart, for error recovery.
  • Unchanged: the fence on restart, CLOSED retention (feat(ctr2): CTR2 relay over Wi-Fi (TCP) and USB HID link v0. Principle VI. #6090 follow-up, now also covering a late READY), teardown when AetherSDR leaves its radio, and UDP.

For the firmware

The device side gets simpler. Boot into the waiting state (waiting_for_host = 1). On HELLO, reset the counter and answer READY, then wait for the host's READY (connected) or CLOSED, skipping anything else until one of those headers arrives. docs/ctr2-usb-relay-design.md and tools/ctr2-firmware-reference/README.md show the new flow. The C reference code itself is unchanged; only its header comments moved.

Validation (Linux)

  • New and updated tests in ctr2_usb_relay_test:
    • Start calls with HELLO (counter 0);
    • HELLO is repeated until answered and stops once linked;
    • HELLOs never stack on a stalled port;
    • no HELLO is queued behind an undelivered CLOSED;
    • back-off after a radio failure, held for every call, and not postponed by stray CLOSEDs;
    • a late READY during a link changes nothing;
    • no HELLO while a session is still draining (radio writes stalled by an injected socket).
  • Links in ctr2_usb_relay_test come up the field way: the host's HELLO answered by the device's READY. Device-sent HELLO is covered as the restart case.
  • New in ctr2_hid_thread_port_test (no sockets): a reopened port writes only after the old port's slow final CLOSED, ports for different devices don't wait for each other, and a reopen after a failure writes at once. New in ctr2_proxy_model_test: a port the relay refuses is shut down, not deleted in place.
  • New in ctr2_usb_closed_retention_test (socket-free): a late READY after a radio refusal keeps the undelivered CLOSED, opens no connection, and respects the back-off, while a device HELLO still forces a restart.
  • Mutation checks: each new guard was removed in turn, and each removal turned its test red: HELLO on Start, READY accepted while calling, no stacking, back-off, back-off held for every call, stray CLOSED not restarting the timer, late READY ignored, READY only after a fresh HELLO, no HELLO mid-drain, the reopen wait, release on close, and shutdown of a refused port.
  • Rebased onto current main (579efd73f). All eight CTR2 suites pass; the relay test passed 10 out of 10 repeated runs and the thread-port test 8 out of 8. The app builds, and every static-checks.yml script passes.
  • Socket use: ctr2_usb_relay_test binds loopback TCP and UDP: a generic radio byte peer on 127.0.0.1, the relay's own UDP socket, and UDP peers on 127.0.0.1 and 127.0.0.2. It exits 77 if loopback can't be bound. ctr2_usb_closed_retention_test opens no sockets: its radio socket's connectToHost() only queues an injected refusal. Both are registered in tests/tests.cmake only; the frozen per-PR CTest gate is unchanged.
  • Not verified: the new flow against real CTR2 firmware, which needs the firmware update this describes.

🤖 Generated with Claude Code

@ten9876
ten9876 requested review from a team as code owners October 3, 2026 16:16
@ten9876 ten9876 assigned ten9876 and unassigned ten9876 Oct 3, 2026

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Issue fit

Partially meets the stated intent; request changes. Reviewed head 3712bb55fcfcc722d53ac1e0fd3027b2eae2c15f against merge base c7513e125b0675308407bbe3e8f49ea1244944b8. There is no closing issue; RFC #6091 supplies the approved independent-client relay scope. The new Start → HELLO → device READY path implements the firmware-author request, but restarting is admitted before the previous close/back-off or drain has completed. These are correctness problems, not objections to the requested workflow. Jeremy retains the decision on the changed external HELLO/READY semantics; the PR explicitly discloses them.

Scope

File/group Change Claimed? Verdict
src/core/Ctr2UsbRelay.{cpp,h} Host calls, accepts READY, retries/back-off Yes In scope; findings below
src/core/Ctr2HidFraming.h Direction/counter comments Yes In scope; no codec implementation change
tests/ctr2_usb_relay_test.cpp Handshake, retry, queue and late-answer coverage Yes In scope; socket execution boundary below
docs/ctr2-usb-relay-design.md Updated host/device contract Yes In scope
tools/ctr2-firmware-reference/{README.md,ctr2_link.h} Firmware usage example and comments Yes In scope; C implementation unchanged

Everything in the diff is explained by the stated change. The single commit's authored date is 2026-10-03; no unrelated commits, UI restyles, settings, dependencies, capability changes, or enforcement-baseline growth. The framing/type values are unchanged, while their handshake semantics deliberately change. The body's claims that CLOSED retention and closing behavior remain unchanged do not hold for the sequences below.

Blockers

  1. [P2] A delayed READY discards undelivered CLOSED and bypasses failure back-off — src/core/Ctr2UsbRelay.cpp:568–569.

    sessionEnded() sets m_awaitingDevice=true immediately on refusal, before CLOSED is delivered or another HELLO is sent. Any delayed READY from an earlier HELLO therefore enters onDeviceStart(), whose discardOutput() deletes that pending CLOSED and immediately opens another radio connection. This is possible after multiple host HELLOs produce multiple device answers: an answer arriving after the first connection fails is treated as permission for a new attempt. It regresses the previous CLOSED-retention behavior and defeats the advertised five-second retry delay.

    Reproduced without sockets: injected HID port holds outgoing reports; injected QTcpSocket::connectToHost only schedules errorOccurred and never calls the network implementation. After one refusal, inject a second READY before acknowledging CLOSED:

    PR head:
    before late READY: connections=1 queued=1 type=3
    after late READY: connections=2 queued=0
    FAIL: late READY preserves pending CLOSED
    FAIL: late READY does not bypass retry backoff
    
    Merge base:
    before late READY: connections=1 queued=1 type=3
    after late READY: connections=1 queued=1
    

    The probe uses READY for initiation on the head and falls back to the base's supported device HELLO initiation on the base; the refusal and subsequent late READY are identical. The existing socket-free ctr2_usb_closed_retention_test passes on both revisions and misses this new entry point.

    Fix: distinguish waiting to issue a fresh HELLO from waiting for its answer. Ignore READY during failure back-off and while the prior CLOSED remains undelivered; only admit it for an eligible new handshake. Keep explicit device HELLO's force-restart semantics. Extend the existing injected-transport retention test with this sequence and mutation-check that guard.

  2. [P2] Host retries can abort the previous link's graceful drain — src/core/Ctr2UsbRelay.cpp:558–563.

    Reasoned from code, not runtime reproduced. Device CLOSED marks m_awaitingDevice=true, calls deviceClosed(), and starts a one-second HELLO timer even when the old session is still draining accepted device-to-radio bytes. sendHello() checks outstanding HID output, but not the existing session or radio write queue. With no outbound HID reports and radio writes still pending, HELLO goes out, the device answers READY, and onDeviceStart():578 terminates the old session. That calls ByteRelay::stop() and ultimately aborts the TCP endpoint (ByteRelay.cpp:309–311), dropping bytes before the existing ten-second drain allowance (ByteRelay.h:52). The new host behavior induces this without an explicit device restart request.

    Fix: defer automatic HELLO/READY admission until the draining session has ended; sessionEnded() already schedules the retry. An explicit device HELLO may still force a restart. Cover the draining state with injected transport/state-machine coverage. I did not run a connected-session probe because this Session unconditionally binds UDP on connection, triggering the socket-test permission boundary.

Nits — non-blocking

  • tests/ctr2_usb_relay_test.cpp:826–877: explicitly disclose the socket use in the PR validation text. The named target binds loopback TCP/UDP, including the new refusal probe. Its existing tests/tests.cmake:392–402 block names the sockets and configures exit-77 skipping. I notified the operator before continuing and did not build or execute this target. It runs in the unfiltered full-suite/sanitizer lanes, not the filtered per-PR CTest steps. The claimed eight-suite, 15-repeat and five-mutation results were not independently rerun in this review.

Verification and attacks that held

  • Built the relevant production relay/framing/byte-pump sources in isolated scratch harnesses with Qt 6.11.2 on Linux; no production source edits and no network I/O. The existing socket-free CLOSED-retention test passed on head and merge base. The late-READY probe passed on base and failed on head as quoted above.
  • Additional socket-free head checks passed: Start sends HELLO with counter zero; a stalled HELLO does not accumulate or open a radio connection; acknowledging it permits retries; duplicate READY while Connecting opens only one connection; retry HELLO does not stack behind pending CLOSED; HELLO resumes after CLOSED acknowledgement.
  • Compared engine-boundary, touchpoint-manifest, capability-record and command-plane checks on base/head: identical results, no new blocking findings; manifest current. git diff --check passes. Confirmed firmware C implementation, UDP implementation and radio-switch teardown code are untouched; that is source evidence, not hardware convergence evidence.
  • Required PR checks are successful, but runs were created at 2026-10-03 16:16:13 UTC, before current main fe1fec519 at 17:27:28 UTC. CI uses pull_request merge results and branch protection has strict:false; this green does not establish integration with current main.
  • No GUI/bridge session was launched: the changed path requires CTR2 HID and a dedicated external radio connection, which the demo simulator does not provide. No live radio or operator instance was touched. Actual CTR2 firmware, USB delivery, macOS/Windows runtime and the drain scenario remain unverified. A model-invocable code-review skill was unavailable; this was a manual review with an independent code-reading pass.

Recommendation: Request changes for the two lifecycle defects, then rerun the focused injected-transport checks and refresh integration evidence against current main.

Posting note: GitHub rejected REQUEST_CHANGES because the authenticated account (ten9876) is also the PR author. This is posted as a COMMENT review; the recommendation remains Request changes.

@ten9876 ten9876 removed their assignment Oct 3, 2026
ten9876 added a commit that referenced this pull request Oct 3, 2026
…id-drain. Principle VIII.

Addresses the review on #6114.

- A delayed READY answering a HELLO sent before a radio refusal could be
  taken as a new start: it discarded the still-undelivered CLOSED and
  opened another connection, skipping the 5 s back-off. READY now opens
  a link only after a HELLO the host has sent since the last link ended,
  so back-off and a pending CLOSED are respected. A device HELLO still
  forces a restart.
- After the device's CLOSED, the host no longer schedules HELLO while the
  old session is still draining the device's last bytes to the radio;
  the session's end schedules it, so an answer cannot abort the drain.
- Tests: ctr2_usb_closed_retention_test gains the late-READY sequence
  (socket-free); ctr2_usb_relay_test gains a stalled radio write that
  holds a drain open while no HELLO may go out. Spec updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876
ten9876 force-pushed the ctr2-host-initiated-handshake branch from 3712bb5 to 21f6160 Compare October 3, 2026 18:08
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. Both lifecycle defects are fixed in 21f6160, and the branch is rebased onto current main (fe1fec519), so CI now tests integration with it.

Finding Fix Pinned by
1. A delayed READY discarded the undelivered CLOSED and bypassed the failure back-off A READY opens a link only as the answer to a HELLO the host has sent since the last link ended (m_helloSent). No HELLO goes out during the back-off or while a CLOSED is pending, so a late answer there is ignored. A device HELLO still forces a restart. ctr2_usb_closed_retention_test::testLateReadyKeepsClosedAndBackoff (socket-free). It reproduces your sequence: refusal, CLOSED held unsent, late READY. Connections stay at 1 and the CLOSED survives. With the guard removed, two checks fail.
2. Host retries could abort the previous session's graceful drain After the device's CLOSED, the host only schedules HELLO if no session exists. A draining session schedules it from sessionEnded(). ctr2_usb_relay_test::testNoHelloWhileDraining. An injected radio socket accepts no writes, so the drain stays open until its timeout: no HELLO goes out meanwhile, and it resumes after. With the guard removed, the test fails.
Nit: socket disclosure The PR description's validation section now lists the loopback TCP and UDP binds and notes that the retention test opens no sockets. –

Locally on the rebased head: all eight CTR2 suites pass, the two affected tests passed 30 out of 30 repeated runs, the app builds, and every static-check script passes. The spec's host rules now state that a READY is accepted only after a fresh HELLO, and that a drain completes before the next HELLO.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1. Issue fit

No linked issue. There is no fixes/closes #NNNN in the body, so I reviewed against the PR's own stated intent plus RFC #6091. GOVERNANCE.md wants an RFC for architectural changes, and there is one: #6091 is RFC Approved, and its stage-2 approval is explicitly conditional — "stage 2 (USB HID transport, once the envelope, handshake and descriptor are agreed with the firmware side)" — with the message-type semantics listed in the 2026-10-02 amendment as "Additions for him to confirm: message types in the spare header byte (DATA / HELLO / READY / CLOSED)". Inverting which side sends HELLO lands squarely inside that confirmation loop, so I read the body's "stays within approved RFC #6091" as substantially correct, not a scope violation — with one caveat under Blockers/maintainer call below.

The stated intent is delivered. The host calls on Start (start(), Ctr2UsbRelay.cpp:415), repeats on a single-shot timer, accepts the device's READY only as a fresh answer, keeps the device-HELLO restart path, and holds HELLO behind a drain and behind undelivered output. Each of the seven claimed guards maps to a hunk and to a named test.

2. Scope

File / group What it changes Claimed by title/body Verdict
src/core/Ctr2UsbRelay.{h,cpp} HELLO timer, m_awaitingDevice/m_helloSent, READY-as-answer guard, back-off, drain gate Yes In scope
src/core/Ctr2HidFraming.h Comment-only (verified: the only non-comment delta is the two trailing // comments on the enumerators) Yes In scope
docs/ctr2-usb-relay-design.md Spec: link flow, host rules, device rules. Test-vector table untouched (correct — framing is unchanged) Yes In scope
tools/ctr2-firmware-reference/README.md Reference example follows the new flow Yes In scope (one nit, #2)
tools/ctr2-firmware-reference/ctr2_link.h Comment-only (verified: git diff shows zero non-comment lines) Yes ("only its header comments moved") In scope — claim holds
tests/ctr2_usb_relay_test.cpp 3 new cases, updated wording/assertions Yes In scope
tests/ctr2_usb_closed_retention_test.cpp testLateReadyKeepsClosedAndBackoff Yes In scope

No build-config, CI, plugin or vendored churn. CHANGELOG.md correctly untouched. No new config key, settings key, capability field, CLI flag or exported API; the only new surface is two Tuning ints with defaults, not operator-facing. No sibling relay implementation exists (Ctr2UsbRelay is the only ByteRelay HID consumer; TcpByteProxy is the Wi-Fi path and is untouched by design).

Deleted behavior, named explicitly: linkFault("CTR2 sent READY, which only the host sends") is gone, and testProtocolViolations flips from "READY from the CTR2 is a fault" to "…is ignored". I checked whether this resurrects a symptom and concluded it does not: the deleted comment named no user-visible symptom, READY now has a defined device-side meaning, and a mid-link READY is reachable as a legitimate late answer. Treating it as a fault would tear down a healthy link. Correct removal.

Socket-test disclosure (reporting duty, not a finding): ctr2_usb_relay_test binds loopback TCP (RadioPeer/QTcpServer on 127.0.0.1 ephemeral) and UDP, and this PR adds StalledWriteSocket — an injected QTcpSocket subclass whose writeData returns 0, i.e. a stalled local socket, not a fake radio/firmware peer. Registered at tests/tests.cmake:398-402 with SKIP_RETURN_CODE 77 and TIMEOUT 120; the test std::exit(kSkip)s when loopback cannot bind. ctr2_usb_closed_retention_test (tests.cmake:349-352) opens no sockets. The body discloses both accurately. Neither target appears in any ctest -R gate in ci.yml, so the body's "all eight CTR2 suites pass" is author-local evidence; CI green here means compile + static checks only (build / check-windows / check-macos / Static checks / sanitizer-configure / CodeQL JS+Py all pass on 21f61602; there is no analyze (cpp) run on this head).

3. Blockers

None.

One item for the maintainer, not a blocker:

  1. The RFC that authorizes this handshake is closed, and the firmware author's request is unevidenced. #6091 was closed as completed on 2026-10-03, and the body's load-bearing premise — "At the CTR2 firmware author's request" — appears nowhere in #6091's thread or this PR's. The inversion is within the RFC's own "agree the handshake with the firmware side" condition, so I am not calling it out of scope, but the wire contract for a third party is now defined only by a sentence in a PR body. Ask for a one-line amendment comment on #6091 (or a link to the firmware author's request) recording the inversion, so the next reader of the RFC does not find HELLO (device: start the link), READY (host: radio connected) in the approved text and the opposite in the code. Inline at src/core/Ctr2HidFraming.h:37.

4. Nits (non-blocking)

  • The documented back-off is inaccurate in two ways (inline at Ctr2UsbRelay.cpp:363 and :623). The timer lambda reschedules with helloIntervalMs unconditionally, so helloRetryAfterFailureMs governs only the first retry after a failure; and linkFault applies the same 5 s to framing errors and incomplete-message timeouts, which have nothing to do with the radio. Practically narrow — when the device answers promptly each failure cycle re-arms 5 s via sessionEnded(error=true), so the 1 s cadence only emerges if the device misses the first post-failure HELLO, and HELLO alone never touches the radio. So this is doc/comment accuracy, not a safety hole; docs/…:191 and the Tuning comment at Ctr2UsbRelay.h:38-40 are the two places to reword.
  • waiting_for_host loses its initial 1 in the firmware reference example (inline at tools/ctr2-firmware-reference/README.md:25). The old link_start() set it on entering USB mode; the new answer_hello() sets it only on a received HELLO, and static int waiting_for_host; is zero-initialized. A device that resets or enters USB mode mid-stream now feeds host bytes straight into ctr2_rx_feed/radio_bytes() instead of skipping to the next 0xFF control header — the exact case the design doc's "this skips the tail of anything sent earlier without misreading it" sentence promises. ctr2_link.c/.h are genuinely unchanged, so the body's claim holds; this is in the example only. The design doc's Device-behavior section (docs/…:230) is likewise silent on initial state.
  • A device CLOSED during the failure back-off shortens it. onMessage's Closed case with no session calls scheduleHello(helloIntervalMs) (Ctr2UsbRelay.cpp:568), restarting the single-shot timer at 1 s and discarding the remaining 5 s. The body says a late READY "never … skips the back-off"; an off-spec duplicate CLOSED does. Impact is one HELLO ~4 s early, so cosmetic, but the invariant is stated more strongly than the code holds.
  • The new normal path is tested narrowly. linkUp()/linkUpHeld() still bring the link up with dev.hello(), so the framing-fault, drain, datagram, restart and connect-timeout scenarios all exercise the device-initiated path. Only testHostCallsAndDeviceAnswers and the retention test go through host-HELLO → device-READY. Switching Rig::linkUp() to dev.answer() after a host HELLO would re-point the whole suite at the path that will actually run in the field, with the device-HELLO restart kept as its own case.
  • m_awaitingDevice is a slightly misleading name for a flag that is true while the host is calling; m_noLinkUp or m_linkDown reads closer to what the three guard sites test. Purely naming.

5. What I tried to break (and it held)

Findings below are reasoned from the code at /tmp/aetherclaude/pr-6114; I have no build, no test run and no hardware, so nothing here was reproduced at runtime.

  • Old-firmware interop, the thing a handshake inversion most plausibly breaks. A CTR2 that still HELLOs first works unchanged: onMessage's Hello case routes to onDeviceStart() at any time, with no m_helloSent or state precondition. In the other direction the host's unsolicited HELLO is harmless to old firmware — its waiting_for_host filter skips any header that is not READY/CLOSED, and its switch has no HELLO case — and because sendHello() is gated on m_awaitingDevice, no HELLO is ever emitted mid-link, so no DATA follows a counter reset the old device didn't expect. The version byte is deliberately not bumped and I think that's defensible here, since both handshakes are accepted.
  • Late-READY orderings, the regression the previous review round found. I walked: READY during a link (m_session != nullptr → ignored); READY with a CLOSED still queued (m_helloSent == false → ignored, CLOSED survives, matching testLateReadyKeepsClosedAndBackoff); READY during the back-off (same); multiple READYs answering repeated HELLOs (the first wins, the rest see m_session != nullptr); READY arriving while a newer HELLO is still queued (onDeviceStart's discardOutput() drops the queued HELLO, and if it is already on the wire the device sees HELLO-then-READY and converges). Every ordering converged. I also confirmed the guard is load-bearing by reading the mutation: drop m_helloSent and the retention test's connections == 1 assertion fails.
  • Timer lifecycle across all five scheduleHello callers (start, the lambda, onMessage/Closed, linkFault, sessionEnded) plus the three stop() sites (onDeviceStart, stop, onPortFailed). scheduleHello is guarded on m_port && m_awaitingDevice and the timer is single-shot, so a double-start() cannot double the cadence, and I found no path that leaves it armed while a session is draining — onDeviceStart stops it before the only window where m_awaitingDevice is true with a live session. releasePort() nulls m_port, which makes every later scheduleHello a no-op; ~Ctr2UsbRelay goes through stop().
  • The "cannot accumulate HELLOs" claim. The !m_reportCosts.empty() guard is a host-queue guard: it bounds the queue, which is what the body claims, and the mutation is visible (drop it and pending() == 1 fails). What it does not bound is HELLOs that the OS accepts on behalf of a non-reading device — whether reportsSent fires in that case is a hidapi/OS question I cannot answer here, so the real on-hardware cadence is unverified. The converse failure (a port that never acks, stranding the host at exactly one HELLO forever) is survivable because device-HELLO still works.
  • Stale-generation callbacks. sessionConnected/sessionDraining/sessionEnded all check generation != m_generation, endSession() bumps the generation before the new Session is built, and sessionEnded additionally requires m_session. A terminate() that lands after onDeviceStart has replaced the session cannot reset m_awaitingDevice or re-arm the timer for the new link.
  • Could not check: whether StalledWriteSocket::writeData returning 0 actually produces a ByteRelay drain rather than an immediate transport fault — that determines whether testNoHelloWhileDraining's state() == Closing assertion holds, and it is the one new test whose mechanism I am taking on trust. Also unchecked: Stop→Start reuse of the same USB device, where the old port's asynchronous CLOSED now races a new port's immediate HELLO. The port lifecycle there is pre-existing and releasePort() detaches cleanly, but it is a new ordering surface that this PR creates.
  • Not a finding, just confirmed: the applet's USB status string is tr("Waiting for CTR2") (Ctr2ProxyModel.cpp:360), which stays accurate under the new flow, and no UI string tells the operator to press anything on the controller — so the body's "the operator presses only Start" needs no UI change to be true.

6. Recommendation

Approve with nits, plus one maintainer call. The state machine is the strongest part of this PR: the m_helloSent-as-answer-token design is the right shape, the three guards are individually load-bearing and individually tested, and the two lifecycle defects from the previous round are genuinely fixed rather than papered over. I attacked the handshake inversion from the direction that usually breaks these — old-firmware interop and late/duplicate control messages — and found nothing that misbehaves.

Concrete next step: fix the waiting_for_host = 1 initializer in the reference example (nit #2) before the firmware author builds against it, since that file is a third-party contract; reword the back-off sentence in the design doc and the Tuning comment to match what the timer actually does; and get a one-line amendment onto #6091 recording the handshake inversion, since the RFC is closed and its approved text still says the opposite. The maintainer should decide whether that amendment is a precondition for merge or a follow-up.


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

Comment thread src/core/Ctr2HidFraming.h
Comment thread src/core/Ctr2UsbRelay.cpp Outdated
Comment thread src/core/Ctr2UsbRelay.cpp Outdated
Comment thread tools/ctr2-firmware-reference/README.md Outdated
ten9876 added a commit that referenced this pull request Oct 3, 2026
…id-drain. Principle VIII.

Addresses the review on #6114.

- A delayed READY answering a HELLO sent before a radio refusal could be
  taken as a new start: it discarded the still-undelivered CLOSED and
  opened another connection, skipping the 5 s back-off. READY now opens
  a link only after a HELLO the host has sent since the last link ended,
  so back-off and a pending CLOSED are respected. A device HELLO still
  forces a restart.
- After the device's CLOSED, the host no longer schedules HELLO while the
  old session is still draining the device's last bytes to the radio;
  the session's end schedules it, so an answer cannot abort the drain.
- Tests: ctr2_usb_closed_retention_test gains the late-READY sequence
  (socket-free); ctr2_usb_relay_test gains a stalled radio write that
  holds a drain open while no HELLO may go out. Spec updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876
ten9876 force-pushed the ctr2-host-initiated-handshake branch from 21f6160 to a905c84 Compare October 3, 2026 19:16
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-ups are in a905c84. The branch is rebased on current main (5747057e0), so CI now runs against current main.

  • Back-off: it now holds for every call after a failure, and stray CLOSEDs can't postpone a call. Details are in the resolved threads.
  • Firmware example: it boots with waiting_for_host = 1, and the spec gains a power-up rule.
  • RFC: the amendment is recorded on [RFC] CTR2 controller relay: opaque independent-client TCP proxy, then USB HID transport #6091.
  • Tests: links now come up the field way. Rig::linkUp(), linkUpHeld() and the other link-up sites wait for the host's HELLO and answer READY (Device::answerCall()). Device-sent HELLO stays covered as the restart case (testHelloRestartsWithFreshConnection and the restart loop in testRestartDiscardsQueuedOutput). The test rig calls every 20 ms. Because a HELLO can follow a CLOSED, the CLOSED-ordering check now ignores HELLOs.
  • Naming: m_awaitingDevice is now m_linkDown.
  • StalledWriteSocket does drain, it doesn't fault: testNoHelloWhileDraining sees State::Closing for 300 ms, and the session ends at the 600 ms drain timeout. It passes on this head.

Validation on a905c84:

  • All 8 CTR2 suites pass, and ctr2_usb_relay_test passed 10 out of 10 repeated runs.
  • Two new guards were mutation-checked. Making the timer reschedule at the normal interval breaks "the back-off holds for every call". Restarting the timer on a stray CLOSED breaks "stray CLOSEDs do not postpone the next call".
  • All 11 static-check scripts pass, and the app builds.
  • ctr2_usb_relay_test binds loopback TCP and UDP, as before.
  • Not tested against CTR2 firmware.

ten9876 and others added 4 commits October 3, 2026 12:55
At the firmware author's request, the host now initiates the CTR2 link so
the operator presses only Start in AetherSDR, not also a connect button
on the controller. Link format v0 is unchanged; only who starts changes.

- On Start, and whenever no link is up, the host sends HELLO and repeats
  it about once a second (every 5 s after a radio connection failed),
  never queuing a new HELLO behind undelivered output.
- The device answers READY; that opens a fresh radio connection, and the
  host replies READY (connected) or CLOSED (failed), as before.
- A READY arriving during a link is a late answer to an earlier HELLO and
  is ignored. A device HELLO still forces a restart.
- Spec, firmware reference README and tests updated: host calls first,
  retries until answered and stops once linked, HELLOs never stack on a
  stalled port, back-off after a radio failure, late READY ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…id-drain. Principle VIII.

Addresses the review on #6114.

- A delayed READY answering a HELLO sent before a radio refusal could be
  taken as a new start: it discarded the still-undelivered CLOSED and
  opened another connection, skipping the 5 s back-off. READY now opens
  a link only after a HELLO the host has sent since the last link ended,
  so back-off and a pending CLOSED are respected. A device HELLO still
  forces a restart.
- After the device's CLOSED, the host no longer schedules HELLO while the
  old session is still draining the device's last bytes to the radio;
  the session's end schedules it, so an answer cannot abort the drain.
- Tests: ctr2_usb_closed_retention_test gains the late-READY sequence
  (socket-free); ctr2_usb_relay_test gains a stalled radio write that
  holds a drain open while no HELLO may go out. Spec updated.

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

Review follow-ups on the host-initiated handshake:

- The failure back-off now holds for every call until a link ends
  cleanly or the operator restarts, not just the first retry. It applies
  to any link fault as well as a failed radio connection, so the tuning
  field is helloRetryAfterFaultMs and the spec says so.
- A CLOSED with no link up never restarts a running call timer, so
  stray CLOSEDs cannot postpone the next HELLO indefinitely.
- The firmware reference example boots with waiting_for_host = 1, and
  the spec states the device's power-up state, so a device that resets
  mid-stream skips to the next control header instead of parsing the tail.
- m_awaitingDevice is m_linkDown: it is true while the host is calling
  or the last link is still draining.
- The relay tests bring links up the way the field will: the host's
  HELLO, answered by the device's READY. Device-sent HELLO stays covered
  as the restart case.

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

Stop wrote the old link's CLOSED on the old port's I/O thread while Start
opened a new handle on the same device and sent HELLO from another, with
nothing ordering the two. A CLOSED landing after the new link's READY
would make the CTR2 drop a link AetherSDR still relays, and AetherSDR
never calls while a link is up.

Ports opened with the same device path now reach it in order: the new
port's I/O thread waits, up to 1 s, until the previous port has written
its final reports and closed the device. A port that failed has already
closed it, so a reopen after a failure does not wait. A port the relay
refuses is handed back with shutdown(), which never blocks, rather than
deleted in place, which would wait on that I/O thread.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876
ten9876 force-pushed the ctr2-host-initiated-handshake branch from a905c84 to d63bad1 Compare October 3, 2026 19:58
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Final pass before landing: one more finding, fixed in d63bad1. The head is rebased on main 579efd73f.

Stop→Start could reorder CLOSED and the new link. This is the ordering the review couldn't check. On Stop, the old HID port writes its final CLOSED on its own I/O thread. Start opened a new handle on the same device immediately and sent HELLO from another thread, with nothing ordering the two writes. If the old CLOSED reached the CTR2 after the new link's READY, the CTR2 would drop a link AetherSDR keeps relaying, and AetherSDR doesn't call while a link is up.

  • Ctr2HidThreadPort takes the device path as a key. A new port's I/O thread waits, up to 1 s and never on the GUI thread, until the previous port for that path has written its final reports and closed the device.
  • Closing the device releases it immediately, so a reopen after a failure doesn't wait.
  • Ctr2ProxyModel hands a refused port back with shutdown({}) instead of delete. Deleting waits on the I/O thread, which could now sit in that wait and freeze the UI for up to a second.

Proof:

  • ctr2_hid_thread_port_test:
    • "a new port for the same device writes only after the old port's final CLOSED": order CH. With the wait removed it fails.
    • "ports for different devices do not wait for each other": order HC, which shows the test can see the race.
    • "a reopen after a failure writes at once": under 500 ms. With release-on-close removed it fails.
    • 8 out of 8 repeated runs pass.
  • ctr2_proxy_model_test, "a refused port is handed back with shutdown(), which never blocks": with delete hid restored it fails.
  • All eight CTR2 suites pass, the app builds, and all 11 static-checks.yml scripts pass on the rebased head. All four commits are verified.

@ten9876
ten9876 merged commit c34c814 into main Oct 3, 2026
7 checks passed
@ten9876
ten9876 deleted the ctr2-host-initiated-handshake branch October 3, 2026 20:17
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.

1 participant