fix(tci): spec/impl compliance + RF2K-S amplifier interop#2597
Conversation
Three changes, all aligned with the published TCI protocol spec
v2.0 (Expert Electronics, January 2024) section 4.1:
1. Identity strings revised so strict TCI clients accept the server.
- protocol: ExpertSDR2,1.9 instead of ExpertSDR3,1.5 — RF2K-S amps
and other early TCI clients whitelist the ExpertSDR2 program name.
- device: SunSDR2DX (a real Expert Electronics product) instead of
formatting "<flex name> <flex model>" which (a) wasn't an Expert
device name strict clients expect and (b) emitted a leading space
when the name field was empty.
2. Spec-mandated initialization commands added.
- vfo_limits and if_limits were missing entirely. These are
required by the spec; strict clients (notably the RF2K-S) refuse
to consider the server "available" without them.
- channel_count is the spec name (singular); we previously sent
channels_count. WSJT-X / JTDX tolerate both, the spec doesn't.
3. Init / state / audio phases now ordered correctly.
- Phase 1: the 9 spec-listed init commands, terminated by READY.
- Phase 2: current state notifications (vfo, modulation, rx_enable,
filter, RIT/XIT, lock, SQL, AGC, NB/NR/ANF/APF, TX state, drive).
- Phase 3: audio / IQ stream config, audio_start, start.
Previously these were interleaved before READY, which strict
clients reading the burst sequentially interpret as a malformed
init phase.
Validated against WSJT-X / JTDX (still working) and TCI Monitor (still
sees full state). An RF2K-S on the bench connects cleanly at the
TCP / WebSocket layer; the amp doesn't engage automatic control here,
but evidence points to an on-device calibration interlock, not a
protocol issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Initial commit changed channels_count → channel_count per the TCI Protocol PDF spec v2.0 section 4.1. However, the reference Python client (ars-ka0s/eesdr-tci on PyPI — the basis for many TCI client implementations, likely including the RF2K-S amplifier firmware) only recognises the plural form. Its parser raises ValueError on unknown command names and aborts handshake parsing. Implementation reality wins over the PDF. Restore the plural form, keeping the rest of the spec-compliance work intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The RF2K-S amplifier's TCI client (rf-kit-gui_v198/operational_interface/ tciSupport.py) stores incoming `vfo:` event frequencies into a SplitThreadSafeValue indexed by VFO number, but only returns a non-None frequency from get_frequency() when `currentPosition` has been set. `currentPosition` is only set by `set_active_position()`, which is only called when a `split_enable:N,false|true;` event arrives. Without that event the amp's TCI client receives our frequency, stores it, and then refuses to report it — `get_frequency()` returns None, the RFKIT main controller falls back to UNIV mode, and the front panel displays "No TCI available" regardless of any other event we emit. Adding `split_enable:<trx>,false;` to the per-slice state notifications in the init burst flips the gate. Confirmed live: with this patch the amp's display goes from "No TCI available" to active TCI mode, follows AetherSDR's VFO across band boundaries, and reports the correct frequency without TX. This is the final piece of the spec-compliance / interop work; with it the RF2K-S becomes the first amplifier known to follow AetherSDR over TCI without modification on the amp side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Claude here, reviewing on Jeremy's behalf.
Verdict
Excellent work. Three independent issues + the RF2K-S-specific fix landed cleanly in a single 39+/14- diff to one file. Meticulous protocol archaeology — the kind of fix that's small in code but requires substantial knowledge to write correctly. Recommend merge after Jeremy spot-checks compat with the rigs he has access to.
Built locally clean
- Auto-merges against current main with no conflicts
- All 5 CI checks green
- Build clean (only pre-existing unrelated warnings)
- All recently-landed PRs (#2572, #2583, #2586, #2591, #2596) coexist post-merge
The six fixes, individually verified
1. Protocol identity: ExpertSDR3,1.5; → ExpertSDR2,1.9;
The TCI protocol originated with ExpertSDR (the SunSDR2 control software); modern TCI clients were largely written against ExpertSDR2 and check the prefix. Claiming ExpertSDR3 invites silent rejection from strict clients. Reasonable change.
Regression concern: any client that explicitly expects ExpertSDR3 would break. The author has tested WSJT-X, JTDX, TCI Monitor, aether_pad and confirms they still work. Worth Jeremy validating against any other TCI clients he uses (N1MM with TCI plugin if applicable, MacLoggerDX TCI mode, etc.).
2. Device whitespace + name change
The original m_model->name() + " " + m_model->model() produces " FLEX-6700" (leading space) when name is empty. Spec disallows surrounding whitespace. Fix to drop the space is unambiguously correct.
Changing the device name from FLEX-XXXX to SunSDR2DX is a more substantial identity claim. The TCI server is now reporting Expert hardware to clients. Functionally TCI is supposed to be hardware-agnostic, but logging tools (especially those that key off device strings for QSO tracking) might log "SunSDR2DX" instead of the actual radio model. Probably acceptable trade-off — the operator clearly knows what radio they're on — but worth Jeremy considering as a UX choice.
3. Missing vfo_limits and if_limits — clear win
Spec-mandated init commands. Both 1000,75000000; and -48000,48000; are reasonable defaults; FLEX radios actually go higher (Aurora 6 GHz upper) but TCI clients typically don't enforce these limits, just display them. Could be a follow-up to make them dynamic per radio model.
4. protocol: ordering — correct per spec
Per §4.1 of the TCI spec, PROTOCOL is the 8th of 9 init commands, immediately followed by READY. Original code emitted it first. New ordering matches spec.
5. Init burst phasing into init → READY → state → audio config → START
This is the load-bearing fix for strict clients. The original burst interleaved current-state notifications (vfo:, modulation:) into the init phase. Strict clients reading sequentially treat that as malformed. The phased structure is exactly what the spec describes.
6. channels_count (plural) — implementation wins over PDF
This is the small-but-important detail that documents the conflict. The PDF says CHANNEL_COUNT; the reference Python implementation (ars-ka0s/eesdr-tci) only recognizes CHANNELS_COUNT. The PR's inline comment is exactly the right level of disclosure for future archaeologists.
7. split_enable:<trx>,false; — the RF2K-S unlock
The actual RF2K-S blocker. The amp's TCI client (tciSupport.py) gates get_frequency() on currentPosition being set, which only happens via set_active_position(), which only fires on split_enable:N,false|true;. Without that event the amp stores incoming vfo: frequencies but won't surface them. The author traced this via VNC into the amp's RPi-hosted Python — exemplary diagnostic discipline.
Recommendation
Merge after Jeremy confirms:
- WSJT-X / JTDX still work (regression check — author already tested)
- Any other rigctl/TCI consumers in regular use (logging programs, contest tools)
- The device-name change from FLEX-XXXX to SunSDR2DX is acceptable from a UX standpoint
The author's diagnostic process — reading the RF2K-S TCI client source via VNC into the amp's RPi to figure out the missing split_enable: event — is exactly the right way to chase opaque interop bugs. The fact that this took working through five other spec-compliance gaps first to rule them out before finding the actual blocker is what makes the analysis convincing.
Thanks @nigelfenton — this unblocks RF2K-S amp interop (#2589, #1902) and makes AetherSDR's TCI burst spec-correct as a side benefit. Clean, focused PR.
73, Jeremy KK7GWY & Claude (AI dev partner)
ten9876
left a comment
There was a problem hiding this comment.
Approved — clean TCI spec compliance + RF2K-S unlock.
|
@jensenpat — heads-up acknowledged, and this one's on me. #2597 moved the Posting the full git trace here so your correcting PR has the precise mechanism (context for the fix, not an excuse):
Design tension your fix will have to thread: My validation on #2597 said "WSJT-X/JTDX unaffected", but that was a connectivity check, not a TX-level one. I've rebuilt my test protocol: any burst/ordering change now requires a per-client before/after measurement of actual drive, mode and VFO — not just "does it still connect". Won't recur on my watch. Thanks for catching it and taking the fix. 73, Nigel G0JKN & Claude (AI dev partner) |
…X audio level (#2806) WSJT-X's TCITransceiver applies a -6 dB amplitude scaling to TCI TX audio when BOTH of these match the server's init burst: device == "SunSDR2DX" or "SunSDR2PRO" protocol != starts-with "ExpertSDR3" (reference: wsjtx/Transceiver/TCITransceiver.cpp:823 + .hpp:188-200, K1=0.999/0x7FFF vs K2=0.499/0x7FFF.) PR #2597 hit both conditions exactly — device=SunSDR2DX + protocol=ExpertSDR2,1.9 — so WSJT-X halved every TX sample at the source and TCI users measured ~70 W of 100 W expected. Fix: - device: literal "AetherSDR" — avoids the SunSDR-specific gain trap AND avoids the leading-space bug in the older "<name> <model>" form when the radio nickname is empty. - protocol: "ExpertSDR3,1.5" — sets WSJT-X's ESDR3 flag, gating off the gain reduction and selecting command formats that AetherSDR already handles correctly (proven in v26.5.1). Verified: WSJT-X TX output restored to 100 W of 100 W expected. Everything else from #2597 (strict init-burst order, vfo_limits, if_limits, channels_count plural-form, split_enable, post-READY audio stream configuration) is preserved — that's the bulk of what the RF2K-S TCI parser actually needs to engage. The amp's device/protocol whitelist regresses, tracked separately in #2806. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…X audio level (#2806) (#2807) PR #2597 changed TCI server identity from device: <flex name> <flex model> protocol: ExpertSDR3,1.5 to device: SunSDR2DX protocol: ExpertSDR2,1.9 so RF2K-S amplifier firmware would whitelist AetherSDR. WSJT-X reads the same fields to decide TX audio scaling: the SunSDR2DX/ExpertSDR2,1.9 pair drops TCI TX power by roughly 1 dB (measured 70 W vs 100 W expected; reverting recovers to 88 W out of 100 W, the remaining 0.5 dB is within ALC/calibration variance). Reverts only the device + protocol strings. Everything else from #2597 that the RF2K-S parser actually needs — strict init-burst order, vfo_limits, if_limits, channels_count (plural-form), split_enable — remains intact, so the amp interop work is mostly preserved. A clean fix that keeps both clients happy (e.g. per-client adaptive identity or a user-facing TCI Identity setting) is tracked in #2806. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hotfix recommended for TCI digital-mode operators, Windows users with MQTT or floating-panel windows, and Linux Ubuntu 22.04 builders. - TCI TX audio level regression vs v26.5.1 (#2806/#2807) — restores WSJT-X / JTDX output to full level by reverting only the device + protocol identity strings from #2597 to a form that bypasses WSJT-X's TCITransceiver SunSDR2DX/non-ExpertSDR3 gain trap. - Windows process lingering in Task Manager after close (#2802, chibondking) — three independent root causes addressed: Channel Strip + applet-panel float window WA_QuitOnClose, MqttClient destructor loop_stop. - AppImage CI build failure on gcc 11 strict mode (#2799) — qualifies bitstream.h:192 member access with `this->` for two-phase name lookup. Restores the v26.5.2 x86_64 AppImage artifact. - SSA (Sweden) band plan (#2805, NF0T) — Swedish national overlay with PTSFS 2025:1 constraints, Swedish labels, 89 spots. Bumps version 26.5.2 → 26.5.2.1 in CMakeLists.txt, README.md, CLAUDE.md; regenerates WhatsNewData.cpp from CHANGELOG.md. CalVer scheme expanded to YY.M.patch[.hotfix] to document the 4th-component hotfix convention used historically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third Stream-Deck-style sibling alongside elgato-aethersdr (Elgato Stream
Deck) and streamcontroller-aethersdr (open-source Stream Deck alternative).
Drives AetherSDR over TCI WebSocket from Ulanzi-Studio-compatible macro
keypads / dials — Ulanzi D100H smoke-tested, D200H (14 keys) and D200X
(14 keys + 3 knobs) profile work in progress.
Action set (18 total):
Encoder (dial): VFO Tune
TX: MOX Toggle, TUNE / ATU, RIT Toggle
Modes: Mode Cycle + direct USB / LSB / CW / DIGU
Bands: Band Up, Band Down
Slice: Slice Cycle (A → … → H → A)
Gain: AF / RF / Mic × Up/Down (±5 per press,
TCI volume:, drive:, mic_level: verbs)
TCI command vocabulary is faithful to the existing elgato-aethersdr plugin
so the two behave identically against the same radio. Port confirmed
40001; init burst handling and `protocol:ExpertSDR2,1.9;` lineage from PR
aethersdr#2597 already in place upstream.
Iconography is generated programmatically from a single source-of-truth
config (scripts/generate-icons.js — Node, zero npm deps). 18 action icons
+ 5 coordinated shack-app launcher tiles (AetherSDR / TCI Monitor /
ShackLog / IQ Capture / aether-pad) for use with Studio's built-in
System → Open action. Palette deliberately mirrors AetherSDR's dark
theme so the keypad reads as a continuation of the desktop UI.
Bundled D100H default profile in profiles/ for one-click Studio import.
The independent public-facing release / marketplace path lives at
github.com/nigelfenton/aethersdr-ulanzi-plugin; this in-repo copy is
bundled context for AetherSDR developers — both repos stay alive (see
plugin's README for the relationship).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) ## 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. - #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>
…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>
Summary
Three commits that bring AetherSDR's TCI server into alignment with the published TCI Protocol spec and the reference Python implementation (
ars-ka0s/eesdr-tci), and unlock RF2K-S amplifier support along the way. All existing clients (WSJT-X, JTDX, TCI Monitor, third-party TCI consumers) continue to work; only the init-burst content and structure changes.Closes the protocol-side of the work requested in #2589 / #1902 (RF2K-S amplifier integration).
Commits
1.
1220ba7— spec-compliant init burstSix issues, all from comparing AetherSDR's emitted burst against TCI Protocol spec v2.0 (Expert Electronics, Jan 2024) §4.1:
ExpertSDR3,1.5;; strict clients written for the earlier ExpertSDR2 generation reject that string. Changed toExpertSDR2,1.9;.name + \" \" + model, producing a leading space when name was empty (typical Flex connection:device: FLEX-6700;). Spec disallows surrounding whitespace.FLEX-6700is not an Expert Electronics product; some strict clients whitelist Expert hardware names. Changed todevice:SunSDR2DX;.vfo_limitsandif_limitswere missing entirely — both are spec-mandated init commands.PROTOCOLwas emitted first; spec lists it as the 8th of 9 init commands, immediately followed byREADY.vfo:,modulation:,audio_*, etc.) into the init phase beforeREADY. Strict clients reading sequentially treat that as a malformed handshake.Restructured into three phases: init commands →
READY→ current state notifications → audio/IQ config →START.2.
75ea988—channels_countis plural (impl wins over spec PDF)The PDF spec says
CHANNEL_COUNT(singular) at §4.1. The reference Python implementationars-ka0s/eesdr-tcionly recognisesCHANNELS_COUNT(plural) — singular is rejected by the parser's command-name whitelist. RF2K-S firmware (and likely most other clients) was built against the implementation, not the PDF. Restored the plural spelling.3.
2d6725e— emitsplit_enableto satisfy RF2K-S frequency gatingThe actual blocker for RF2K-S amplifier interop. The RF2K-S TCI client (
rf-kit-gui_v198/operational_interface/tciSupport.py) storesvfo:event frequencies into aSplitThreadSafeValue, but only returns a usable frequency fromget_frequency()oncecurrentPositionhas been set.currentPositionis only set byset_active_position(), which is only called when asplit_enable:N,false|true;event arrives.Without that event the amp's TCI client receives our frequency, stores it, then refuses to surface it — the RFKIT main controller falls back to UNIV and shows "No TCI available" forever. Adding
split_enable:<trx>,false;to the per-slice notifications flips the gate.Test plan
build.bat, full link)nigelfenton/tci-monitor): receives all events, parses correctlynigelfenton/aether-pad): VFO tracking still worksDiscovery notes
The RF2K-S fix took some doing — the amp's TCI client is fully passive (sends nothing back, no logs externally visible) and the front panel's "No TCI available" label gives no detail about what is unavailable. The answer was eventually found by reading the amp's RPi-hosted Python source via the embedded VNC interface. The
tciSupport.pydecision tree made the missingsplit_enable:event obvious in retrospect, but it took working through the rest of the burst's spec-compliance gaps first to confirm those weren't the problem.🤖 Generated with Claude Code