Skip to content

fix(cat): make set_split_vfo / tx_enable idempotent to stop radio TX watchdog#2568

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
pepefrog1234:fix/cat-set-split-vfo-idempotent
May 12, 2026
Merged

fix(cat): make set_split_vfo / tx_enable idempotent to stop radio TX watchdog#2568
ten9876 merged 1 commit into
aethersdr:mainfrom
pepefrog1234:fix/cat-set-split-vfo-idempotent

Conversation

@pepefrog1234

Copy link
Copy Markdown
Contributor

Summary

Digital-mode software (VARA HF / VARAC / WSJT-X / N1MM / fldigi) over rigctld is currently unusable on FLEX-8600 fw 4.1.5: PTT engages and the carrier comes up, but the radio drops back to RX after roughly 1 second even though the client is still asserting PTT and pushing dax_tx audio. This PR fixes that.

Root Cause

RigctlProtocol::cmdSetSplitVfo and TciProtocol::cmdTxEnable both called setTxSlice(true) unconditionally, emitting slice set <N> tx=1 to the radio every time the protocol handler ran — even when the slice was already the TX slice. The matching code in cmdSetPtt correctly guards with !isTxSlice(); only these two paths were missing the guard.

Hamlib-based contest loggers and digital-mode software poll set_split_vfo every ~3 s as part of their state-sync loop. AetherSDR exposes 8 CAT channels (kCatChannels = 8, RigctlServer + RigctlPty), so a single polling client multiplied by all the channels it touches produces a burst of identical slice set 0 tx=1 commands. Connection logs show them stacked at the same millisecond:

[07:04:32.299] TX: \"C77|slice set 0 tx=1\"
[07:04:32.299] TX: \"C78|slice set 0 tx=1\"
[07:04:32.299] TX: \"C79|slice set 0 tx=1\"
[07:04:32.299] TX: \"C80|slice set 0 tx=1\"
[07:04:35.312] TX: \"C84|slice set 0 tx=1\"     ← next poll, ~3 s later
[07:04:35.312] TX: \"C85|slice set 0 tx=1\"
[07:04:35.312] TX: \"C86|slice set 0 tx=1\"
[07:04:35.312] TX: \"C87|slice set 0 tx=1\"

This sustained spam interferes with the radio's internal TX-state tracking. When PTT subsequently engages, the radio firmware decides the stream is unhealthy and forces an UNKEY_REQUESTED roughly 1 s into the transmission:

[06:58:05.634] TX: \"C108|xmit 1\"
[06:58:05.666] RX: \"interlock state=PTT_REQUESTED source=SW\"
[06:58:05.666] RX: \"interlock state=TRANSMITTING source=SW\"
[06:58:05.832] sendToRadio #1800 bytes=284 sent=284 streamId=0x84000000   ← DAX audio flowing
[06:58:06.062] RX: \"interlock state=TRANSMITTING\"
[06:58:07.031] sendToRadio #2000 bytes=284 sent=284 streamId=0x84000000
[06:58:08.094] sendToRadio #2200 bytes=284 sent=284 streamId=0x84000000
[06:58:08.453] RX: \"interlock state=UNKEY_REQUESTED\"   ← radio unkeys itself after ~2.8s
[06:58:10.363] TX: \"C172|xmit 0\"                       ← client only sees CAT release here

The hamlib client (VARAC in this case) is still holding PTT — the radio gave up on its own.

Diagnosis Path

The symptom ("PTT cut after 1 s") naturally suggests DAX TX audio path, packet format, network timing, or firmware watchdog. We ruled all of those out with targeted instrumentation:

Check Result
xmit 1 reaches radio, radio enters state=TRANSMITTING
m_radioTransmitting flips true via radioTransmittingChanged
m_daxTxMode true in DIGU mode
feedDaxTxAudio receives audio throughout TX window
sendToRadio emits packets with correct stream ID (0x84000000) and size (284 B)
QUdpSocket::writeDatagram returns sent=284 (no kernel drop)
Radio still issues UNKEY_REQUESTED ~1 s after PTT

A counter-experiment confirmed direction: gating dax_tx packets so they only flow during m_radioTransmitting=true made the radio unkey in 127 ms instead of ~1 s — the radio wants a continuous packet stream, so packet flow isn't the trigger. The trigger is the CAT command spam: with the CAT service disabled entirely, PTT holds for the full transmission duration with the same DAX path, same firmware, same VARA build.

Fix

Add the same !isTxSlice() idempotency check that cmdSetPtt already has:

// RigctlProtocol::cmdSetSplitVfo — before
if (auto* s = currentSlice())
    s->setTxSlice(true);

// after
if (auto* s = currentSlice(); s && !s->isTxSlice())
    s->setTxSlice(true);

TciProtocol::cmdTxEnable gets the same treatment. When the slice is already the TX slice, the command is a no-op anyway; sending it again costs a TCP round-trip and apparently provokes firmware-side TX-state churn.

The first non-redundant call still goes through — e.g. on actual split disable, after slice teardown, or after profile-global load (the original #145 re-claim case is preserved in MainWindow.cpp:8598).

Why This Was a Deep Bug

  • The symptom (PTT drops mid-transmission) points at the TX/DAX subsystem, not the rigctld split-vfo handler.
  • The buggy code path is functionally correct in isolation — it sets the TX slice, which is what set_split_vfo 0 VFOA requests.
  • The matching cmdSetPtt has the guard, so the pattern was known; only this one handler missed it.
  • Single-client / quick-PTT testing doesn't reproduce it. It needs (a) a long-running hamlib client that polls split state, (b) multiple CAT channels active, and (c) a long enough PTT to cross the firmware's watchdog window.
  • v0.8.20 has the same un-guarded code, but doesn't exhibit the symptom in our setup — the radio's TX-state tracking has apparently become more sensitive between firmware patch levels, or other command-stream changes since v0.8.20 push the radio over the threshold.

Test Plan

  • Connect to FLEX-8600 fw 4.1.5.39794 on macOS (Apple Silicon, fw / hardware unchanged from v0.8.20)
  • Mode: DIGU; mic_selection=PC; DAX bridge enabled
  • VARA HF v4.9.0 (audio source) + VARAC (rigctld CAT) running
  • Before fix: PTT engages → carrier appears → radio unkeys after ~1–3 s with VARAC still asserting PTT
  • After fix: PTT holds for the full transmission until VARAC releases
  • Verified slice set 0 tx=1 no longer spammed every 3 s in the connection log
  • Reviewer to verify on other rigctld clients (N1MM, WSJT-X, fldigi) — same code path
  • Reviewer to verify TCI path on Windows / Linux (TciProtocol fix is parallel)

🤖 Generated with Claude Code

…watchdog

cmdSetSplitVfo unconditionally called setTxSlice(true) on every invocation,
emitting `slice set N tx=1` to the radio even when the slice was already
the TX slice. cmdTxEnable in TciProtocol had the same pattern.

Hamlib clients (VARA, VARAC, N1MM, WSJT-X, fldigi) poll set_split_vfo
every ~3 s as part of their state-sync loop. Multiplied by AetherSDR's
8 CAT channels (RigctlServer + RigctlPty) and several concurrent clients,
the radio receives bursts of redundant `slice set N tx=1` commands at a
sustained ~3 s cadence — observable in connection logs as four identical
commands at the same millisecond.

On FLEX-8600 firmware 4.1.5 this repeated tx-assignment churn appears
to disturb the radio's TX state machine: after PTT engages, the radio
issues `state=UNKEY_REQUESTED` roughly 1 s into the transmission even
though the client is still sending `xmit 1` and dax_tx VITA-49 packets
at the correct rate. Symptom for the operator is "PTT engages, carrier
appears, then radio drops back to RX after ~1 second" — making VARA HF,
VARAC, and any other digital-mode software effectively unusable.

Confirmed root cause by instrumenting feedDaxTxAudio and sendToRadio:
DAX TX packets continued to flow throughout the broken-PTT window with
correct stream ID (0x84000000), size (284 bytes), and socket sendto()
returning sent=284. The unkey was radio-side, not client-side packet
loss or audio gating. Disabling CAT entirely made PTT hold for the full
transmission duration, confirming the spam was the trigger.

Mirror the existing `!isTxSlice()` guard from cmdSetPtt (line 397):

  if (auto* s = currentSlice(); s && !s->isTxSlice())
      s->setTxSlice(true);

When the slice is already TX, the command is a no-op anyway — sending
it again costs a round trip and apparently provokes firmware-side
TX-state churn. The first non-redundant call (e.g. on actual split
disable, or after slice teardown / profile load) still goes through.

Tested on macOS with FLEX-8600 fw 4.1.5.39794 + VARA HF v4.9.0 + VARAC:
PTT now holds for the full transmission instead of unkeying after ~1 s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@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.

Thanks for the deep dive — that's an exceptionally well-documented root cause. The diagnosis path (ruling out DAX, packet stream, kernel writes before landing on CAT-side state churn) and the counter-experiment confirming direction are the kind of evidence that makes this kind of "looks like a TX-path bug" fix reviewable at all. Appreciated.

Verified against main:

  • RigctlProtocol.cpp:443-444 (split-disable branch) and TciProtocol.cpp:407-413 (tx_enable enable branch) both call setTxSlice(true) unconditionally.
  • RigctlProtocol::cmdSetPtt (line 397) and TciProtocol's set_ptt path (line 386) already have the !isTxSlice() guard.

So the fix really is just "apply the existing pattern to the two stragglers." Minimal, on-pattern, and the non-redundant cases (!enable split disable after teardown, profile reload re-claim) still flow through.

One thing worth checking before merge — in TciProtocol::cmdTxEnable, the current code wraps setTxSlice(true) in QMetaObject::invokeMethod(s, [s]() { ... }, Qt::QueuedConnection). The cmdSetPtt precedent (RigctlProtocol.cpp:395-399 and TciProtocol.cpp:384-388) puts the !isTxSlice() check inside the queued lambda, so the check runs on the same thread as the mutation. If this PR places the guard outside the invokeMethod call, it still kills the poll storm (caller thread sees isTxSlice() == true on subsequent polls), but there's a small TOCTOU window where another protocol channel could race. Putting it inside the lambda matches the established pattern and closes that window — not a blocker, but worth mirroring cmdSetPtt exactly.

For RigctlProtocol::cmdSetSplitVfo, the existing code calls currentSlice()/setTxSlice() directly (no invokeMethod), so the inline s && !s->isTxSlice() check is the right shape.

Two follow-up thoughts (not for this PR):

  • The same poll-storm hypothesis would predict similar firmware churn from any other CAT command that maps to a redundant radio-side state set. If you have logs handy showing only slice set ... tx=1 was the culprit, great; otherwise it might be worth a quick grep over the other hamlib state-sync handlers (set_freq, set_mode) for unguarded sets in the future.
  • The fact that v0.8.20 had the same code but didn't reproduce is interesting — agrees with your "radio TX-state tracking got more sensitive" theory, but might also be worth a one-line comment near the guard pointing future readers at this PR so the !isTxSlice() check doesn't get "cleaned up" by a future drive-by refactor.

Minor: the PR description has a templated placeholder leak — s ${PR_DIFF}${PR_DIFF} !s->isTxSlice() — looks like a render bug, not a code issue.

Nice work, and thanks for the patience to chase this down across the full TX pipeline.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude here, reviewing on Jeremy's behalf.

Verdict

Strong fix with exemplary diagnostic work. Merge-worthy after Jeremy verifies on his FLEX-8600.

The root-cause analysis is the kind I'd want every TX-path bug report to include — the counter-experiment (gating dax_tx packets shortens unkey to 127 ms, proving packet flow isn't the trigger) is the piece that elevates this from "guess that fixed it" to "this is the trigger, here's the evidence."

Verified

  • isTxSlice() reads radio-confirmed state, not a cached local prediction. SliceModel.h:34 returns m_txSlice which is set from radio status updates, so the idempotency check tests against the authoritative ground truth — exactly right.
  • SliceModel::setTxSlice is not idempotent at the model layer. SliceModel.cpp:332-335 sends slice set N tx=0/1 unconditionally. Moving the guard inside the model would be wrong (model would trust its own state vs. the radio's) — the PR's choice to guard at the call site is correct.
  • cmdSetPtt claim is correctRigctlProtocol.cpp:397-398 and TciProtocol.cpp:386-387 both already have the !isTxSlice() guard, matching the pattern this PR extends.
  • All build / check-paths / check-windows (skipped) / CodeQL / analyze (cpp) green.

Stale-code audit

setTxSlice(true) call sites across the codebase:

Site Pattern Status
RigctlProtocol.cpp:398 (cmdSetPtt) one-shot, polled but guarded ✓ pre-existing guard
RigctlProtocol.cpp:444 (cmdSetSplitVfo) polled every ~3s fixed by PR
TciProtocol.cpp:387 (one of the TCI handlers) guarded ✓ pre-existing guard
TciProtocol.cpp:411 (cmdTxEnable) TCI command path fixed by PR
MainWindow.cpp × 6 (lines 8929, 8962, 9312, 9360, 10290, 13086) one-shot user actions / split setup not bugs

The 6 MainWindow sites are user-button-press / slice-creation / split-setup handlers — they fire once per user action, not in polled loops, so they're outside the hot-loop scenario. Confirmed by quick context check that none are inside repeating timers or status-handler callbacks.

Minor notes (non-blocking)

  • The PR uses C++17 init-if syntax (if (auto* s = currentSlice(); s && !s->isTxSlice())). Matches project style; clean.
  • The added comment explaining the Hamlib polling cadence is good — exactly the kind of "comment non-obvious protocol decisions" that CLAUDE.md asks for. Worth adding firmware version to the comment (fw 4.1.5.39794 from your test setup) since the PR explicitly notes the symptom didn't reproduce on the same un-guarded code in v0.8.20 — that's a firmware-sensitivity signal future readers should see in the source.
  • No tests. Defensible — this needs a real hamlib client + radio + multi-second PTT window to reproduce. A unit test would just verify the guard exists, which the diff already does. Manual verification is the right test mode here.

What I'd want from Jeremy before merge

  • Verify on the FLEX-8600 with VARA HF / VARAC on the actual rig
  • Spot-check one other rigctld client (N1MM or fldigi) to make sure the fix doesn't regress them
  • Ideally Windows TCI client check for the parallel cmdTxEnable fix, though the same code path argument applies — if it works for rigctld it works for TCI

Thank you

@pepefrog1234 — the diagnostic discipline here (ruling out DAX path, packet format, network timing, firmware watchdog with targeted instrumentation, then the counter-experiment) is genuinely the right way to chase this kind of bug. The fact that v0.8.20 had the same unguarded code and didn't trigger is the honest note that keeps the analysis grounded — you're not claiming certainty beyond what you measured. Appreciated.

73, Jeremy KK7GWY & Claude (AI dev partner)

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved — diagnostic work is exceptional, root cause verified, fix is minimal and matches the existing cmdSetPtt guard pattern.

@ten9876 ten9876 merged commit 73d48b2 into aethersdr:main May 12, 2026
5 checks passed
ten9876 added a commit that referenced this pull request May 23, 2026
…#2931)

## Summary

With two or more slices, TX would not stay on the slice the user picked
— it kept jumping back to another slice every couple of seconds, and the
user couldn't keep TX where they wanted it. Root cause is
`cmdSetSplitVfo` reclaiming TX on every `set_split_vfo 0` **poll**
rather than on a real split-state change.

## Root Cause

Each rigctld / PTY CAT channel is bound to a fixed slice
(`m_sliceIndex`). `cmdSetSplitVfo` moved TX onto that slice whenever a
client sent `set_split_vfo 0` (split disabled). #2568 added an
`!isTxSlice()` guard to stop the redundant **command spam**, but that
only suppressed the already-TX case — it didn't stop TX from being
**seized**.

Hamlib loggers (VARA, VARAC, N1MM, fldigi) poll `set_split_vfo 0 VFOA`
every few seconds. With a CAT channel bound to a non-TX slice the
sequence was:

```
user clicks slice A TX badge    → slice set 0 tx=1   (A is TX)
~1.7 s later logger polls split → channel bound to slice B
cmdSetSplitVfo sees B != TX      → slice set 1 tx=1
TX jumps back to B
```

The `!isTxSlice()` guard actually *guaranteed* the re-seize: the user
moving TX away is exactly the moment the bound slice becomes non-TX,
which re-satisfies the guard on the very next poll.

Captured in the connection log (slice 0 = A, slice 1 = B, single client,
no split):

```
08:11:59.663  TX: C162|slice set 0 tx=1     ← user clicks A's TX badge
08:11:59.785  RX: slice 0 ... tx=1          ← A is TX (user succeeded)
08:12:01.379  TX: C167|slice set 1 tx=1     ← logger split poll, 1.7 s later
08:12:01.379  TX: C168|slice set 1 tx=1
08:12:01.379  TX: C169|slice set 1 tx=1
08:12:01.379  TX: C170|slice set 1 tx=1
08:12:01.498  RX: slice 1 ... tx=1          ← TX yanked back to B
```

## Fix

Track the last split state per `RigctlProtocol` instance (tri-state:
unknown / disabled / enabled) and reclaim TX only on a genuine
split→non-split **transition** (1→0 edge) — never on a steady-state poll
or the first report after connect.

```cpp
const bool wasEnabled  = (m_lastSplitEnable == 1);
const bool firstReport = (m_lastSplitEnable < 0);
m_lastSplitEnable = enable ? 1 : 0;

if (!enable && wasEnabled && !firstReport) {
    if (auto* s = currentSlice(); s && !s->isTxSlice())
        s->setTxSlice(true);
}
```

A logger that sits in non-split and polls repeatedly no longer fights
the user's TX choice, while a real "turn split off" still moves TX back
to the client's slice.

## Test plan

- [x] macOS, FLEX-6600 fw 4.2.18, VARAC on rigctld
- [x] Slice A = TX, add slice B → select TX back to A → TX **stays** on
A (previously yanked to B within ~2 s)
- [x] Genuine split toggle (split on → split off) still moves TX to the
channel's slice
- [ ] Reviewer: confirm contest-logger split workflows (N1MM true split
on/off) still behave on Windows/Linux

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy Fielder <kk7gwy@aethersdr.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…aethersdr#2931)

## Summary

With two or more slices, TX would not stay on the slice the user picked
— it kept jumping back to another slice every couple of seconds, and the
user couldn't keep TX where they wanted it. Root cause is
`cmdSetSplitVfo` reclaiming TX on every `set_split_vfo 0` **poll**
rather than on a real split-state change.

## Root Cause

Each rigctld / PTY CAT channel is bound to a fixed slice
(`m_sliceIndex`). `cmdSetSplitVfo` moved TX onto that slice whenever a
client sent `set_split_vfo 0` (split disabled). aethersdr#2568 added an
`!isTxSlice()` guard to stop the redundant **command spam**, but that
only suppressed the already-TX case — it didn't stop TX from being
**seized**.

Hamlib loggers (VARA, VARAC, N1MM, fldigi) poll `set_split_vfo 0 VFOA`
every few seconds. With a CAT channel bound to a non-TX slice the
sequence was:

```
user clicks slice A TX badge    → slice set 0 tx=1   (A is TX)
~1.7 s later logger polls split → channel bound to slice B
cmdSetSplitVfo sees B != TX      → slice set 1 tx=1
TX jumps back to B
```

The `!isTxSlice()` guard actually *guaranteed* the re-seize: the user
moving TX away is exactly the moment the bound slice becomes non-TX,
which re-satisfies the guard on the very next poll.

Captured in the connection log (slice 0 = A, slice 1 = B, single client,
no split):

```
08:11:59.663  TX: C162|slice set 0 tx=1     ← user clicks A's TX badge
08:11:59.785  RX: slice 0 ... tx=1          ← A is TX (user succeeded)
08:12:01.379  TX: C167|slice set 1 tx=1     ← logger split poll, 1.7 s later
08:12:01.379  TX: C168|slice set 1 tx=1
08:12:01.379  TX: C169|slice set 1 tx=1
08:12:01.379  TX: C170|slice set 1 tx=1
08:12:01.498  RX: slice 1 ... tx=1          ← TX yanked back to B
```

## Fix

Track the last split state per `RigctlProtocol` instance (tri-state:
unknown / disabled / enabled) and reclaim TX only on a genuine
split→non-split **transition** (1→0 edge) — never on a steady-state poll
or the first report after connect.

```cpp
const bool wasEnabled  = (m_lastSplitEnable == 1);
const bool firstReport = (m_lastSplitEnable < 0);
m_lastSplitEnable = enable ? 1 : 0;

if (!enable && wasEnabled && !firstReport) {
    if (auto* s = currentSlice(); s && !s->isTxSlice())
        s->setTxSlice(true);
}
```

A logger that sits in non-split and polls repeatedly no longer fights
the user's TX choice, while a real "turn split off" still moves TX back
to the client's slice.

## Test plan

- [x] macOS, FLEX-6600 fw 4.2.18, VARAC on rigctld
- [x] Slice A = TX, add slice B → select TX back to A → TX **stays** on
A (previously yanked to B within ~2 s)
- [x] Genuine split toggle (split on → split off) still moves TX to the
channel's slice
- [ ] Reviewer: confirm contest-logger split workflows (N1MM true split
on/off) still behave on Windows/Linux

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy Fielder <kk7gwy@aethersdr.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