Skip to content

fix(tci): send ready; after the full settings dump, not mid-burst#3498

Merged
NF0T merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/tci-ready-after-full-init-dump
Jun 13, 2026
Merged

fix(tci): send ready; after the full settings dump, not mid-burst#3498
NF0T merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/tci-ready-after-full-init-dump

Conversation

@jensenpat

@jensenpat jensenpat commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

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 #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.
  • fix(tci): spec/impl compliance + RF2K-S amplifier interop #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_Readytci_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

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>

@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 @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 after protocol: to after iq_samplerate:, with audio_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 the device:/protocol: block explicitly concedes the RF2K-S whitelist is regressed and that split_enable is "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 was split_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 other ready reference (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 NF0T self-assigned this Jun 13, 2026
@NF0T NF0T enabled auto-merge (squash) June 13, 2026 13:39

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

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_enable is 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-X TCITransceiver.cpp). Moving ready; 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.

@NF0T NF0T merged commit a96b0bc into aethersdr:main Jun 13, 2026
6 checks passed
NF0T pushed a commit that referenced this pull request Jun 13, 2026
…#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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
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.

TCI: ready; sent before the init state dump completes — spec-faithful clients initialize from defaults

2 participants