fix(tci): send ready; after the full settings dump, not mid-burst#3498
Conversation
The TCI init burst sent ready; immediately after the phase-1 identity commands (protocol:...), before any state or audio/IQ stream parameters. Clients that latch their cached settings when ready; arrives — notably SDC / CW Skimmer (reported by Yuri UT4LW) — initialized from defaults instead of our actual parameters, in particular iq_samplerate, causing incorrect client setup. Real ExpertSDR3 transmits its complete initial state (including audio_samplerate / iq_samplerate) and only then sends ready;, and the spec defines READY as "sent after the initialization commands". Move ready; to after iq_samplerate;, keeping audio_start; / start; as the trailing stream-control commands. The early-READY placement came from aethersdr#2597, which claimed strict clients reject non-init commands before READY. The reference parser it cited (ars-ka0s/eesdr-tci, basis of the RF2K-S amp client) does not enforce any pre-READY grammar — it aborts only on unrecognized command names, anywhere in the stream, and every command now sent pre-READY was already present in the burst. The actual RF2K-S engagement fix was split_enable (still emitted), not READY placement. WSJT-X / JTDX parse every command on arrival and use ready; only to advance their init state machine (verified in TCITransceiver.cpp), matching how they already interoperate with real ExpertSDR3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
There was a problem hiding this comment.
Thanks @jensenpat — this looks correct, and the homework on the #2597 history is appreciated.
Verified:
- The diff is exactly what the title says:
ready;moves from afterprotocol:to afteriq_samplerate:, withaudio_start;/start;still trailing. Single file, in scope, no behavior change beyond ordering. - The regression concern (#2597's early-READY rationale) checks out as moot. The strict-client identity whitelist that #2597 set up (
device:SunSDR2DX+protocol:ExpertSDR2) was already abandoned by #2806 — the in-code comment at thedevice:/protocol:block explicitly concedes the RF2K-S whitelist is regressed and thatsplit_enableis "what the RF2K-S TCI parser actually needs to engage." That fix is preserved here (still emitted per-slice, now pre-READY), and #2597's own discovery notes agree the engagement blocker wassplit_enable, not READY placement. TciServer::sendInitBurst()splits the burst on;and sends one command per WebSocket message, so the new ordering is delivered as authored. No other call sites, no tests pinning the old order, and the only otherreadyreference (NetworkDiagnosticsDialog line coloring) is order-independent.- Matching real ExpertSDR3 (full state dump →
ready;) is the strongest compatibility argument — any client in the wild already tolerates this ordering.
One suggestion (non-blocking): READY placement has now moved twice (#2597 early, this PR late), each time to fix a different client. A small unit test asserting the invariant — ready; appears after iq_samplerate: and before audio_start; in generateInitBurst() (works with a null model) — would keep the next interop fix from silently re-breaking SDC/CW Skimmer. Fine as a follow-up.
The one claim I couldn't independently re-verify is the eesdr-tci parser behavior (no pre-READY grammar check); but since every command now sent pre-READY was already in the burst, even a strict-name-whitelist client sees no new command names, so the exposure argument holds regardless.
LGTM as a COMMENT review — maintainers may want a quick RF2K-S smoke test before merge given the hardware history, but the code-side analysis supports the change.
🤖 aethersdr-agent · cost: $5.1255 · model: claude-fable-5
NF0T
left a comment
There was a problem hiding this comment.
Reviewed the diff, the full #2597 → #2806 → #3497 history, and the client interop analysis.
Historical sign-off:
The early ready; placement came from #2597, which claimed ars-ka0s/eesdr-tci (the RF2K-S parser basis) would reject non-init commands before READY. That rationale was orphaned by #2806's identity-string revert — the code comment from that work already concedes the RF2K-S whitelist is regressed and that split_enable (still emitted, unchanged here) is what the amp actually needs to engage. The early ready; was a zombie: its justification gone, its cost now materializing in SDC / CW Skimmer (#3497, wire-captured by UT4LW).
Technical sign-off:
- The safety argument is bulletproof independent of the eesdr-tci internals debate: every command now appearing before
ready;was already present in the burst before this change. Zero new command names cross the pre-READY boundary regardless of what strict clients whitelist. split_enableis preserved per-slice in Phase 2 — the actual RF2K-S engagement fix is untouched.- WSJT-X / JTDX parse every TCI command on arrival and use
ready;only to advance their own init state machine (verified in WSJT-XTCITransceiver.cpp). Movingready;later means they advance with complete state — strictly better. - RF2K-S, TCI Monitor, aether-pad, Ulanzi plugin: order-agnostic by design, confirmed unaffected.
- Single file, single logical change. No other
ready;call sites, no tests pinning the old order.
Non-blocking note: READY placement has now moved twice — early in #2597, correct here. A small unit test asserting ready; appears after iq_samplerate: and before audio_start; in generateInitBurst() (works against a null model) would keep the next interop fix from silently re-breaking SDC / CW Skimmer. Fine as a follow-up; not holding this back for it.
Principle VIII — Fix is grounded in a wire capture from UT4LW and source-verified client behavior in WSJT-X TCITransceiver.cpp. Evidence over assertion throughout.
…#3502) ## Problem TCI Protocol v2.0 defines `VOLUME` as the main (master AF) volume **in dB, range −60..0, where −60 dB is silence** — real ExpertSDR3 sends e.g. `VOLUME:-12;`. AetherSDR has been sending and parsing it as our internal **0–100 percent** amplitude instead. Two concrete breakages: 1. **Outbound:** every `volume:` we emit — the connect init-burst seed (#3245), GET replies, SET echoes, and the title-bar-slider broadcast — carries a 0–100 percent value (`volume:33;`, visible in Yuri UT4LW's init-burst capture from the #3498 report). To a spec-conforming client that's an out-of-range value on a −60..0 scale. 2. **Inbound (worse):** a spec-conforming client setting `volume:-12;` (a normal listening level) got clamped to 0 percent — i.e. **we muted the operator's audio** instead of setting −12 dB. This only affects the local monitor volume path (`applyMasterVolume` → PC audio RX volume or radio lineout gain). The TCI audio streams themselves (TciRxGain/TciTxGain) and TX levels (`drive`/`mic_level`) are unrelated and untouched. ## Fix Convert at the wire; everything inboard stays 0–100 percent (the `pendingMasterVolume()` → `masterVolumeRequested` → `applyMasterVolume` contract is unchanged). **Emit in dB** — `cmdVolume` GET, SET echo, the init-burst seed, and `TciServer::broadcastMasterVolume()` all now send `volumeDbFromPercent(pct)`: | internal % | wire dB | |---|---| | 100 | 0 | | 50 | −6 | | 25 | −12 | | 10 | −20 | | 0 | −60 | **Parse SET dual-scale** — the spec range is non-positive, so positive values are unambiguous: - `value ≤ 0` → spec dB, amplitude mapping `10^(dB/20)`; −60 → mute; values between −59 and −41 floor at 1 % so only the spec's explicit silence point (−60) mutes. - `value ≥ 1` → **legacy AetherSDR percent, still accepted** — existing percent senders keep working unchanged. - `volume:0` now means 0 dB = full volume per spec (previously: percent mute). Muting is the job of the TCI `mute:` command. The SET echo is round-tripped through the percent store so it always matches what a subsequent GET returns. ## Precision note (honest limits) The internal store is integer percent, so dB→%→dB round-trip is **exact from −24 dB up to 0 dB**, within ±1 dB down to −36 dB, and floors at 1 % (≈−40 dB) below that. This affects only whisper-quiet settings; the practical knob range is unaffected. ## Client compatibility - **WSJT-X / JTDX**: recognize `volume` and explicitly ignore it (`case Cmd_Volume: break;` in `Transceiver/TCITransceiver.cpp`), and never send it. Unaffected. - **Spec-conforming clients** (ExpertSDR-native panels, SDC ecosystem): now see correct dB values and their dB SETs take effect instead of muting. This is the population the fix targets. - **Value-mirroring gain steppers** (Ulanzi D100H / Elgato / StreamController, the #3245 clients): these mirror whatever value we seed and step it (#3245 verified one happily mirroring `volume:87`). With a dB seed they now step in dB space; if a given plugin clamps its mirror to 0..100, its percent SETs still work via the legacy parse path.⚠️ **Hardware re-verification of the Ulanzi D100H first-step behavior is the one open test** — flagged below. - **eesdr-tci-based parsers** (RF2K-S): `VOLUME` is a known command name with no client-side range validation on the float; no parse risk. ## Testing - Builds clean (RelWithDebInfo, Ninja, macOS). - Conversion table + round-trip stability verified numerically (above). - [ ] Live check: spec client SET `volume:-12;` → audible level drop, GET returns `-12` - [ ] Live check: Ulanzi D100H AF-gain step still tracks from the seeded value (#3245 regression check) ## Related / follow-ups - `rx_volume` and `mon_volume` have the same percent-vs-dB mismatch per spec (`RX_VOLUME`/`MON_VOLUME`: dB −60..0). They're **not** bundled here because `rx_volume` also has an argument-shape divergence (spec is 3-arg `trx,channel,dB`, and the spec's 2-arg GET `trx,channel` collides with our documented legacy 2-arg SET `trx,value` from #1764) that needs its own design decision. - Complements #3498 (init-burst `ready;` ordering) — together they make the connect handshake read correctly to spec-conforming clients. 💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Codex <noreply@openai.com>
…thersdr#3498) ## Problem Reported by Yuri UT4LW (SDC / CW Skimmer ecosystem): AetherSDR's TCI server sends `ready;` immediately after the `protocol:...` identity command, before the state notifications and audio/IQ stream parameters: ``` ... protocol:ExpertSDR3,1.5; ready; ← too early drive:0,100; ... iq_samplerate:48000; audio_start; start; ``` `ready;` marks the end of the server's initial settings dump. Clients that latch their cached settings the moment `ready;` arrives — notably SDC / CW Skimmer — initialize from defaults instead of our actual parameters (in particular `iq_samplerate`), causing incorrect client initialization. ## Fix Move `ready;` to after `iq_samplerate:...;` so it terminates the complete settings dump, keeping `audio_start;` / `start;` as the trailing stream-control commands. This matches real ExpertSDR3 behavior (full state dump, then `ready;`) and the spec's READY definition ("sent after the initialization commands"). ## Why the early READY was there, and why it's safe to remove The early placement came from aethersdr#2597, which claimed strict clients (RF2K-S amplifier) treat anything before READY that isn't one of the 9 spec-listed init commands as malformed. Verified against the reference parser that claim was based on (`ars-ka0s/eesdr-tci`, the basis of the RF2K-S TCI client): - It aborts only on **unrecognized command names**, anywhere in the stream — there is no pre-READY grammar check. Every command now sent pre-READY was already present in the burst, so no new exposure. - `READY` merely sets a "ready event" flag for the client app to await — i.e. it means "initial sync complete", which is exactly why it must come last. - aethersdr#2597's own bench notes concede the early READY never made the amp engage; the actual fix was `split_enable` (still emitted, unchanged). WSJT-X / JTDX parse every TCI command on arrival regardless of ready-state and use `ready;` only to advance their init state machine (verified in WSJT-X `Transceiver/TCITransceiver.cpp`: `Cmd_Ready` → `tci_done7()`; state commands arriving earlier are cached). Since real ExpertSDR3 sends the full state dump before `ready;`, every TCI client in the wild already handles this ordering — it is the maximally compatible choice. ## New burst order 1. Init/identity: `vfo_limits`, `if_limits`, `trx_count`, `channels_count`, `device`, `receive_only`, `modulations_list`, `protocol` 2. Per-slice + global state: `vfo`, `modulation`, `rx_enable`, filters, RIT/XIT, `split_enable`, lock, SQL, AGC, NB/NR/ANF/APF, `tx_enable`, `drive`, `tune_drive`, `mic_level`, `trx`, `volume` 3. Audio/IQ stream config: `audio_samplerate`, `audio_stream_sample_type`, `audio_stream_channels`, `audio_stream_samples`, `tx_stream_audio_buffering`, `iq_samplerate` 4. **`ready;`** 5. `audio_start;` `start;` ## Testing - Builds clean (RelWithDebInfo, Ninja). - Burst order verified in `generateInitBurst()`; no other call sites changed. - Interop analysis above for WSJT-X/JTDX, eesdr-tci/RF2K-S, SDC/CW Skimmer. 💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat Co-authored-by: Codex <noreply@openai.com>
…aethersdr#3502) ## Problem TCI Protocol v2.0 defines `VOLUME` as the main (master AF) volume **in dB, range −60..0, where −60 dB is silence** — real ExpertSDR3 sends e.g. `VOLUME:-12;`. AetherSDR has been sending and parsing it as our internal **0–100 percent** amplitude instead. Two concrete breakages: 1. **Outbound:** every `volume:` we emit — the connect init-burst seed (aethersdr#3245), GET replies, SET echoes, and the title-bar-slider broadcast — carries a 0–100 percent value (`volume:33;`, visible in Yuri UT4LW's init-burst capture from the aethersdr#3498 report). To a spec-conforming client that's an out-of-range value on a −60..0 scale. 2. **Inbound (worse):** a spec-conforming client setting `volume:-12;` (a normal listening level) got clamped to 0 percent — i.e. **we muted the operator's audio** instead of setting −12 dB. This only affects the local monitor volume path (`applyMasterVolume` → PC audio RX volume or radio lineout gain). The TCI audio streams themselves (TciRxGain/TciTxGain) and TX levels (`drive`/`mic_level`) are unrelated and untouched. ## Fix Convert at the wire; everything inboard stays 0–100 percent (the `pendingMasterVolume()` → `masterVolumeRequested` → `applyMasterVolume` contract is unchanged). **Emit in dB** — `cmdVolume` GET, SET echo, the init-burst seed, and `TciServer::broadcastMasterVolume()` all now send `volumeDbFromPercent(pct)`: | internal % | wire dB | |---|---| | 100 | 0 | | 50 | −6 | | 25 | −12 | | 10 | −20 | | 0 | −60 | **Parse SET dual-scale** — the spec range is non-positive, so positive values are unambiguous: - `value ≤ 0` → spec dB, amplitude mapping `10^(dB/20)`; −60 → mute; values between −59 and −41 floor at 1 % so only the spec's explicit silence point (−60) mutes. - `value ≥ 1` → **legacy AetherSDR percent, still accepted** — existing percent senders keep working unchanged. - `volume:0` now means 0 dB = full volume per spec (previously: percent mute). Muting is the job of the TCI `mute:` command. The SET echo is round-tripped through the percent store so it always matches what a subsequent GET returns. ## Precision note (honest limits) The internal store is integer percent, so dB→%→dB round-trip is **exact from −24 dB up to 0 dB**, within ±1 dB down to −36 dB, and floors at 1 % (≈−40 dB) below that. This affects only whisper-quiet settings; the practical knob range is unaffected. ## Client compatibility - **WSJT-X / JTDX**: recognize `volume` and explicitly ignore it (`case Cmd_Volume: break;` in `Transceiver/TCITransceiver.cpp`), and never send it. Unaffected. - **Spec-conforming clients** (ExpertSDR-native panels, SDC ecosystem): now see correct dB values and their dB SETs take effect instead of muting. This is the population the fix targets. - **Value-mirroring gain steppers** (Ulanzi D100H / Elgato / StreamController, the aethersdr#3245 clients): these mirror whatever value we seed and step it (aethersdr#3245 verified one happily mirroring `volume:87`). With a dB seed they now step in dB space; if a given plugin clamps its mirror to 0..100, its percent SETs still work via the legacy parse path.⚠️ **Hardware re-verification of the Ulanzi D100H first-step behavior is the one open test** — flagged below. - **eesdr-tci-based parsers** (RF2K-S): `VOLUME` is a known command name with no client-side range validation on the float; no parse risk. ## Testing - Builds clean (RelWithDebInfo, Ninja, macOS). - Conversion table + round-trip stability verified numerically (above). - [ ] Live check: spec client SET `volume:-12;` → audible level drop, GET returns `-12` - [ ] Live check: Ulanzi D100H AF-gain step still tracks from the seeded value (aethersdr#3245 regression check) ## Related / follow-ups - `rx_volume` and `mon_volume` have the same percent-vs-dB mismatch per spec (`RX_VOLUME`/`MON_VOLUME`: dB −60..0). They're **not** bundled here because `rx_volume` also has an argument-shape divergence (spec is 3-arg `trx,channel,dB`, and the spec's 2-arg GET `trx,channel` collides with our documented legacy 2-arg SET `trx,value` from aethersdr#1764) that needs its own design decision. - Complements aethersdr#3498 (init-burst `ready;` ordering) — together they make the connect handshake read correctly to spec-conforming clients. 💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Codex <noreply@openai.com>
Problem
Reported by Yuri UT4LW (SDC / CW Skimmer ecosystem): AetherSDR's TCI server sends
ready;immediately after theprotocol:...identity command, before the state notifications and audio/IQ stream parameters:ready;marks the end of the server's initial settings dump. Clients that latch their cached settings the momentready;arrives — notably SDC / CW Skimmer — initialize from defaults instead of our actual parameters (in particulariq_samplerate), causing incorrect client initialization.Fix
Move
ready;to afteriq_samplerate:...;so it terminates the complete settings dump, keepingaudio_start;/start;as the trailing stream-control commands. This matches real ExpertSDR3 behavior (full state dump, thenready;) and the spec's READY definition ("sent after the initialization commands").Why the early READY was there, and why it's safe to remove
The early placement came from #2597, which claimed strict clients (RF2K-S amplifier) treat anything before READY that isn't one of the 9 spec-listed init commands as malformed. Verified against the reference parser that claim was based on (
ars-ka0s/eesdr-tci, the basis of the RF2K-S TCI client):READYmerely sets a "ready event" flag for the client app to await — i.e. it means "initial sync complete", which is exactly why it must come last.split_enable(still emitted, unchanged).WSJT-X / JTDX parse every TCI command on arrival regardless of ready-state and use
ready;only to advance their init state machine (verified in WSJT-XTransceiver/TCITransceiver.cpp:Cmd_Ready→tci_done7(); state commands arriving earlier are cached). Since real ExpertSDR3 sends the full state dump beforeready;, every TCI client in the wild already handles this ordering — it is the maximally compatible choice.New burst order
vfo_limits,if_limits,trx_count,channels_count,device,receive_only,modulations_list,protocolvfo,modulation,rx_enable, filters, RIT/XIT,split_enable, lock, SQL, AGC, NB/NR/ANF/APF,tx_enable,drive,tune_drive,mic_level,trx,volumeaudio_samplerate,audio_stream_sample_type,audio_stream_channels,audio_stream_samples,tx_stream_audio_buffering,iq_samplerateready;audio_start;start;Testing
generateInitBurst(); no other call sites changed.💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat