fix(smartmtr): TX meter + audio gate engage on VOX-keyed transmit#3862
Conversation
…thersdr#3861) VOX keys the radio autonomously, so the client never calls setTransmit() and m_txRequested stays false. The interlock gate only treated m_txRequested, CW key, CWX, Tune, or hardware PTT sources (MIC/ACC/RCA) as legitimate owned-TX paths, so a VOX-keyed state=TRANSMITTING fell into the force-off branch — setTransmitting(false) kept the SmartMtr on the RX Signal scale and the TX audio gate closed. Extract the force-off decision into a pure helper RadioStatusOwnership::interlockKeepsLocalTxOn and add VOX as an owned-TX path gated on TransmitModel::voxEnable(). Gating on voxEnable() (which the radio echoes via transmit ... vox_enable=1) rather than a source=="VOX" token is correct regardless of the interlock source string and cannot mis-fire — it is still ANDed with m_txOwnedByUs, and non-TX interlock states still fall through to the existing release path, so VOX-enabled does not stick TX on. When VOX is off the boolean is bit-identical to before, so MOX/PTT, hardware PTT, CW, CWX, Tune, and the SW optimistic local-unkey behaviour are unchanged. Adds testInterlockTxGate() with VOX cases plus regression guards (SW unkey, foreign-owner force-off, hardware PTT, CW, Tune, MOX). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for the thorough writeup and tests, @dsocha — this is a clean fix and the root-cause analysis matches what I see in the code.
Verified
- Faithful refactor. The extracted
interlockKeepsLocalTxOn()is the exact De Morgan inverse of the old inline force-off condition atRadioModel.cpp:4995. WithvoxEnabled == falseit is bit-identical, so MOX/PTT, hardware PTT (MIC/ACC/RCA), CW, CWX, Tune, and the SW optimistic-unkey path are all unchanged. The 6 regression-guard cases in the test confirm this. voxEnable()is the right gate. Gating on the persistentvoxEnable()flag is safe precisely because actual TX assertion still requiresstate == "TRANSMITTING"— when VOX is enabled but the radio isn't keyed, the keep-on result just routes into the finalelse, which still setssetTransmitting(false). So VOX-enabled cannot stick TX on, exactly as your description claims. Good reasoning.- Foreign-TX still wins. The early
if (!txOwnedByUs) return false;preserves the foreign-owner force-off, and the test covers it. - Namespace/header wiring (
AetherSDR::RadioStatusOwnership, caller's qualified call, test'susing namespace) all resolve. Header is#pragma once, pure helper, no RAII/lifetime concerns.
One minor thing to be aware of (not a blocker)
When VOX is enabled and the user also keys via client MOX/PTT (SW source), the optimistic local-unkey path is slightly relaxed: after setTransmit(false) (so txRequested == false), a stale state=TRANSMITTING frame now keeps the gate on for that one frame (voxEnabled short-circuits the keep-on), instead of forcing off immediately. It self-corrects on the next non-TRANSMITTING status, and arguably it's more correct (with VOX on and live audio the radio may genuinely still be keyed) — but worth noting since the "bit-identical" guarantee only holds with VOX off. No change requested; just flagging for the record.
Nice that you added the first unit coverage for this gate. LGTM from a correctness standpoint.
🤖 aethersdr-agent · cost: $2.0229 · model: claude-opus-4-8
ten9876
left a comment
There was a problem hiding this comment.
Approved. Did the full TX-safety analysis (Principle VI) on the interlock gate, not just the helper:
- Foreign-TX force-off wins first —
if (!txOwnedByUs) return false;is unconditional, before VOX. Multiflex safety preserved. - Bit-identical with VOX off — the extracted
interlockKeepsLocalTxOn()is the exact De Morgan inverse of the old inline condition; the only addition is|| voxEnabled. MOX/PTT, hardware PTT, CW, CWX, Tune, SW optimistic-unkey unchanged (6 regression-guard tests confirm). - VOX-enabled cannot stick TX on — actual TX only asserts via
else if (state == "TRANSMITTING"); a VOX-enabled non-transmitting state (RECEIVE/READY) still falls to the finalelseandsetTransmitting(false). No stuck TX / stuck-open audio gate. This is the key property and it holds. - Gated on the persistent
voxEnable()flag (radio-echoed), ANDed withtxOwnedByUs— robust to whatever interlock source string the radio emits.
First unit coverage for this gate (10 cases incl. foreign-owner force-off with VOX, VOX-off regressions). CI green on all 6. Thanks @dsocha — clean fix with exactly the right tests for a TX-interlock change.
Closes #3861
Problem
The SmartMtr TX meter (and the TX audio gate) did not engage when transmit was keyed via VOX, though it worked for MOX/PTT, hardware PTT (MIC/ACC/RCA), CW, and Tune.
Root cause
VOX keys the radio autonomously, so the client never calls
setTransmit()andm_txRequestedstaysfalse. The interlock gate inRadioModelonly treatedm_txRequested, CW key, CWX, Tune, or hardware PTT sources (MIC/ACC/RCA) as legitimate owned-TX paths. A VOX-keyedstate=TRANSMITTINGmatched none of them, so the force-off branch ransetTransmitting(false)—moxChangednever fired, the SmartMtr stayed on the RX Signal scale, and the TX audio gate stayed closed.The recognized sources (
SW/MIC/ACC/RCA/TUNE) come from FlexLibParsePTTSource(added in #2373 for the analogous hardware-PTT case); VOX was never added.Fix
Extract the force-off decision into a pure helper
RadioStatusOwnership::interlockKeepsLocalTxOn(...)and add VOX as an owned-TX path gated onTransmitModel::voxEnable().Gating on
voxEnable()(which the radio echoes viatransmit ... vox_enable=1) rather than matching asource=="VOX"token is correct regardless of the interlock source string the radio emits, and cannot mis-fire:m_txOwnedByUs, so foreign-TX force-off wins first;state=TRANSMITTING.Non-breaking
When VOX is off, the boolean is bit-identical to before, so MOX/PTT, hardware PTT, CW, CWX, Tune, and the SW optimistic local-unkey behaviour are unchanged.
Tests
Adds
testInterlockTxGate()with VOX cases plus regression guards (SW unkey, foreign-owner force-off, hardware PTT, CW, Tune, MOX). The interlock gate had no unit coverage before; extracting the decision makes it testable.transmit_model_test)🤖 Generated with Claude Code