Repository navigation
feat(tx): apply TUNE power live while TUNE is keyed (HL2, Icom). Principle VI. - #6104
Conversation
…ciple VI. A TUNE power change made while the TUNE carrier is up now reaches the carrier on backends without a command plane, instead of waiting for the next key-down and raising the "dropped" notice. - IRadioBackend::setTunePower(int): new verb, default no-op. It never keys; a backend generating its own carrier re-applies drive to one in progress and otherwise does nothing. - RadioCapabilities::TransmitDriveControl::tunePowerAppliesLive: a field inside the existing record, so FROZEN_BOOL_COUNT stays 71. - TransmitModel::tunePowerCommandIssued, emitted from setTunePower() only, never from the status-decode path. RadioModel forwards it while TUNE is keyed (isTuning() and a live Tune activity) to a backend that declares the field, and the drop-notice gate routes a keyed tunepower only then. - Hl2Backend::setTunePower: drive register only, through applyDrive() (the transmit gate and clamp), only while m_tuning and while the TUNE operation still permits dispatch. m_rfPowerPercent is untouched, so the unkey restores the operator's RF drive. - IcomCivBackend::setTunePower writes the tune drive while tuning. Also fixes the restore: RF power set during TUNE used to overwrite the tune drive and be lost at the unkey. It is now held in m_preTuneTxPowerPercent and written back at the unkey, and the TUNE re-request and tx.power scrub paths write the live drive directly. Socket-free tests: transmit_seam_drop_notice_test (flag on/off, unkeyed, decode echo, after release), hl2_tx_gate_test (EP2 0x09 bank bytes), icom_backend_seam_test (CI-V 14 0A writes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nciple VI. applyKeying(false) queued the RF-drive restore (setTxPower -> setTxDriveLevel) to MetisClient before the MOX-off, as separate cross-thread invokes. Each invoke is posted on its own, so the transport thread can deliver the restore and then run an EP2 pacer tick before the MOX-off arrives. That frame is keyed, carries the full-scale tune tone, and puts the operator's RF drive into the 0x09 bank. hl2_tx_gate_test now walks every packet the unkey's queued calls allow: an event filter on MetisClient builds the next EP2 packet before each queued call is delivered. Before this change packet 3 of 8 was keyed with the tune tone at drive 204 (80% RF); after it, no keyed frame follows the restore. The drive restore now posts after the MOX-off; the band-filter re-decision and the ATU-request clear stay where they were. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Principle VI. With RF power set during TUNE now held for the unkey, the confirming CI-V read of the tune-drive write (14 0A) was still published as TransmitDelta::rfPower. TransmitModel::rfPower() then showed the tune level, so a relative control (the power wheel, a CAT/TCI read-modify- write) stepped from it and its result became the value the unkey restores: 100% RF, 10% tune, one wheel step left the radio at 11%. While m_tuning, the kRfPower readback still updates the live register mirror and confirmState, but publishes no rfPower. The periodic poll already skips that read during TUNE. The unkey's restore write is read back and published as usual. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings the branch up from 2d57129 to f2acec1. Merged cleanly; the overlapping files (Hl2Backend.cpp/.h, IRadioBackend.h, RadioModel.cpp, radio-capabilities-map.md) needed no manual resolution. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
ten9876
left a comment
There was a problem hiding this comment.
Adversarial review — PR #6104 (head 00e9fc2c8)
Review event: COMMENT. This PR is authored by ten9876, the identity this review is posted from, and GitHub doesn't accept APPROVE or REQUEST_CHANGES on your own PR. The verdict below is in the body: one blocker (mechanical), plus nits. Approval stays with the maintainer.
Issue fit
There is no linked issue. The PR carries out the maintainer's design decisions for live TUNE power, recorded in the plan. Each decision maps to a hunk:
- Capability as a field in the existing record:
RadioCapabilities.h:321. - Icom wired in the same PR, with the restore fix:
IcomCivBackend.cpp:4968-4991. - Drag flood accepted as is: there is no coalescing anywhere in the diff.
- HL2 unkey order folded in:
Hl2Backend.cpp:4032-4071.
It also adds one change outside that list: the Icom readback guard (IcomCivBackend.cpp:2068). The body discloses it and gives the reason.
Scope
| File / group | What it changes | Claimed in the body? | Verdict |
|---|---|---|---|
IRadioBackend.h, RadioCapabilities.h |
New verb setTunePower (a default no-op) and the new field tunePowerAppliesLive |
Yes | New public seam surface. The maintainer already ruled on it (decision 1). FROZEN_BOOL_COUNT stays at 71 |
TransmitModel.{h,cpp} |
New signal tunePowerCommandIssued |
Yes | In scope |
RadioModel.cpp |
Connects the signal in the model-lifetime block, and changes the drop-notice gate | Yes | In scope |
Hl2Backend.{h,cpp} |
setTunePower, m_tuneOperation, the unkey reorder, and the flag |
Yes | In scope |
IcomCivBackend.{h,cpp} |
setTunePower, writeTxPowerLevel, the restore fix, the readback guard, and the flag |
Yes | In scope |
docs/architecture/* |
Caps-map row and Icom design table | Yes | In scope |
tests/* (3 files) |
Socket-free coverage | Yes | In scope |
Merge commit 00e9fc2c8 |
origin/main at f2acec1e5, merged clean |
Yes ("Merge with main") | In scope |
Everything in the diff is explained by the body. Nothing is bundled.
Blockers
1. Two backends leave the new field defaulted, which the ADDING A FIELD contract forbids. The contract is at src/core/backends/RadioCapabilities.h:196-199: "Set every field explicitly in every backend and record it in docs/architecture/radio-capabilities-map.md." The PR sets tunePowerAppliesLive explicitly for HL2 (Hl2Backend.cpp:1824) and Icom (IcomCivBackend.cpp:363). It leaves it defaulted for the two other backends that build a TransmitDriveControl:
src/core/backends/flex/FlexBackend.cpp:280-281src/core/backends/anan/AnanBackend.cpp:427-428
The value false is correct for both. Flex applies TUNE power live through its own command plane, not this seam verb, and ANAN has no TX. But the contract exists so that "absent" and "never set" can be told apart. Only the HL2 and Icom backends were touched, so no checker catches this. The fix is mechanical:
caps.transmitDriveControl = RadioCapabilities::TransmitDriveControl{
SliceFrequencyControl::Authority::Radio, /*tunePowerAppliesLive=*/false};(FlexBackend.cpp:280)
c.transmitDriveControl = RadioCapabilities::TransmitDriveControl{
SliceFrequencyControl::Authority::Engine, /*tunePowerAppliesLive=*/false};(AnanBackend.cpp:427)
Nits (non-blocking)
-
Icom
setTunePowerhas no admission defence (src/core/backends/icom/IcomCivBackend.cpp:4979-4985). HL2 checksm_tuneOperationand Icom checks onlym_tuning. Icom is safe today, because every unkey path clearsm_tuningthroughstopTuneProducer():applyKeyingat:4868- the radio-originated unkey at
:2941 - the session disconnect at
:1529
The one gap: an unkey refused because cleanup isn't permitted would leave
m_tuninglatched. The verb writes only the14 0Alevel and never keys, so this is not a TX-safety hole. If you want the two backends to match, store the TUNE operation insetTune(true)and apply the same check. -
m_tuneOperationoutlives the carrier (src/core/backends/hl2/Hl2Backend.h:882). It is set atHl2Backend.cpp:4690and never cleared, so the backend holds the stale operation's shared state until the next TUNE. It is harmless, becausesetTunePowerchecksm_tuningfirst. Resetting it next tom_tuning = false(Hl2Backend.cpp:4043) would make the lifetime match. -
The Tune-activity check in the connect is not pinned on its own (
src/models/RadioModel.cpp:2128). The body says so. A test where the TxCoordinator drops the Tune contribution whileTransmitModel::isTuning()is still latched would pin it, for example an emergency stop with nostopTune(). The HL2 operation check is the backstop and is pinned. Icom has no backstop (nit 1). -
The drop-notice gate and the connect use different predicates (
RadioModel.cpp:1927passesisTuning()only;:2127-2128also requires a live Tune activity). In the gap, the notice is withheld but nothing is applied live. No value is lost, because the next key-down carries it. Noted for completeness. -
No hardware verification, which the body discloses. Neither the HL2 drive change nor the Icom
14 0Areadback behaviour has been seen on a real radio. A dummy-load bench check of HL2 live tune power and of the release order would close the HL2 half. The body already asks for an Icom CI-V capture.
Attacks that held
-
Keying or raising drive outside an operator-started TUNE. Each backend checks before writing:
- HL2
setTunePower(Hl2Backend.cpp:4712) needsm_tuning, which onlysetTune(true)sets, afterpermitsDispatch. It also needs the stored TUNE operation to still permit dispatch. It only callsapplyDrive, which clamps to 0..100 and writes 0 with the PA off whenm_txAllowedis false.MetisClient::setTxDriveLevelforces 0 again at the wire. - Icom writes only
14 0A, and only whilem_tuning. - RadioModel forwards only while
isTuning(), a live Tune activity and the flag all hold.
No path keys. Callers of
TransmitModel::setTunePowerare the TxApplet, MIDI, TCItune_drive, the automation verb and the SWR sweep. The SWR sweep sets TUNE power before TUNE and restores it after TUNE stops (MainWindow_SwrSweep.cpp:449,641), so it gains no live effect. - HL2
-
Emergency stop or watchdog versus
setTunePower. Both run on the owner thread, so neither can interleave with the other. After a stop, the stored operation is cancelled, sosetTunePowerwrites nothing. Mutation: I removed the operation check, andhl2_tx_gate_testfailed with "tune power after the TUNE operation was stopped: no drive bank". -
HL2 unkey order on every path. Every unkey goes through
applyKeying(false):setTune(false)setKeying(false)from the TX watchdog, MOX/PTT coordinator and key verb- the CW break-in hang
The connect-time reset (
Hl2Backend.cpp:~7011) clearsm_tuningfirst, so it does no restore, as before. MOX-off (:4058-4063) is now posted before the drive restore (:4069). Both are invokes to the sameMetisClient, so they arrive in posting order. Mutation: I put the restore back first, and the test failed with "unkey packet 3 of 8: MOX set, tune tone on, drive 204". With the fix restored the test passes. I checked each restored file's sha256 against HEAD. -
Icom restore and readback guard. A setter change made during TUNE goes to
m_preTuneTxPowerPercent. The TUNE re-request, thetx.powerscrub and the tune drive itself usewriteTxPowerLeveland leave that slot alone. The unkey restore and the disconnect emergency path both write the held value. Mutations:- Guard removed: the test failed ("not published as the RF power").
- Restore fix reverted: three checks failed.
-
Flex unchanged.
FlexBackenddoesn't overridesetTunePower, and its flag is false, so the connect never calls it. Flex keeps its command plane, so the drop-notice function is never consulted. No Flex file is in the diff. -
Static gates, merge base
f2acec1e5vs head. Findings fromcheck_engine_boundary --strict,gen_touchpoint_manifest --check,check_capability_records --strict(71),check_command_plane --strict(406/32) andgen_seam_probe_table --check(51 signals) are identical once line numbers are normalised. -
Tests run. The merged head builds clean. I ran the three changed tests: all three pass, and all three are socket-free. The only grep hits for socket APIs were a comment and a no-op
connectRadiooverride. I then ran the full default ctest at -j8 with every test whose source uses a socket API excluded: 525 of 604 ran, and all passed exceptconnection_panel_size_test(known locally, #5811). -
Automated pass.
/code-reviewat medium effort reported zero findings. It independently reached the same conclusions on unkey ordering, the Icom restore and Flex.
Not driven in the app. The demo SimBackend is receive-only and doesn't declare the flag, so the bridge can't reach this behaviour. Reaching it would mean keying an HL2 or Icom, which a review must not do. Everything above is socket-free tests plus reading the code.
🤖 Generated with Claude Code
…peration at unkey. Principle VI. - FlexBackend and AnanBackend state tunePowerAppliesLive=false explicitly, per RadioCapabilities' ADDING A FIELD rule. Sim and RTL declare no TransmitDriveControl record, so they have no such field. - IcomCivBackend::setTunePower refuses once the TUNE operation stored at setTune(true) no longer permits dispatch, matching Hl2Backend. - Hl2Backend and IcomCivBackend clear the stored TUNE operation on every unkey and connection reset, so it does not outlive the carrier. - transmit_seam_drop_notice_test pins the RadioModel connect's Tune-activity check on its own: a decoded tune state with no live Tune activity forwards nothing. Each new check fails when its guard is removed (Icom admission check, Icom and HL2 operation clear, RadioModel Tune-activity check, Icom operation store). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Review findings addressed in
|
Brings in #6019 (HL2 forward power meter), which touches Hl2Backend.cpp/.h and MetisClient. Merged clean; #6019 changes the forward-power telemetry path, not keying, drive or TUNE. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Brings in #5981 and #6012 (HL2 CW RX bandpass, RIT/XIT registers), which touch Hl2Backend.cpp/.h. Merged clean; neither changes keying, drive or TUNE. 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.
1. Issue fit
No linked issue — there is no fixes/closes #NNNN, and search_issues "tune power while keyed" turned up nothing this would close (#5610/#5975 are the CW-vs-TUNE level thread, a different defect). So this is reviewed against its own stated intent, which it meets: the TUNE power slider now reaches a keyed carrier on HL2 and Icom, the drop notice is withheld only where a backend declares the verb real, and the two bundled TX-power defects are both genuine. I reproduced every static-gate number in the body on the merged head (see below), and the governance question the missing issue raises is in §3.
2. Scope
| File / group | What it changes | Claimed in body? | Verdict |
|---|---|---|---|
IRadioBackend.h, RadioCapabilities.h |
new setTunePower() virtual (default no-op) + TransmitDriveControl::tunePowerAppliesLive |
yes | In scope. New internal seam surface — see the maintainer note below |
TransmitModel.{h,cpp} |
tunePowerCommandIssued(int), emitted from setTunePower() only |
yes | In scope; mirrors rfPowerCommandIssued exactly, and applyChanges still routes through tunePowerChanged (line 122), so no echo loop |
RadioModel.cpp |
forward connect + drop-notice gate | yes | In scope — but the two gates use different predicates; Blocker-adjacent nit #1 |
Hl2Backend.{h,cpp} live tune power |
setTunePower → applyDrive, m_tuneOperation stored/cleared |
yes | In scope |
Hl2Backend.cpp unkey reorder |
drive restore moved behind the MOX-off post | yes (own section) | Independent TX-safety fix, same function. Real — see "what I tried to break" |
IcomCivBackend.{h,cpp} live tune power |
setTunePower, writeTxPowerLevel() factoring |
yes | In scope |
IcomCivBackend.cpp restore + readback |
setTxPower during TUNE holds for the unkey; kRfPower readback not published while tuning |
yes (own section) | Independent fix, found by review of this branch |
AnanBackend.cpp, FlexBackend.cpp |
declare the field false |
yes | Required by the ADDING A FIELD rule in RadioCapabilities.h:196-199; all four production construction sites state it (grepped) |
docs/architecture/*.md |
caps-map row + CI-V table row | yes | In scope |
tests/* (3 files) |
new coverage | yes | In scope |
| merge commits ×2 | origin/main → 75d00ae2 |
yes | Clean; no behavior |
Nothing in the diff is unexplained, no CHANGELOG.md entry, no unrelated build/CI churn, no removed guard. The two bolded rows are independent TX-safety fixes that happen to live in the same functions — disclosed honestly and reasonable to keep together, but worth saying out loud: both would land cleanly on their own if the feature needs more discussion.
Socket tests: none added or removed. hl2_tx_gate_test drives a real MetisClient (UDP-capable) but Hl2TxGateTestAccess::prepare() opens no socket — MetisClientTestAccess::setStreaming() fakes the running state — and the new InterleaveProbe is a QEvent::MetaCall event filter, not a peer. All three targets are registered in tests/tests.cmake (435, 1142, 5147) and run in the default Linux graph.
3. Blockers
None. One finding is a should-fix-before-merge nit rather than a blocker, because I could not find a production path that reaches it on the only two backends that declare the flag — reasoning below.
4. Nits (non-blocking)
- The forward gate and the drop-notice gate disagree, and the new test asserts only half of it (inline on
RadioModel.cpp:1927,:2128, andtransmit_seam_drop_notice_test.cpp:260). Forwarding needsisTuning() && (activeTxActivities() & Tune) && appliesLive; the notice gate atRadioModel.cpp:2360-2362passes onlym_transmitModel.isTuning()astuneKeyed. In the overlap — tuning, no live Tune activity,appliesLivetrue — the value reaches no setter and the notice is suppressed. That is the silent drop #5263/#5637 exist to prevent, andtunePowerNotForwardedWithoutTuneActivityconstructs exactly that state while checking onlytunePowers.isEmpty(). I traced the reach and believe it is currently unreachable on HL2/Icom: neither decodesTransmitDelta::tune, and the coordinator path (RadioModel_TxCoordinator.cpp:936) uses the no-argTransmitModel::stopTune(), whose refusal branch isif (route.admit && …)withroute.admitnull — so it always clearsm_tune. Still a latent inconsistency, and the cheap fix is oneliveTuneKeyed()helper used by both sites. - The revoked-admission case is also silent (inline on
IcomCivBackend.cpp:4986).emergencyStop()while tuning makessetTunePowerreturn early in both backends, but the model-level notice gate cannot see that, so the operator is told nothing. Fail-closed is right; the lack of a notice is the gap. Marginal, same family as #1. - Icom: a front-panel RF-power change during TUNE is now entirely invisible (inline on
IcomCivBackend.cpp:2070). The suppression is correct for the confirming14 0Aread, but it also swallows an unsolicited front-panel publication — whichm_preTuneTxPowerPercentthen overwrites at the unkey. The overwrite is pre-existing; this hunk removes the only signal it happened. Worth a sentence in the comment acknowledging the limit. setTunePowerskipstransmitContext()(inline onIcomCivBackend.cpp:4986).setTune(true)checks the operation and the context;setTunePowerchecks only the operation. HL2 is covered incidentally byapplyDrive'sm_txAllowedassert-off; Icom has no equivalent. Drive is not keying, so this is not a Principle VI hole, but the asymmetry is unexplained.- Stale fixture comment (inline on
transmit_seam_drop_notice_test.cpp:85): "the Hermes-Lite 2's answers to the questions the gate asks" no longer holds once the helper takestunePowerAppliesLive=false, which is no HL2. - Out of scope, worth its own issue: the PR's own argument — "each invoke is its own post, so an EP2 tick can fall between two of them" — applies verbatim to
applyBandFilter("unkey").m_keyed = keylands atHl2Backend.cpp:3988, soapplyBandFilterat:4025already selects the receive filter byte (transmitting = m_keyed || m_tuningis false) and posts it ahead of the MOX-off at:4061. That is relay hot-switching under a still-keyed frame, on every unkey, not just a tune. Pre-existing and correctly left alone here — please file it.
For the maintainer: there is no linked issue, and moving the TUNE power slider now changes RF output where it previously did not. GOVERNANCE.md §"What requires an RFC" lists "any change to default UX behavior — what a click, shortcut, or gesture does out of the box"; the counter is that this is parity with Flex, which puts it under "protocol compliance fixes matching SmartSDR behavior". I lean parity-fix, but it is a user-visible TX behavior change on two backends plus a new IRadioBackend virtual, so it is your call rather than mine. (The new virtual is internal only — grep -rn "IRadioBackend\|RadioCapabilities" hal-plugin/ is empty, so there is no out-of-tree plugin ABI to break.)
5. What I tried to break
All of the following is reasoned from the head checkout, not reproduced at runtime — I have no build and ran nothing but the Python gates.
- Principle VI, the new verb. Traced both implementations to the wire: HL2
setTunePower→applyDrive→setTxDriveLevel(queued, andapplyDriveasserts drive 0 with the PA bit off when!m_txAllowed); Icom →writeTxPowerLevel→ CI-V14 0Aonly. Neither touchessetMox/setCwMoxor1C 00, and both are gated onm_tuningplus a still-admittedm_tuneOperation.TxCoordinator::Operation::permitsDispatchreturns false on!m_state(TxCoordinator.cpp:51), so a default-constructedm_tuneOperationreally does refuse — the clearing at unkey is load-bearing, not cosmetic. - The latched-
m_tuningattack. HL2setTune(true)setsm_tuningbeforesetKeying(true), which returns early at!m_txAllowedwithout clearing it — so a gate-closed HL2 can latchm_tuningwith no carrier, and the PR adds a path that acts on it. Dead end in production:m_txAllowedis set once atHl2Backend.cpp:304and never flips, andcapabilities().canTransmit = m_txAllowed, so the model's keying admission refuses TUNE before the backend sees it. The test-only case is covered and fails closed (drive 0, PA off). - Icom restore arithmetic.
m_preTuneTxPowerPercentis snapshotted beforem_tuning = trueand before the tune write; the re-request branch does not re-snapshot it; thetx.powerscrub re-assertsm_txPowerPercent(the tune drive) and leaves the held value alone; the disconnect emergency path (:910) writes the held value and runs beforedisconnectRadioclears it;applyKeyingand the PTT-readback path (:2943) both callstopTuneProducer()before the restore, sosetTxPoweris no longer in them_tuningbranch when the write has to happen. Double unkey returns-1the second time. I could not get the tune drive to become the operating drive on any of those paths. - The readback early-return. It skips only the
emit;confirmState("civ.20.10")still runs, and both branchesreturn, so no case falls through. - Would the tests pass against the unfixed code?
tunePowerChangedWhileKeyedAppliesLivefails if the connect is deleted or the flag ignored; the HL2 drive-bank table discriminates by exact register value (102/25/153/255/0) so a no-opsetTunePowerleaveslastDriveempty and the check fails;InterleaveProbebuilds a packet before each deliveredMetaCall, andsetTxDriveLevelis itself a queued invoke (:4883), so on the old order a packet with MOX set, tone on, and drive 204 is reachable by construction rather than by timing luck. The Icom readback test checks its own premise first (the same frame outside TUNE does publish). These are real discriminators, not implementation-echo. - Stale value at key-down.
RadioModel_TxCoordinator.cpp:506andRadioModel.cpp:2597both readm_transmitModel.tunePower()at dispatch, so a change made during a deferred admission is not lost. - Gates reproduced on the merged head:
check_capability_records.py --strict→ 71 bools, at the frozen 71 (identical onmain);gen_touchpoint_manifest.py --check→ up to date;check_command_plane.py --strict→ 406/32, 0 blocking;gen_seam_probe_table.py --check→ clean, 51 signals.get_check_runsonccd4176c: all 7 green. - Could not check: anything on real hardware. The body is candid about this, and its "still wanted" ask — a real-Icom CI-V capture of TUNE plus one RF-power wheel step — is the right one. The
tunePowerAppliesLiverow in the CI-V design doc correctly says "socket-free tests only; not live-verified".
6. Recommendation
Approve with nits. This is careful work: the feature is gated at three independent levels, the capability field is folded inside the existing optional record so no ratchet moves, and both bundled defects are real — the HL2 unkey ordering in particular is a genuine Principle VI hazard that the two-separate-posts argument settles convincingly. Nit #1 is the one I would like addressed before merge (collapse the two tune-keyed predicates into one helper and add the droppedStartingWith assertion to tunePowerNotForwardedWithoutTuneActivity); it is a nit rather than a blocker only because I could not find a production path that reaches the divergence today. Next step: a maintainer ruling on the missing-issue / UX-behavior question in §3, then #1 plus the comment fixes. Thanks for the mutation table and the explicit "not verified" section — they made this much faster to audit.
🤖 aethersdr-agent · cost: $9.2709 · model: claude-opus-5
|
The HL2 unkey reorder narrows the window but cannot close it: the gateware keeps transmitting after the MOX-off frame.
So a restore one frame after MOX-off still lands under about 20 + 12 ms of full-scale tune carrier. Our own record agrees in size: on 2026-09-03 our unit (gateware 74.2) unkeyed itself 40-54 ms after the host stopped sending keyed frames. That was before an unrelated repair of the same unit, and it was not re-measured since.
The rest of the HL2 side checks out against the source. 73, ON8ST |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…tail. Principle VI. The HL2 keeps radiating after the MOX-off frame: keyed IQ already in its TX FIFO drains (tx_buffer_latency, 20 ms default, radio.v:934; 0x17 is never written) and the last sample is held for ptt_hang_time (12 ms, radio.v:935, 1036-1047). Drive applies at once (ad9866ctrl.v:134-140). Restoring RF drive one frame after MOX-off therefore put the RF drive under the full-scale tune tone still draining. Found by ON8ST. The unkey now arms a transmit tail of kTxTailHoldMs (70 ms, never below the RX unkey hold, which measured 51.66-66.15 ms of the same tail; ON8ST's unit unkeyed 40-54 ms after the host stopped keyed frames). While it is pending, applyBandFilter() keeps the transmit byte, and setTxPower() records but does not write an owed RF drive. When it ends, the receive filter and the owed RF drive are applied. A re-key cancels the tail, so a restore is never applied over a new carrier; the drive stays owed to the next unkey's tail. A disconnect or connection reset drops it. hl2_tx_gate_test replaces the host-packet-order check with: the unkey posts no drive restore, the tail ending restores it, re-key cancels, RF power set during the tail waits, a retune during the tail keeps the transmit filter, a disconnect drops the tail, and the real timer ends it. Each fails when its guard is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
|
@on8st Thank you, this was a real hole. The fix is in What changed (
Why 70 ms. It is never below the RX unkey hold, and it covers:
Why a timer and not
Tests.
Removing each guard makes its test fail. Other checks. The full socket-free ctest passes (523 run), except the known local Not hardware-tested. Nothing observed |
|
Thank you, and you are right about On the hardware check: re-measuring the unkey on this head means keying the radio, which here needs the operator at the station with the dummy load confirmed. It is on the list for the next such session, together with the #6052 key-down measurement. I will post the unkey time and whether the drive restore lands after RF has stopped. Until then this stays not hardware-tested, as you say. 73, ON8ST |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
… Icom refuses tune power like setTune. Principle VI. - RadioModel::tuneCarrierLive() (TransmitModel tuning AND a live Tune activity) now decides both whether a TUNE power change is forwarded live and whether its drop notice is withheld. A tune state decoded off the radio with no admitted TUNE keeps the notice instead of being silently dropped. - IcomCivBackend::setTunePower also requires the backend's transmit context, as setTune(true) does, and logs a refusal on aether.icom.tx, since the verb returns nothing to RadioModel. - The Icom RF-power readback comment states the CI-V limit: a front-panel change during TUNE cannot be told from the confirming read. - transmit_seam_drop_notice_test asserts the notice stands in the diverging state; icom_backend_seam_test covers a lapsed transmit context and its log. Each fails when its guard is removed. 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, so a GitHub approval isn't possible). Head
|
Resolves the transmit_seam_drop_notice_test conflict with #6104 by keeping both sides: the setTunePower recorder and the CW/VOX/MON/PROC recorders, and a header that names both seams. RadioModel's gate merged cleanly; it keeps #6104's tuneCarrierLive tunepower arm and #6086's record-gated keys. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Fixes #6122 ## Root cause #6104 (`fe1fec51`) changed the HL2 so that after an unkey it holds the tune drive through the radio's transmit tail (`Hl2Backend::kTxTailHoldMs`, 70 ms). It restores RF drive only when the tail ends. This is a TX-safety change: the gateware keeps radiating its FIFO plus PTT hang after the MOX-off frame. `hl2_backend_seam_test` still asserted the old contract, a restore at the unkey itself. So main's Full Suite failed four checks: - `#4549: an unkey that bypasses setTune() still restores RF power` - `#4549: releasing TUNE restores RF power` - `#4549: the unkey applies the power set during the tune` - `#4912: health reports the register the wire carries` The product is right; the test was stale. This reached main because #6104's local runs excluded `hl2_backend_seam_test` as socket-owning. That was a misclassification: the only match was `disconnectRadio()` against a `connectRadio(` grep pattern. The source opens no socket. ## Change (test only) `tunePowerAndRestore` now parks the tail and ends it through the test-access friend (`parkTxTail`, `expireTxTail`). It asserts both phases. **Immediately after the unkey,** the tune drive is still on the register and on the wire. This holds for: - an unkey that bypasses `setTune()`; - a TUNE release; - a release after an RF power change made during the tune. **When the tail ends,** RF power is restored on the register and the wire, including the power set during the tune. **#4912:** health reports the register the wire carries in both phases. A new check covers an RF power change made during the tail: the requested percent moves, while the register and the wire stay on the tune drive until the tail ends. No product code changes. ## Evidence - `hl2_backend_seam_test` passes. - Mutations: each one makes the test fail, and each file was restored and checked by sha256. | Mutation | Failing checks | |---|---| | Restore at the unkey (tail deferral removed) | the three "holds the tune drive through the tail" / "waits for the tail" checks | | Restore never applied when the tail ends | the three "restores … after the tail" checks | - Full socket-free ctest at -j8: 566 of 620 run. Every test whose source opens a socket was excluded after reading the source, not just matching the name. All pass except `connection_panel_size_test`, which already fails locally (#5811). - All 73 socket-free `hl2_*` tests ran and passed. That includes `hl2_backend_seam_test`, `hl2_tx_gate_test`, `hl2_pan_create_async_test` and `hl2_receiver_count_restart_paced_test`. - The remaining `hl2_*` tests bind UDP and were not run: `hl2_emergency_stop_test` and `hl2_receiver_count_restart_test`. - The other tests #6104 touched also pass: `transmit_seam_drop_notice_test` and `icom_backend_seam_test`. - The four static gates and `gen_seam_probe_table.py --check` are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings the branch onto main at d9de044 so CI runs against it. The merge is textually clean. Main's HL2 changes since the branch point (aethersdr#6104, aethersdr#6113, aethersdr#6126) are in TX keying and tune, openReceiverDsp, capabilities and the TX reset in pushInitialState(); none writes Receiver::mode, agcMode or the held AGC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… per receiver. Principle I. (#6109) Fixes #6105 ## Decision Jeremy's ruling on #6012 ([approving review](#6012 (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 `Slice` holds `RITOn` / `RITFreq` / `XITOn` / `XITFreq` per slice and sends them as `slice 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.cpp` forwarded `SliceModel::ritCommandIssued` and `xitCommandIssued` to four `IRadioBackend` verbs that took no slice id. `SliceModel` keeps RIT/XIT per slice, but `Hl2Backend` held a single radio-wide value: - RIT was applied only to the transmit receiver (`rxTunedHz()`, gated on `ownsTx`). - XIT was added in `setTxFrequency()`. On a multi-receiver HL2 or Radioberry this caused two bugs: 1. **Overwrite.** RIT or XIT changed on any slice overwrote the value for every slice. 2. **XIT on handoff.** `setTxSlice()` re-wrote the TX register with the previous slice's XIT, while the new transmit slice showed XIT off. ## Fix - **Seam:** `setSliceRitEnabled`, `setSliceRitOffset`, `setSliceXitEnabled` and `setSliceXitOffset` now take `(int sliceId, …)`. The XIT-offset default still forwards to the RIT offset, for Icom's single 0x21 register. The `RadioModel` lambdas pass `s->sliceId()`. They are connected only for non-Flex backends, so Flex keeps its existing per-slice `slice set` path; the command text is unchanged and pinned by a test. - **HL2:** RIT and XIT now live on each `Receiver`, resolved via `m_ids.byUi`. - `rxTunedHz()` no longer has the `ownsTx` gate. Each receiver applies its own RIT on its own NCO. - `setTxFrequency()` reads the XIT of `rx(m_txDdc)`. A handoff, or closing the transmit receiver, therefore picks up the new owner's XIT with no re-send. - The RIT re-tune in `setTxSlice()` and `logRitScope()` are gone; they only existed because the value was radio-wide. Each setter instead logs a `qCInfo` line 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, as `createPanadapter()`'s seed already is. Receivers that existed keep their own RIT/XIT across a reconnect, and `emitSliceState()` publishes them. - **Icom:** it ignores the id (one slice, always the TX slice). The three `ControlSpec` seam-verb names are renamed to match. - **HL2 readback (the optional item) is done.** `emitSliceState()` now publishes `ritOn` / `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: - after a TX move, receiver 1 does not pick up receiver 0's RIT, and receiver 0 keeps its own; - RIT on the non-transmit receiver moves its own NCO; - after the transmit receiver closes, DDC 0 keeps its RIT. New cases: - RIT on a non-transmit receiver shifts only that receiver; - turning it off leaves the transmit receiver's RIT alone; - XIT on a non-transmit receiver leaves the TX register alone; - **handoff regression:** TX moves to a receiver with XIT off and the TX register is exactly that receiver's dial; it then moves back and the old XIT applies again; - published readback of the offset and of the clamp; - closing receiver 0 renumbers receiver 1 to DDC 0, and it keeps its own RIT; - a receiver that `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): - RIT on slice 1 reaches the seam as slice 1, and XIT on slice 0 as slice 0; - the Flex `slice set <n> rit_on=… rit_freq=…` and `xit_*` text is unchanged; - an unchanged echo emits nothing; - a corrected echo is adopted; - `setRit()` ends on the backend's clamped value; - a backend that publishes from the enable verb (the old offset), then from the offset verb, yields exactly one `ritChanged(true, 500)` / `xitChanged(true, -250)`; - Flex shape, on a backend-less `SliceModel` (not `RadioModel` + `FlexBackend`): `setRit` sends the `slice set` text and announces once, a matching status echo emits nothing, and a status carrying only `rit_freq` keeps `rit_on`. - **`hl2_tx_gate_test`, the case added with #6104's transmit tail.** XIT composes with TUNE and the tail: - An XIT change while TUNE is keyed moves only the TX register: still keyed, no drive bank written. - Inside the tail, XIT moves the TX register, and the tail stays pending with the RF drive still owed. When it ends, the drive is restored. - Mutation: an XIT offset that ends the tail turns 2 checks red. - **Mutation checks** (each mutation was reverted, and the file's sha256 matched afterwards): | Mutation | Checks that went red | |---|---| | Re-gate RIT to the TX receiver | 3 | | TX XIT read from DDC 0 instead of `m_txDdc` | 5, including the handoff regression | | Drop the XIT readback | 2 | | Lambda passes slice 0 instead of `s->sliceId()` | 1 | | Unguarded `applyChanges` | 2 | | `setRit()` announces its arguments | 1 | | RIT in-flight guard removed from `applyChanges` | 1 | | XIT in-flight guard removed from `applyChanges` | 1 | | `buildReceivers()` seed keeps receiver 0's RIT/XIT | 1 | | `buildReceivers()` seed keeps the RIT-moved-NCO flag | 1 | - **ctest:** 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, and `connection_panel_size_test` (#5811). The two RIT tests matched that grep only on the word `connectRadio` and 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 radio` confirmed the radio was `AetherSDR Demo` / `DEMO-0001`. - Used `invoke` on the RX applet's `RIT toggle` / `RIT increase` ×2 / `XIT toggle` / `XIT decrease`. - `get slice 0` went from `ritOn=false ritFreq=0 xitOn=false xitFreq=0` to `ritOn=true ritFreq=20 xitOn=true xitFreq=-10`. - After zero + off it returned to all off/zero, with `transmitting=false` throughout. - **Not verified:** - Only a sanity pass on the demo. It runs `SimBackend`, which is a seam backend, not Flex; the Flex command text is covered by the unit test. - No HL2 or Icom hardware run. HL2 multi-receiver behaviour is proven only by `hl2_rit_xit_test` against the constructed backend. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
On HL2 and Icom, a TUNE power change made while TUNE is keyed now reaches the carrier. Before this, the value waited for the next key-down, and the operator saw a "dropped" notice for a control that looks live. This PR also fixes two TX-power defects in the same paths:
What changed
Seam and capability
IRadioBackend::setTunePower(int)is a new verb with a default no-op, next tosetTxPower. It never keys. A backend that generates its own carrier re-applies drive to a carrier in progress and otherwise does nothing; the nextsetTune()carries the value. Owner-thread contract as forsetTxPower.RadioCapabilities::TransmitDriveControl::tunePowerAppliesLivedefaults tofalse. It is a field inside the existing record, not a top-level bool, soFROZEN_BOOL_COUNTstays at 71 (check_capability_records.py --strictreports 71 on both base and head). The contract text inIRadioBackend.handdocs/architecture/radio-capabilities-map.mdare updated to match.Models
TransmitModel::tunePowerCommandIssued(int). OnlysetTunePower()emits it, aftercommandReady, mirroringrfPowerCommandIssued.applyChanges()(status decode) never emits it.RadioModelconnects to it in the constructor's model-lifetime block, next torfPowerCommandIssued(RadioModel::setupBackend() leaks a connectionStateChanged connection per family switch #4599). It forwards a change only when all of these hold:isTuning()is trueTxActivity::Tunecontribution existstunePowerAppliesLivetunePowerAppliesLivebefore callingsetTunePower. The behaviour is the same for every real backend, because the base verb is a no-op. The check makes the contract explicit: the field promises the verb is real, and a backend that doesn't declare it is never handed the call.RadioModel::tuneCarrierLive():isTuning()AND a liveTxActivity::Tune. The gate isrouted = canTransmit && transmitDriveControl && (!isTuning() || (tuneCarrierLive() && tunePowerAppliesLive)). While tuning, a value that is not forwarded live always keeps its notice: a backend without the field, or a tune state decoded off the radio with no TUNE this client admitted.TransmitDriveControlis built with the field stated explicitly, as the ADDING A FIELD rule requires:trueon HL2 and Icom,falseon Flex (whose tune power travels as command-plane text) and ANAN. Sim and RTL declare no record.HL2
Hl2Backend::setTunePowerwrites only the drive register, throughapplyDrive(), so the clamp and the transmit gate still apply. It doesn't touchm_rfPowerPercent, band memory ornotifyOperatingStateChanged, and it doesn't route throughsetTxPoweror re-callsetTune(true).m_tuningis true. It also acts only while the TUNE operation, stored asm_tuneOperationinsetTune(true), still permits dispatch. That optional defence is included: a TUNE whose admission was revoked or emergency-stopped takes no drive change. Every unkey and the connection reset clearm_tuneOperation, so it never outlives the carrier.tunePowerAppliesLive = truein the HL2 capabilities.Icom
IcomCivBackend::setTunePowerwrites CI-V14 0A(the tune drive) while tuning and does nothing otherwise. It also requires the same admissionsetTune(true)checked: the TUNE operation stored there, and the backend's transmit context. The operation is cleared at every unkey and session reset. A refusal is logged onaether.icom.tx, because the verb returns nothing toRadioModel.tunePowerAppliesLive = truein the Icom capabilities.Restore fix.
setTxPowerduring TUNE now records the value inm_preTuneTxPowerPercent, which the unkey (and the disconnect emergency path) writes back. It no longer writes over the tune drive.The raw write is factored into
writeTxPowerLevel(). The TUNE re-request branch insetTune(true)and thetx.powercontrols scrub both use it, so neither can stamp the tune drive into the restore slot.Readback during TUNE. The CI-V read that confirms the tune-drive write (
14 0A) is no longer published asTransmitDelta::rfPowerwhile tuning. The periodic poll already skipped that read during TUNE. Without this,TransmitModel::rfPower()showed the tune level. A relative control (the power wheel, or a CAT/TCI read-modify-write) would then step from that level, and the result would become the value the unkey restores. Example: 100% RF and 10% tune, then one wheel step, left the radio at 11%. Visible effect: on Icom the RF power slider no longer jumps to the tune level while TUNE is keyed. It keeps the operator's RF power, matching Flex. (Found by code review of this branch.)Evidence for the readback leak, and its limits. This comes from the code path and a scripted-reply unit test. It is not hardware-verified: no Icom was on the bench.
14 0A.sendUserCommand()queues a confirming read of that level 60 ms later (queueRead(..., now + 60)).kRfPowerreply handler published it asTransmitDelta::rfPower.icom_backend_seam_testscripts that CI-V reply. It does not observe a real radio's answer.Still wanted: a CI-V capture from a real Icom of TUNE plus one RF-power wheel step. It should show the confirming
14 0Aread during TUNE and confirm that the restore after the unkey is the operator's RF power.The HL2 transmit tail (found by ON8ST)
ON8ST showed from the gateware RTL that the HL2 keeps transmitting after the MOX-off frame:
dsopenhpsdr1.v:351-352tags each sample withds_cmd_ptt.PTTTX,radio.vdrains the keyed samples already queued in the FIFO (tx_buffer_latency, 20 ms default,radio.v:934). AetherSDR never writes 0x17, so the default applies.ptt_hang_time(12 ms default,radio.v:935, 1036-1047).tx_onstays high until the state machine reachesNOTX(radio.v:1002).ad9866ctrl.v:134-140loads the TX gain as soon as the command arrives.So restoring RF drive one frame after MOX-off still landed under roughly 20 + 12 ms of full-scale tune carrier. ON8ST's unit (gateware 74.2) unkeyed itself 40-54 ms after the host stopped sending keyed frames. The same applies to the receive filter switch at unkey.
Why a timer, not
tx_on.tx_onis reported only in the discovery / alternate-port reply:resp_control(control.v:899) feedsusopenhpsdr1.v:292(6'h20). The EP6 stream's responses carryptt_resp = cw_on | ext_pttinstead (control.v:456, 472-475), which a host MOX never raises.MetisProtocol.cpp:546is inparseDiscoveryReply(). Readingtx_onmid-stream would mean polling the discovery port during TX, which the stream-free telemetry design avoids because that reply preempts EP6 (Hl2TelemetryCadence.h). Gateware withoutEXTENDED_RESPalso sends zeros there, so a "tx_onlow" trigger would fail open.Fix (
Hl2Backend).kTxTailHoldMs= 70 ms, after the MOX-off is queued.static_assertpins it). It covers the gateware's 20 + 12 ms default tail and ON8ST's 40-54 ms measurement. The RX-hold measurement of the same tail on one HL2 was 51.66-66.15 ms, n = 11.applyBandFilter()keeps the transmit filter byte, andsetTxPower()records an owed RF drive without writing it.Icom is unchanged: its PTT-off is queued before the power restore, and the radio owns its own timing.
Maintainer decisions (Jeremy):
tx_onpoll, and 0x17 is not written.Out of scope (unchanged)
tune_driveor MIDI power limits. Note that TCItune_driveand the automationtransmit tunepowerverb go throughTransmitModel::setTunePower, so on HL2/Icom they now also apply live while TUNE is keyed, as they already did on Flex.Tests (all socket-free; nothing keys real hardware)
transmit_seam_drop_notice_test:setTunePower(30)arrives exactly once, with no re-key and no drop notice.startTunecarries the value.applyChanges, a different value) sends no call.stopTune, no call.applyChanges,tune=true) with no live Tune activity: no call, and the drop notice stands. This pins the Tune-activity check on its own, in both the forward and the gate.hl2_tx_gate_testreads the 0x09 bank bytes off the real EP2 packet builder.The same file has the transmit-tail tests, with the hold parked so each test ends the tail itself:
MetisClient.icom_backend_seam_testchecks the CI-V14 0Awrites:tx.powerscrub during TUNE re-asserts the live 15% and keeps 50% held.rfPower. The premise is checked first: the same readback outside TUNE does publish.Mutation results (each mutation made its test fail, then the file was restored and verified by sha256 against HEAD)
!tuneKeyedtunePowerChangedinsteadisTuning/Tune-activity check removedm_tuningguard removedsetTxPowerapplyDrivebypassedsetTxPowertail check removedsetTunePowernot marking drive owedsetTunePowerguard removedsetTunePowerno-opsetTxPowersetTxPowersetTune(true)tuneCarrierLiveisTuning()aloneMerge with main
The branch was cut from
2d5712946. It now carries signed merges oforigin/mainup toc7513e125(27 commits, including #6062, #6005, #5979, #5982, #5976, #6011, #5980, #6019, #5981, #6012 and #6050 on HL2, and #6049 inRadioModel). Every merge was clean. The files both sides touched (Hl2Backend.cpp/.h,IRadioBackend.h,RadioModel.cpp,radio-capabilities-map.md) needed no manual resolution, and none of main's changes in them touch keying, drive or TUNE. Everything below was re-run on the current head.Static gates (merge base
c7513e125vs merged head, findings diffed per file)check_engine_boundary.py --strict,gen_touchpoint_manifest.py --check,check_capability_records.py --strictandcheck_command_plane.py --strictall produce identical findings on base and head once line numbers are normalised:No ratchet moved, and no new wire text was added.
gen_seam_probe_table.py --checkis clean (51 signals): this PR adds a seam verb but no seam signal.Not verified
tx_onor RF output with this change.thumbdv_queue_testand four new socket tests from main are excluded too): all pass exceptconnection_panel_size_test, which is a known local failure (Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811).transmit_seam_drop_notice_test,hl2_tx_gate_testandicom_backend_seam_testpass.🤖 Generated with Claude Code