Repository navigation
fix(rit): carry the slice id through the RIT/XIT seam; HL2 holds them per receiver. Principle I. - #6109
Conversation
… per receiver. Principle I. RadioModel forwarded SliceModel's per-slice RIT/XIT to four slice-less IRadioBackend verbs, so on a multi-receiver HL2 any slice's RIT/XIT overwrote one radio-wide value, and after a transmit-slice handoff the TX register kept the previous slice's XIT while the new slice showed XIT off. The seam now follows FlexLib's model (Slice.cs RITOn/RITFreq/XITOn/ XITFreq, sent as "slice set <n> rit_*/xit_*"): setSliceRitEnabled/ setSliceRitOffset/setSliceXitEnabled/setSliceXitOffset(int sliceId, ...). The XIT-offset default still forwards to the RIT offset for Icom's single register, and Icom ignores the id (one slice, always the TX slice). HL2 keeps RIT/XIT on each Receiver: RIT shifts that receiver's own receive, XIT reaches the TX NCO register from whichever receiver owns transmit, so a handoff picks up the new owner's XIT with no re-send. emitSliceState() publishes the held values back; SliceModel adopts them only when they change, and setRit()/setXit() announce the corrected value. Fixes #6105 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
There was a problem hiding this comment.
Issue fit
Yes, on the code. #6105's structural defect — RadioModel capturing s but forwarding [this] and dropping the slice id — is closed: both lambdas now capture s and pass s->sliceId(), and all four IRadioBackend verbs carry it. Consequence 1 (cross-receiver overwrite) is closed by moving ritOn/ritHz/xitOn/xitHz onto Receiver and dropping the ownsTx gate in rxTunedHz(). Consequence 2 (XIT readout vs TX register after a handoff) is closed by setTxFrequency() reading rx(m_txDdc)->xitOn/xitHz instead of a radio-wide member — every caller (tune 2842, handoff 3162, calibration 4513, pushInitialState 7029) and removePanadapter's retuneReceiver(m_txDdc) therefore pick up the new owner's XIT with no re-send. The optional readback item is done too.
The FlexLib citation holds: Slice.cs:1482-1553 does keep RITOn/RITFreq/XITOn/XITFreq per slice and sends slice set <n> rit_on=/rit_freq=/xit_on=/xit_freq= per slice. Principle I framing is accurate.
My one reservation is governance, not code — see "Needs maintainer decision".
Scope
| File(s) | What it changes | Claimed? | Verdict |
|---|---|---|---|
IRadioBackend.h |
Four verbs gain int sliceId; XIT-offset default still forwards to the RIT offset |
Yes | In scope |
hl2/Hl2Backend.{h,cpp} |
RIT/XIT move to Receiver; ownsTx gate dropped; setTxFrequency reads the TX owner's XIT; setTxSlice RIT block and logRitScope() removed; clampRitXit() extracted; emitSliceState publishes the four fields |
Yes | In scope |
icom/IcomCivBackend.{h,cpp} |
Renamed overrides, Q_UNUSED(sliceId), scrubDrive passes sliceId() |
Yes | In scope |
icom/IcomControls.cpp |
Three seamVerb strings renamed |
Yes | In scope — report-only metadata (ControlSpec::seamVerb, IcomControls.h:77-80); no gate or doc pins these |
models/RadioModel.cpp |
Lambdas capture s, pass s->sliceId() |
Yes | In scope |
models/SliceModel.cpp |
setRit/setXit emit the members; applyChanges guards the RIT/XIT pairs |
Yes — body's readback section | In scope |
tests/hl2_rit_xit_test.cpp, tests/slice_rit_xit_seam_test.cpp, tests/tests.cmake |
Rewritten/new coverage, registration | Yes | In scope |
No unrelated files, no new public surface beyond the seam the issue asked for (verb signatures change; no new protocol verb, settings key or capability field), no CHANGELOG.md entry, no socket test added or removed (slice_rit_xit_seam_test injects a RecordingBackend through setBackendForTest — no QTcpServer/QUdpSocket/bind/listen/connectToHost, and no fake radio peer).
On the - lines specifically: the deleted setTxSlice() RIT re-tune block and logRitScope() are the #6012 "RIT follows transmit" behaviour this change supersedes, not a lost guard — nothing re-asserts a value that no longer exists. The deleted comment "RIT belongs to the transmit receiver, so it leaves one and joins the other" names behaviour, not a symptom, and the six hl2_rit_xit_test checks that asserted it are correctly inverted rather than dropped. The ncoMovedForRit comment losing "or transmit moved away" is right for the same reason; the flag itself is per-Receiver already.
No RFC gap: GOVERNANCE.md:111-112 exempts "Bug fixes with a clear root cause" and "Protocol compliance fixes matching SmartSDR behavior".
Blockers
None. I could not construct a case where the final state is wrong.
Needs maintainer decision
-
The fix shape was #6105's open question, and I can't find the ruling that settled it on C. #6105 is still
state: openwith themaintainer-reviewlabel, and its only thread comment is the agent triage, which recommends option B, floats a third shape (slice id on the two RIT verbs only, XIT TX-gated, "a non-TX slice's XIT is meaningless everywhere"), and closes with "Not eligible for implementation until the fix shape is chosen andaetherclaude-eligibleis added." The body's quoted #6012 ruling ("RIT/XIT stay radio-wide and follow transmit … Per-slice RIT/XIT through the seam is filed as #6105") is not in #6012's comment thread either — it may well be in a PR review body, whichlist_issue_commentsdoesn't return, so I'm reporting this as unlocated, not as absent. The second half — that option C was chosen for #6105 — I could not find anywhere.Three reasons this is worth a deliberate ruling rather than a shrug: the triage argued B was cheaper because
RadioModel.cppsits under #5554 §2.6 / #5262 M4 and C's signature change is "itself M4-shaped work"; GOVERNANCE.md:62-63 is explicit that "Changes that touchsrc/gui/,src/core/, orsrc/models/always require maintainer review regardless of domain"; and C has a real advantage over B the triage itself identified, which this PR should get credit for — under B, CAT/TCI/rigctl writes to a non-TX slice (TciProtocol.cpp,SmartCatProtocol.cpp:924/937,RigctlProtocol.cpp) would still updateSliceModeland light the readout while nothing reached the radio. C closes that; B leaves it. So I'd rule for C on the merits. It just needs to be the maintainer's recorded call, and ideally a comment on #6105 plus the label.Worth noting in C's favour against the triage's third shape: this PR stores XIT per receiver but only ever lets the TX owner's reach the single TX register, so it already honours "one transmitter" without making a non-TX slice's XIT do anything.
Nits (non-blocking)
- Enable-then-offset publishes a stale offset mid-flight (
RadioModel.cpp:1650). Final state is correct on every path I walked, but the intermediateritChangedis new and uncovered. Detail inline. clampRitXit()runs before the!rnull check (Hl2Backend.cpp:4416,:4447) — an offset aimed at a slice that isn't running logs a clamp warning for a receiver that doesn't exist.kRitXitMaxHz = 9999vs FlexLib's ±99999 (Hl2Backend.h:588). Pre-existing from #6012, but this PR makes it operator-visible for the first time via the readback, and the PR's own authority disagrees with it. Detail inline.logRitScope()'s removal leaves no RIT trail in a support bundle (Hl2Backend.h:584). It was added deliberately in response to #6012 review item 3. Detail inline.- One test check doesn't prove what its message says (
hl2_rit_xit_test.cpp:203) — the trailingsetTxSlice(1)is the already-ours early-return branch. The real handoff regression at :209-211 is sound.
What I tried to break, and failed
- Stale verb names and un-overridden siblings. Grepped the whole head checkout (not just
src/):setRitEnabled|setRitOffset|setXitEnabled|setXitOffsetappear in exactly zero files. Nodocs/,tools/,scripts/orresources/reference;docs/automation-bridge.md:4149'srit.offsetrow carries noseamVerb, so it isn't stale;icom_meters_test.cpp:568pins a different row (setSliceFmDtcs);hal-plugin/has noIRadioBackendsubclass. Because all four areoverride, a missed sibling would be a compile error, andcheck-macos/check-windows/build/Static checksare all green (7/7 on2ded146). - The second receiver and DDC compaction — the case I expected to find a bug in.
removePanadapterdoesm_rx.erase(m_rx.begin() + ddc)andm_ids.remove(ddc)renumbers DDC indices while UI numbers stay fixed. Because the new fields live insideReceiver, they shift with the vector element, so closing receiver 0 does not hand receiver 1's RIT to the wrong slice. Putting them onReceiverrather than in a side map keyed by DDC is what makes that fall out for free — it's strictly better than the old radio-wide members. - A readback feedback loop. Every
ritChanged/xitChangedconsumer is display-only and already signal-blocked:VfoWidget.cpp:4935/4942andRxApplet.cpp:2768/2780set labels underQSignalBlocker,MainWindow.cpp:8510-8536refreshes Stream Deck / RC-28 / TMATE2 labels,MainWindow_Wiring.cpp:2184repaints the overlay,TciServer.cpp:2985-2986broadcasts a flag read back from the model. Nothing callssetRit/setXitfrom them, andapplyChangesemits only the status signals — so the new per-tune publish cannot loop. TheapplyChangesguard strictly reduces emissions versus main, including for Flex. emitSliceStatezeroing a restored RIT on connect. No path to zero:grep -i rit|xitoverRestoredRadioState.his empty and there is no RIT/XIT settings key, so there's no persisted value for the fresh-connect(false, 0)publish to clobber.- The clamp early-return swallowing the correction.
if (!r || (clamped == r->ritHz && clamped == hz))looked like a candidate — ask for 20000 twice and the second call findsclamped == r->ritHz. The second conjunct saves it: when the clamp bites,clamped != hz, so it never returns and always republishes 9999. Correct, and deliberately so. retuneReceiver()not publishing. Verified the chain:retuneReceiver→setSliceFrequency(ids->uiNumber, ...)→emitSliceState(ddc)at the tail. The "retuneReceiver() publishes as part of the re-tune" comment is accurate.- The body's "connected only for non-Flex backends".
wireBackendReceiverState()does run for every backend andFlexBackenddoes emitsliceChanged, so I expected this to be wrong — but the slice-creation block the lambdas live in is behindif (!s && !m_flexBackend)atRadioModel.cpp:1541. Claim holds. - Test-layer policy and the CMake contract. Both tests are socket-free model/backend-table tests, the correct layer.
slice_rit_xit_seam_testlinksaethercore Qt6::Corematching theatu_seam_gate_test/control_receive_testprecedent, usesTestSettingsProfile, is registered withadd_test, and is inAETHER_SETTINGS_CONSUMERS. Neither target compilesAppSettings.cpp, so${AETHER_SETTINGS_SOURCES}is correctly absent.
Could not check: nothing was built or run — every finding above is reasoned from the head checkout at /tmp/aetherclaude/pr-6109, not reproduced at runtime. I cannot confirm the body's 587/587 ctest run, the four static gates, the mutation table, or the automation-bridge demo pass. The HL2 and Icom hardware gap the body already declares stands. And I cannot read PR review bodies, only issue comments, which is the limit on the ruling check above.
Recommendation
Needs maintainer decision — on the fix shape only, not the code. The implementation is the most careful thing I've reviewed on this seam: the Receiver-struct placement makes DDC compaction correct for free, the clamp republish is right in the one case it would have been easy to get wrong, the six inverted test checks are inverted rather than deleted, and the mutation table names the exact checks I'd have asked for. If the option-C ruling exists, this is Approve-with-nits and the nits are all small. Concrete next step: point at the ruling (or get one recorded on #6105 with the label), then address the five nits — the one worth a line of code is the mid-flight stale-offset publish.
Thanks for the unusually honest "Not verified" section; it's what let me spend the review on the parts that actually needed attacking.
🤖 aethersdr-agent · cost: $8.5791 · model: claude-opus-5
|
Antares Detector — candidate vulnerable file(s)
The function assignedProfileForSlice(int sliceId) const directly returns m_sliceAssignments.value(sliceId), which blindly returns a value from a map without any validation. This is a direct use of untrusted sliceId as an index into a map, which can lead to path traversal or out-of-bounds access if the map is manipulated by external input. 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: $8.7517 · model: claude-opus-5 |
… clear. Principle VIII. - SliceModel::setRit()/setXit() adopt a synchronous backend's per-verb publishes silently and announce the final value once, so an enable-then-offset pair never shows the old offset mid-flight. - Hl2Backend::buildReceivers(): a receiver seeded from receiver 0 starts with RIT/XIT off and no RIT-moved-NCO flag, as createPanadapter()'s seed already does. - HL2 RIT/XIT setters check the receiver before clamping, and log each change with the DDC, the slice and whether it transmits. - Tests: the stale-step and Flex-shaped readback cases in slice_rit_xit_seam_test; DDC compaction and receiver build in hl2_rit_xit_test; one check no longer leans on an early return. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
|
Re the Antares detector note: |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…inciple VI. An XIT change while TUNE is keyed moves only the TX NCO register: still keyed, no drive bank. Inside the tail it moves the register, and the tail stays pending with the RF drive still owed until it ends. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
|
Admin-merged on the maintainer's approval (self-authored PR). Head
|
Fixes #6105
Decision
Jeremy's ruling on #6012 (approving review, 2026-10-02): "RIT/XIT stay radio-wide and follow transmit, as implemented … Per-slice RIT/XIT through the seam is filed as #6105." For #6105 he chose option C, a per-slice seam modelled on FlexLib. FlexLib is the protocol authority (Principle I). Its
SliceholdsRITOn/RITFreq/XITOn/XITFreqper slice and sends them asslice set <n> rit_on= / rit_freq= / xit_on= / xit_freq=(reference/FlexLib_API_v4.1.5.39794/FlexLib/Slice.cs:1487-1546). Each slice stores its own XIT, and the radio applies it when that slice transmits.Root cause
RadioModel.cppforwardedSliceModel::ritCommandIssuedandxitCommandIssuedto fourIRadioBackendverbs that took no slice id.SliceModelkeeps RIT/XIT per slice, butHl2Backendheld a single radio-wide value:rxTunedHz(), gated onownsTx).setTxFrequency().On a multi-receiver HL2 or Radioberry this caused two bugs:
setTxSlice()re-wrote the TX register with the previous slice's XIT, while the new transmit slice showed XIT off.Fix
setSliceRitEnabled,setSliceRitOffset,setSliceXitEnabledandsetSliceXitOffsetnow take(int sliceId, …). The XIT-offset default still forwards to the RIT offset, for Icom's single 0x21 register. TheRadioModellambdas passs->sliceId(). They are connected only for non-Flex backends, so Flex keeps its existing per-sliceslice setpath; the command text is unchanged and pinned by a test.Receiver, resolved viam_ids.byUi.rxTunedHz()no longer has theownsTxgate. Each receiver applies its own RIT on its own NCO.setTxFrequency()reads the XIT ofrx(m_txDdc). A handoff, or closing the transmit receiver, therefore picks up the new owner's XIT with no re-send.setTxSlice()andlogRitScope()are gone; they only existed because the value was radio-wide. Each setter instead logs aqCInfoline naming the DDC, the slice, and whether that receiver transmits. It checks the receiver exists before clamping.buildReceivers(): a receiver that a (re)connect adds is seeded from receiver 0, as before, but with RIT/XIT off and no RIT-moved-NCO flag, ascreatePanadapter()'s seed already is. Receivers that existed keep their own RIT/XIT across a reconnect, andemitSliceState()publishes them.ControlSpecseam-verb names are renamed to match.emitSliceState()now publishesritOn/ritFreq/xitOn/xitFreq, and each setter ends by publishing them. The readout therefore shows what the receiver holds, including a clamped offset and the zero state after a fresh connect. To make this clean:SliceModel::applyChanges()guards RIT/XIT the way it already guards the AGC pair, so the echo that comes with every HL2 tune emits nothing.setRit()/setXit()announce once, with the members rather than the arguments. While the command is being emitted, a synchronous backend's per-verb publishes are adopted without announcing them. So an enable-then-offset pair never shows the old offset in between, and a correction (HL2's ±9999 Hz clamp) is the value announced. This has no effect on Flex, where nothing is echoed synchronously.Validation
hl2_rit_xit_test, 58/58. The 6 checks that asserted "RIT follows transmit" now assert per-receiver behaviour:New cases:
buildReceivers()adds starts with RIT/XIT off and without receiver 0's RIT-moved-NCO flag, while receiver 0 keeps its own.New
slice_rit_xit_seam_test, 16/16 (socket-free, with a recording backend):slice set <n> rit_on=… rit_freq=…andxit_*text is unchanged;setRit()ends on the backend's clamped value;ritChanged(true, 500)/xitChanged(true, -250);SliceModel(notRadioModel+FlexBackend):setRitsends theslice settext and announces once, a matching status echo emits nothing, and a status carrying onlyrit_freqkeepsrit_on.hl2_tx_gate_test, the case added with feat(tx): apply TUNE power live while TUNE is keyed (HL2, Icom). Principle VI. #6104's transmit tail. XIT composes with TUNE and the tail:Mutation checks (each mutation was reverted, and the file's sha256 matched afterwards):
m_txDdcs->sliceId()applyChangessetRit()announces its argumentsapplyChangesapplyChangesbuildReceivers()seed keeps receiver 0's RIT/XITbuildReceivers()seed keeps the RIT-moved-NCO flagctest: 522/522 passed with
--no-tests=error. This is the full set of 621 minus 99 excluded tests: those whose sources use socket APIs, the socket-owning families, andconnection_panel_size_test(Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811). The two RIT tests matched that grep only on the wordconnectRadioand were run directly, 58/58 and 16/16.Static gates:
check_engine_boundary --strict(0 blocking),gen_touchpoint_manifest --check(up to date),check_capability_records --strict(71, at the frozen 71),check_command_plane --strict(401 = baseline). No ratchet grew.Agent automation bridge, built-in demo only. Offscreen, isolated settings,
AETHER_AUTOMATION_NO_TX=1, never keyed.get radioconfirmed the radio wasAetherSDR Demo/DEMO-0001.invokeon the RX applet'sRIT toggle/RIT increase×2 /XIT toggle/XIT decrease.get slice 0went fromritOn=false ritFreq=0 xitOn=false xitFreq=0toritOn=true ritFreq=20 xitOn=true xitFreq=-10.transmitting=falsethroughout.Not verified:
SimBackend, which is a seam backend, not Flex; the Flex command text is covered by the unit test.hl2_rit_xit_testagainst the constructed backend.🤖 Generated with Claude Code
https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g