Skip to content

feat(tx): apply TUNE power live while TUNE is keyed (HL2, Icom). Principle VI. - #6104

Merged
ten9876 merged 11 commits into
mainfrom
feat/live-tune-power
Oct 3, 2026
Merged

ten9876 merged 11 commits into
mainfrom
feat/live-tune-power

Conversation

@ten9876

@ten9876 ten9876 commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • HL2 transmit tail. At TUNE release the RF drive was restored right after MOX-off, but the HL2 keeps radiating for its FIFO drain plus PTT hang after the MOX-off frame. The restore landed under the still-draining full-scale tune tone. The drive restore and the receive filter switch now wait out that tail. (Found by ON8ST.)
  • Icom restore. An RF power change made during TUNE was written over the tune drive and then lost at the unkey.

What changed

Seam and capability

  • IRadioBackend::setTunePower(int) is a new verb with a default no-op, next to setTxPower. It never keys. A backend that generates its own carrier re-applies drive to a carrier in progress and otherwise does nothing; the next setTune() carries the value. Owner-thread contract as for setTxPower.
  • RadioCapabilities::TransmitDriveControl::tunePowerAppliesLive defaults to false. It is a field inside the existing record, not a top-level bool, so FROZEN_BOOL_COUNT stays at 71 (check_capability_records.py --strict reports 71 on both base and head). The contract text in IRadioBackend.h and docs/architecture/radio-capabilities-map.md are updated to match.

Models

  • New signal TransmitModel::tunePowerCommandIssued(int). Only setTunePower() emits it, after commandReady, mirroring rfPowerCommandIssued. applyChanges() (status decode) never emits it.
  • RadioModel connects to it in the constructor's model-lifetime block, next to rfPowerCommandIssued (RadioModel::setupBackend() leaks a connectionStateChanged connection per family switch #4599). It forwards a change only when all of these hold:
    • isTuning() is true
    • a live TxActivity::Tune contribution exists
    • the backend declares tunePowerAppliesLive
  • The connect checks tunePowerAppliesLive before calling setTunePower. 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.
  • Both the forward and the drop-notice gate use one predicate, RadioModel::tuneCarrierLive(): isTuning() AND a live TxActivity::Tune. The gate is routed = 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.
  • Every TransmitDriveControl is built with the field stated explicitly, as the ADDING A FIELD rule requires: true on HL2 and Icom, false on Flex (whose tune power travels as command-plane text) and ANAN. Sim and RTL declare no record.

HL2

  • Hl2Backend::setTunePower writes only the drive register, through applyDrive(), so the clamp and the transmit gate still apply. It doesn't touch m_rfPowerPercent, band memory or notifyOperatingStateChanged, and it doesn't route through setTxPower or re-call setTune(true).
  • It acts only while m_tuning is true. It also acts only while the TUNE operation, stored as m_tuneOperation in setTune(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 clear m_tuneOperation, so it never outlives the carrier.
  • tunePowerAppliesLive = true in the HL2 capabilities.

Icom

  • IcomCivBackend::setTunePower writes CI-V 14 0A (the tune drive) while tuning and does nothing otherwise. It also requires the same admission setTune(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 on aether.icom.tx, because the verb returns nothing to RadioModel. tunePowerAppliesLive = true in the Icom capabilities.

  • Restore fix. setTxPower during TUNE now records the value in m_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 in setTune(true) and the tx.power controls 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 as TransmitDelta::rfPower while 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.

    • Icom has no tune-carrier command, so TUNE writes the tune level into 14 0A.
    • sendUserCommand() queues a confirming read of that level 60 ms later (queueRead(..., now + 60)).
    • The kRfPower reply handler published it as TransmitDelta::rfPower.
    • icom_backend_seam_test scripts 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 0A read 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:

  • MOX travels with the samples, not as a register. dsopenhpsdr1.v:351-352 tags each sample with ds_cmd_ptt.
  • In PTTTX, radio.v drains 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.
  • It then holds the last sample for ptt_hang_time (12 ms default, radio.v:935, 1036-1047). tx_on stays high until the state machine reaches NOTX (radio.v:1002).
  • Drive is not buffered. ad9866ctrl.v:134-140 loads 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_on is reported only in the discovery / alternate-port reply: resp_control (control.v:899) feeds usopenhpsdr1.v:292 (6'h20). The EP6 stream's responses carry ptt_resp = cw_on | ext_ptt instead (control.v:456, 472-475), which a host MOX never raises. MetisProtocol.cpp:546 is in parseDiscoveryReply(). Reading tx_on mid-stream would mean polling the discovery port during TX, which the stream-free telemetry design avoids because that reply preempts EP6 (Hl2TelemetryCadence.h). Gateware without EXTENDED_RESP also sends zeros there, so a "tx_on low" trigger would fail open.

Fix (Hl2Backend).

  • Every key-up arms a transmit tail of kTxTailHoldMs = 70 ms, after the MOX-off is queued.
  • 70 ms is never below the existing RX unkey hold (a static_assert pins 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.
  • While the tail is pending, applyBandFilter() keeps the transmit filter byte, and setTxPower() records an owed RF drive without writing it.
  • When the tail 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, and the drive stays owed to the next unkey's tail.
  • A disconnect or connection reset drops the tail.

Icom is unchanged: its PTT-off is queued before the power restore, and the radio owns its own timing.

Maintainer decisions (Jeremy):

  1. A fixed 70 ms timer ends the tail. There is no tx_on poll, and 0x17 is not written.
  2. A voice key inside the tail of a TUNE release keeps the tune drive for that transmission. The restore is cancelled, never applied over the new carrier, and RF drive returns at the next unkey's tail.
  3. The transmit filter byte is held for the tail on every unkey, including CW break-in elements.

Out of scope (unchanged)

  • No changes to TCI tune_drive or MIDI power limits. Note that TCI tune_drive and the automation transmit tunepower verb go through TransmitModel::setTunePower, so on HL2/Icom they now also apply live while TUNE is keyed, as they already did on Flex.
  • No slider dimming or tooltip UX for backends that don't declare the field.
  • No tune-power ceiling tied to RF power. Flex keeps them independent, and so does this PR.
  • Drag flood is accepted as is, matching RF power: no coalescing and no debounce.

Tests (all socket-free; nothing keys real hardware)

  • transmit_seam_drop_notice_test:

    • Flag on: setTunePower(30) arrives exactly once, with no re-key and no drop notice.
    • Flag off: no call, and the notice stays.
    • Not tuning: no call, and the next startTune carries the value.
    • A decoded tune power while keyed (applyChanges, a different value) sends no call.
    • After stopTune, no call.
    • A tune state decoded off the radio (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_test reads the 0x09 bank bytes off the real EP2 packet builder.

    Step 0x09 bank result
    RF power 40% drive 102
    TUNE at 10% drive 25
    live 60% drive 153, PA on
    150% 255
    −5% 0, PA off
    release 102 restored
    80% after release no bank, no keyed frame
    gate closed while tuning 0, PA off
    TUNE operation emergency-stopped no bank
    release stored TUNE operation dropped
    stopped client no bank

    The same file has the transmit-tail tests, with the hold parked so each test ends the tail itself:

    • The unkey's queued calls post no RF-drive restore; walked packet by packet with an event filter on MetisClient.
    • The tail ending restores RF drive.
    • A re-key inside the tail cancels it; no restore under the new carrier, and the next unkey's tail pays it.
    • TUNE re-keyed inside the tail keeps its tune drive.
    • RF power set during the tail waits for it.
    • A live tune drive with no key-time tune power is still restored.
    • A disconnect drops the tail.
    • On a TX-only filter board, the TX byte holds through the tail, and through a retune inside it, until the tail ends.
    • The real timer ends a 20 ms tail.
  • icom_backend_seam_test checks the CI-V 14 0A writes:

    • The capability is declared.
    • Not tuning: no write.
    • While tuning: the live 25% is written, with no re-key.
    • RF 50% during TUNE: no write, and it is held for restore.
    • A TUNE re-request writes 15% and keeps 50% held.
    • A tx.power scrub during TUNE re-asserts the live 15% and keeps 50% held.
    • Release writes 50%.
    • After release: no write, and the stored TUNE operation is dropped.
    • A TUNE whose operation was emergency-stopped: no write.
    • A lapsed transmit context with the operation still live: no write, and one warning logged.
    • An RF power readback of the tune level during TUNE publishes no 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)

Mutation Result
RadioModel connect deleted RED
gate reverted to !tuneKeyed RED
flag ignored (gate + connect) RED
wired from tunePowerChanged instead RED
isTuning/Tune-activity check removed RED
Tune-activity check alone removed RED
HL2 m_tuning guard removed RED
HL2 implemented via setTxPower RED
HL2 applyDrive bypassed RED
HL2 tune-operation check removed RED
HL2 flag not declared RED
HL2 tail deferral removed (restore at unkey) RED
HL2 re-key cancel removed RED
HL2 setTxPower tail check removed RED
HL2 filter predicate ignores the tail RED
HL2 disconnect cancel removed RED
HL2 setTunePower not marking drive owed RED
Icom restore fix reverted RED
Icom setTunePower guard removed RED
Icom setTunePower no-op RED
Icom TUNE re-request via setTxPower RED
Icom scrub via setTxPower RED
Icom flag not declared RED
Icom tune-drive readback published during TUNE RED
Icom tune-operation check removed RED
Icom tune operation not stored at setTune(true) RED
Icom tune operation not cleared at unkey RED
Icom transmit-context check removed RED
Icom refusal warning removed RED
Drop-notice gate ignores tuneCarrierLive RED
Forward uses isTuning() alone RED
HL2 tune operation not cleared at unkey RED

Merge with main

The branch was cut from 2d5712946. It now carries signed merges of origin/main up to c7513e125 (27 commits, including #6062, #6005, #5979, #5982, #5976, #6011, #5980, #6019, #5981, #6012 and #6050 on HL2, and #6049 in RadioModel). 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 c7513e125 vs merged head, findings diffed per file)

check_engine_boundary.py --strict, gen_touchpoint_manifest.py --check, check_capability_records.py --strict and check_command_plane.py --strict all produce identical findings on base and head once line numbers are normalised:

  • Engine boundary: 102 tracked findings, 0 blocking.
  • Touchpoint manifest: up to date.
  • Capability records: 71 bools.
  • Command plane: 401 commands across 32 files.

No ratchet moved, and no new wire text was added. gen_seam_probe_table.py --check is clean (51 signals): this PR adds a seam verb but no seam signal.

Not verified

  • No live-radio test on HL2 or Icom; per the TX rules, nothing was keyed. The Icom path, including the readback leak and its guard, is proven only against the backend's CI-V output and scripted CI-V replies. A real-Icom CI-V capture of TUNE plus one RF-power wheel step is still wanted.
  • Icom's RF-power readback is suppressed during TUNE. CI-V cannot tell the confirming read of the tune drive from a front-panel change, so an RF-power knob turned on the radio during TUNE is not seen and the unkey restores the pre-TUNE value (the code comment says so).
  • The HL2 transmit-tail fix has no hardware test. Its 70 ms rests on the gateware source defaults and on bench measurements made before it existed: ON8ST's 40-54 ms unkey, and the RX unkey-hold measurement of the same tail. Nothing here observed tx_on or RF output with this change.
  • Full default ctest at -j8 on the merged head, with every test whose source uses a socket API excluded (523 of 620 run; the pty-backed thumbdv_queue_test and four new socket tests from main are excluded too): all pass except connection_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_test and icom_backend_seam_test pass.

🤖 Generated with Claude Code

ten9876 and others added 4 commits October 2, 2026 07:50
…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
ten9876 requested a review from a team as a code owner October 3, 2026 00:36
@ten9876 ten9876 self-assigned this 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.

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-281
  • src/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)

  1. Icom setTunePower has no admission defence (src/core/backends/icom/IcomCivBackend.cpp:4979-4985). HL2 checks m_tuneOperation and Icom checks only m_tuning. Icom is safe today, because every unkey path clears m_tuning through stopTuneProducer():

    • applyKeying at :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_tuning latched. The verb writes only the 14 0A level and never keys, so this is not a TX-safety hole. If you want the two backends to match, store the TUNE operation in setTune(true) and apply the same check.

  2. m_tuneOperation outlives the carrier (src/core/backends/hl2/Hl2Backend.h:882). It is set at Hl2Backend.cpp:4690 and never cleared, so the backend holds the stale operation's shared state until the next TUNE. It is harmless, because setTunePower checks m_tuning first. Resetting it next to m_tuning = false (Hl2Backend.cpp:4043) would make the lifetime match.

  3. 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 while TransmitModel::isTuning() is still latched would pin it, for example an emergency stop with no stopTune(). The HL2 operation check is the backstop and is pinned. Icom has no backstop (nit 1).

  4. The drop-notice gate and the connect use different predicates (RadioModel.cpp:1927 passes isTuning() only; :2127-2128 also 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.

  5. No hardware verification, which the body discloses. Neither the HL2 drive change nor the Icom 14 0A readback 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) needs m_tuning, which only setTune(true) sets, after permitsDispatch. It also needs the stored TUNE operation to still permit dispatch. It only calls applyDrive, which clamps to 0..100 and writes 0 with the PA off when m_txAllowed is false. MetisClient::setTxDriveLevel forces 0 again at the wire.
    • Icom writes only 14 0A, and only while m_tuning.
    • RadioModel forwards only while isTuning(), a live Tune activity and the flag all hold.

    No path keys. Callers of TransmitModel::setTunePower are the TxApplet, MIDI, TCI tune_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.

  • 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, so setTunePower writes nothing. Mutation: I removed the operation check, and hl2_tx_gate_test failed 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) clears m_tuning first, so it does no restore, as before. MOX-off (:4058-4063) is now posted before the drive restore (:4069). Both are invokes to the same MetisClient, 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, the tx.power scrub and the tune drive itself use writeTxPowerLevel and 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. FlexBackend doesn't override setTunePower, 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 f2acec1e5 vs head. Findings from check_engine_boundary --strict, gen_touchpoint_manifest --check, check_capability_records --strict (71), check_command_plane --strict (406/32) and gen_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 connectRadio override. 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 except connection_panel_size_test (known locally, #5811).

  • Automated pass. /code-review at 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
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Review findings addressed in 6dc5aafa8

The maintainer decided to fix the blocker and every nit in this PR. Each fix below is proved by a test that fails when its guard is removed. For each mutation, I restored the file afterwards and checked its sha256.

Blocker 1: the ADDING A FIELD rule.

  • FlexBackend.cpp:280-282 and AnanBackend.cpp:427-428 now state /*tunePowerAppliesLive=*/false.
  • These were the only two TransmitDriveControl built with the field left at its default. HL2, Icom and the test fixture already stated it.
  • Sim (SimBackend.cpp:296) and RTL (RtlSdrBackend.cpp:127) declare no record, so the field doesn't exist there.
  • There is no kiwi backend.

Nit 1: Icom had no admission check.

  • IcomCivBackend::setTunePower now refuses once the TUNE operation stored in setTune(true) no longer permits dispatch. This is the same check as HL2.
  • New test in icom_backend_seam_test: "tune power after the TUNE operation was stopped writes nothing".
  • Removing the check makes that test fail.
  • Not storing the operation in setTune(true) makes "tune power while tuning writes the 25% tune drive" fail.

Nit 2: the stale m_tuneOperation.

  • HL2 now clears it at every unkey (applyKeying(false)) and at connection reset.
  • Icom clears it in stopTuneProducer(), which every unkey goes through, and in both session-reset paths.
  • New check in both tests: "TUNE release drops the TUNE operation", with a premise that the operation is held while tuning. A setTunePower after release still writes nothing.
  • Removing the clear makes the test fail, on HL2 and on Icom.

Nit 3: the Tune-activity check is now pinned on its own.

  • New test tunePowerNotForwardedWithoutTuneActivity: a tune state decoded off the radio, with no live Tune activity, forwards no setTunePower().
  • Removing only the activity check from RadioModel.cpp:2127-2128 makes it fail with "tuning without a live Tune activity: no setTunePower() reaches the backend".

Nits 4 and 5: no code change.

  • Nit 4: no value is lost, because the next key-down carries it. The new test above also covers the gap between the two conditions.
  • Nit 5: hardware verification is still open, and the PR body says so.

Proof, on 6dc5aafa8:

  • Clean -j8 build.
  • transmit_seam_drop_notice_test, hl2_tx_gate_test and icom_backend_seam_test pass.
  • Full socket-free ctest: 512 of 604 ran. Every test that uses a socket, or the pty-backed thumbdv_queue_test, was excluded. All pass except connection_panel_size_test, which also fails locally on main (Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811).
  • I ran the four static gates and the seam probe table check on the merge base f2acec1e5 and on the head. The findings are identical: 71 capability bools, 406 commands across 32 files, 51 signals.
  • The PR body now describes these changes.

ten9876 and others added 2 commits October 2, 2026 18:21
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

@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, 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)

  1. The forward gate and the drop-notice gate disagree, and the new test asserts only half of it (inline on RadioModel.cpp:1927, :2128, and transmit_seam_drop_notice_test.cpp:260). Forwarding needs isTuning() && (activeTxActivities() & Tune) && appliesLive; the notice gate at RadioModel.cpp:2360-2362 passes only m_transmitModel.isTuning() as tuneKeyed. In the overlap — tuning, no live Tune activity, appliesLive true — the value reaches no setter and the notice is suppressed. That is the silent drop #5263/#5637 exist to prevent, and tunePowerNotForwardedWithoutTuneActivity constructs exactly that state while checking only tunePowers.isEmpty(). I traced the reach and believe it is currently unreachable on HL2/Icom: neither decodes TransmitDelta::tune, and the coordinator path (RadioModel_TxCoordinator.cpp:936) uses the no-arg TransmitModel::stopTune(), whose refusal branch is if (route.admit && …) with route.admit null — so it always clears m_tune. Still a latent inconsistency, and the cheap fix is one liveTuneKeyed() helper used by both sites.
  2. The revoked-admission case is also silent (inline on IcomCivBackend.cpp:4986). emergencyStop() while tuning makes setTunePower return 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.
  3. Icom: a front-panel RF-power change during TUNE is now entirely invisible (inline on IcomCivBackend.cpp:2070). The suppression is correct for the confirming 14 0A read, but it also swallows an unsolicited front-panel publication — which m_preTuneTxPowerPercent then 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.
  4. setTunePower skips transmitContext() (inline on IcomCivBackend.cpp:4986). setTune(true) checks the operation and the context; setTunePower checks only the operation. HL2 is covered incidentally by applyDrive's m_txAllowed assert-off; Icom has no equivalent. Drive is not keying, so this is not a Principle VI hole, but the asymmetry is unexplained.
  5. 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 takes tunePowerAppliesLive=false, which is no HL2.
  6. 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 = key lands at Hl2Backend.cpp:3988, so applyBandFilter at :4025 already selects the receive filter byte (transmitting = m_keyed || m_tuning is 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, and applyDrive asserts drive 0 with the PA bit off when !m_txAllowed); Icom → writeTxPowerLevel → CI-V 14 0A only. Neither touches setMox/setCwMox or 1C 00, and both are gated on m_tuning plus a still-admitted m_tuneOperation. TxCoordinator::Operation::permitsDispatch returns false on !m_state (TxCoordinator.cpp:51), so a default-constructed m_tuneOperation really does refuse — the clearing at unkey is load-bearing, not cosmetic.
  • The latched-m_tuning attack. HL2 setTune(true) sets m_tuning before setKeying(true), which returns early at !m_txAllowed without clearing it — so a gate-closed HL2 can latch m_tuning with no carrier, and the PR adds a path that acts on it. Dead end in production: m_txAllowed is set once at Hl2Backend.cpp:304 and never flips, and capabilities().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_preTuneTxPowerPercent is snapshotted before m_tuning = true and before the tune write; the re-request branch does not re-snapshot it; the tx.power scrub re-asserts m_txPowerPercent (the tune drive) and leaves the held value alone; the disconnect emergency path (:910) writes the held value and runs before disconnectRadio clears it; applyKeying and the PTT-readback path (:2943) both call stopTuneProducer() before the restore, so setTxPower is no longer in the m_tuning branch when the write has to happen. Double unkey returns -1 the 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 branches return, so no case falls through.
  • Would the tests pass against the unfixed code? tunePowerChangedWhileKeyedAppliesLive fails 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-op setTunePower leaves lastDrive empty and the check fails; InterleaveProbe builds a packet before each delivered MetaCall, and setTxDriveLevel is 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:506 and RadioModel.cpp:2597 both read m_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 on main); 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_runs on ccd4176c: 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 tunePowerAppliesLive row 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

Comment thread src/models/RadioModel.cpp Outdated
Comment thread src/models/RadioModel.cpp
Comment thread tests/transmit_seam_drop_notice_test.cpp
Comment thread src/core/backends/hl2/Hl2Backend.cpp
Comment thread src/core/backends/icom/IcomCivBackend.cpp
Comment thread src/core/backends/icom/IcomCivBackend.cpp
Comment thread tests/transmit_seam_drop_notice_test.cpp Outdated
@on8st

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

The HL2 unkey reorder narrows the window but cannot close it: the gateware keeps transmitting after the MOX-off frame.

  • Keyed IQ already in the TX FIFO still goes out. MOX is carried with the samples, not as a register: dsopenhpsdr1.v:351-352 tags each sample with ds_cmd_ptt, and radio.v (PTTTX, 1030-1049) drains the samples already queued with that tag. tx_buffer_latency defaults to 20 ms (radio.v:934), and AetherSDR never writes 0x17.
  • After that, the last sample is held for ptt_hang_time, 12 ms by default (radio.v:935, 1042-1048), with tx_on still high.
  • Drive takes effect when the command arrives, not through that FIFO: ad9866ctrl.v:134-140 loads the TX gain at once.

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.

testTuneReleaseUnkeysBeforeRestoringDrive checks host packets, so it cannot see this. Suggestion: restore drive once telemetry tx_on (0x1b[3], MetisProtocol.cpp:546) reads low, or at least after the 70 ms unkey hold (Hl2Backend.h:933). The same timing applies to the bot's note about applyBandFilter("unkey").

The rest of the HL2 side checks out against the source.

73, ON8ST

ten9876 and others added 2 commits October 3, 2026 07:47
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
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

@on8st Thank you, this was a real hole. The fix is in 319a83f11.

What changed (Hl2Backend).

  • Every key-up now arms a transmit tail of 70 ms (kTxTailHoldMs), starting after the MOX-off is queued. Until the tail ends:
    • the RF drive is not restored;
    • applyBandFilter() keeps the transmit byte, both for applyBandFilter("unkey") and for a retune during the tail;
    • setTxPower() only records the value.
  • A re-key cancels the tail, so a restore never lands on a new carrier. Disconnect and reset drop it.

Why 70 ms. It is never below the RX unkey hold, and it covers:

  • the 20 ms FIFO plus 12 ms hang defaults you cited (radio.v:934-935);
  • your 40-54 ms measurement;
  • our RX-hold measurement of the same tail, 51.66-66.15 ms.

Why a timer and not tx_on. One correction to the suggestion: tx_on reaches the host only through the discovery / alternate-port reply. resp_control (control.v:899) feeds usopenhpsdr1.v:292 (6'h20), and MetisProtocol.cpp:546 is in parseDiscoveryReply(). The EP6 responses carry ptt_resp = cw_on | ext_ptt instead (control.v:456, 472-475). So reading it during TX would mean polling the discovery port, which preempts EP6. Jeremy chose the fixed timer, recorded in the PR body with the other two decisions:

  • a voice re-key inside the tail keeps the tune drive;
  • the TX filter byte is held for every unkey's tail, CW break-in included.

Tests. hl2_tx_gate_test no longer relies on host packet order. It now checks that:

  • the unkey posts no drive restore;
  • the tail ending restores the drive;
  • a re-key cancels the restore;
  • RF power set during the tail waits for it;
  • the TX filter byte holds through the tail and through a retune inside it;
  • a disconnect drops the tail;
  • the real timer ends it.

Removing each guard makes its test fail.

Other checks. The full socket-free ctest passes (523 run), except the known local connection_panel_size_test failure (#5811). The four static gates are unchanged against main.

Not hardware-tested. Nothing observed tx_on or RF output with this change. If you can re-measure your unit's unkey on this head, that would close it.

@on8st

on8st commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Thank you, and you are right about tx_on: my suggestion was wrong on that point. MetisProtocol.cpp:546 is in the discovery-reply parser, so the bit is not available in the EP6 stream during transmit. The fixed 70 ms tail is the sound choice.

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

ten9876 and others added 2 commits October 3, 2026 09:09
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
@ten9876
ten9876 merged commit fe1fec5 into main Oct 3, 2026
7 checks passed
@ten9876
ten9876 deleted the feat/live-tune-power branch October 3, 2026 17:27
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Admin-merged on the maintainer's approval (self-authored PR, so a GitHub approval isn't possible). Head 86772de2b:

  • All 7 review threads fixed or answered with evidence, and resolved. The notice gate and the forwarding connect now share tuneCarrierLive(). Icom setTunePower checks transmit-context admission and logs a refusal.
  • HL2 transmit tail (found by @on8st): RF drive and the RX filter restore only after a 70 ms tail. A re-key cancels the pending restore. Not hardware-tested yet; on8st will measure the unkey at their next keyed session.
  • All required checks green, from a run created after main's tip. Drift: fix(sim): open the gate before announcing connected, reply before disconnecting. Principle VIII. #6108 (23a845e7) landed since, touches none of this PR's files, and merges cleanly.

ten9876 added a commit that referenced this pull request Oct 3, 2026
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
ten9876 added a commit that referenced this pull request Oct 3, 2026
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>
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 3, 2026
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>
ten9876 added a commit that referenced this pull request Oct 3, 2026
… 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>
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.

2 participants