feat(hl2): basic TCI signaling — WSJT-X over a Hermes-Lite 2 - #4471
#4471feat(hl2): basic TCI signaling — WSJT-X over a Hermes-Lite 2#4471ten9876 merged 8 commits intoaethersdr:mainaethersdr/AetherSDR:mainfrom jensenpat:feat/hl2-tci-signalingjensenpat/AetherSDR:feat/hl2-tci-signalingCopy head branch name to clipboard
Conversation
RX handedness fix — the demodulator and the spectrum were wired to each other's conventionOperator reported: DIGU at 7.074 put FT8 at 7.071 on the panadapter and decoded nothing, while LSB/DIGL at the same dial lined up and decoded perfectly — and LSB transmit produced real spotted QSOs. A radio that is completely correct in the wrong mode isn't a mode bug; the whole chain was coherently inverted.
So the demodulator wants the raw wire (1 and 2 cancel) and the spectrum wants the conjugate. The measurement that settled itForcing the WDSP slice shift to zero — the one geometry where no second error can compensate. Old chain, all four modes inverted by 100–300× in magnitude:
The shift sign is unchangedIt looked like a co-conspirator and isn't. Once handedness is settled it's derivable: the wire puts Both DSP tests were feeding IQ no HL2 ever sendsThat's why a mirrored panadapter and an inverted demodulator both passed:
Both now generate wire order. Assertions unchanged, which is the point: the expectations were always right, the stimulus wasn't. Verification — live HL2, receive only
Full suite 178/178. No transmissions during this work — the radio was on a live antenna throughout. One thing noticed, not fixedAt zero shift the recovered tone read 1300/1700 Hz against a 1500 Hz offset — a consistent ~200 Hz high receive offset (≈20 ppm at 10 MHz, in line with an uncalibrated HL2 reference). Harmless for FT8 and unrelated to handedness, but worth a calibration knob later. |
On-air confirmation: DIGU transmit — 63 PSK Reporter spots on 14.074The last item I had flagged as unproven. The TX chain was unit-proven at 77–85 dB opposite-sideband suppression off the modulator's own IQ, but it had never been on the air in DIGU — every real QSO before this was LSB through the inverted chain. 63 spots on PSK Reporter, 20 m FT8, DIGU. This is third-party evidence from independent receivers worldwide, so it validates the whole transmit path end to end in a way nothing in this repo can:
Combined with the receive-side proof above, both directions are now confirmed correct in the modes WSJT-X actually uses, on real hardware and on the air. |
TCI on an HL2 completed its handshake and then did nothing useful: WSJT-X
showed a connected rig, tracked frequency and mode, and never decoded or
transmitted anything. Four separate failures, each silent in its own way,
because the TCI server was written against a Flex and an HL2 has neither of
the two planes it assumes — no DAX/VITA-49 data plane, and no SmartSDR text
command plane.
RX audio wirePanStreamTciSinks() binds TCI's audio feed to PanadapterStream,
which an in-process backend does not have, so nothing was bound and
TCI clients heard silence. Route RadioModel::backendAudioFrameReady
to onDaxAudioReady() on channel 1 — the payload is already float32
stereo at 24 kHz, and the channel→TRX fallback maps 1 → trx 0, which
is the whole mapping on a single-slice radio.
TX confirm TciServer waits on RadioModel::radioTransmittingChanged to confirm
a TCI key and gives up 1250 ms later. That edge is decoded from Flex
`interlock` status and never arrived, so every WSJT-X transmission
was unkeyed a second and a quarter in, mid-tone. Publish the edge
from our own keying command on a backend that has no status plane to
be authoritative instead — via both setTransmit() and the
moxCommandIssued path, since the MOX button uses only the latter.
TX audio AudioEngine::feedDaxTxAudio returned immediately unless a Flex TX
stream id was set. A host-modulating backend has none and never will:
its modulator is local. Hand the audio to the same final-monitor tap
the microphone uses, which already routes to submitTxAudio(). Still a
DSP bypass — these are pre-shaped digital tones.
Tuning The VFO set went through sendCmdPublic(), which is swallowed on a
seam backend AND never runs its completion callback, so the band hop
was dropped and WSJT-X's 2 s vfo-echo timeout expired on top of it.
Drive the model instead; it routes through IRadioBackend and is
authoritative on return.
Also skips the Flex DAX arrangements (ensureDaxForTci, the dax=1 TX route) on a
backend with no DAX plane, rather than emitting Flex text at a radio speaking
HPSDR.
Two mode-table bugs surfaced while verifying the above, both of which broke
WSJT-X directly:
- The HL2 passband was sticky across mode changes. We own the DSP, so
nothing echoes a mode-appropriate filter back the way a Flex does, and
arriving at DIGU from CW handed the decoder a ~500 Hz window. Add a
per-mode default passband, applied on change only so an operator's own
filter edit survives (oracle addendum 2 §B3).
- modeFromString knew "CWU" but not "CW" — the spelling TciProtocol produces
for TCI's `cw` and the one a Flex reports — so CW fell through to the USB
fallback and was demodulated as SSB with the mode indicator reading CW.
NFM was missing for the same reason.
Verified on a live Hermes-Lite 2 (00:1C:C0:A2:13:DD) with a stdlib TCI client
that shares no code with AetherSDR:
- handshake: trx_count:1, one slice, one pan, WSJT-X's fields all present
- RX audio: 187 frames / 382,976 floats in 4 s (previously zero)
- VFO set echoed in 15–39 ms, against a 2 s client timeout
- keyed via TCI and held the full 4 s with no spurious unkey
- TX audio reaches the modulator: forward-power counts peak at 1533 with
audio vs 0–1 keyed silent, TX FIFO steady at 26–30 with no under/overflow
- per-mode passbands correct for cw/digu/digl/usb/lsb/am/cwr
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the operator's seat: DIGU on 7.074 put the FT8 signals on the
wrong side of the passband and decoded nothing, while DIGL on the same dial
decoded perfectly. USB and LSB were both fine. A sideband bug that reverses
itself depending on which mode you arrived from is an ordering bug, and it was.
In WDSP the mode does not select the RX sideband — the NBP filter edges do (see
WdspChannel::setFilter). SetRXAMode rebuilds that stage from its own per-mode
notion of the passband, so any filter pushed BEFORE the mode call is discarded
by it. Hl2Backend::setSliceMode pushed the filter first and never re-pushed.
Why it looked mode-dependent: USB<->LSB flips the filter's SIGN, so
SliceModel::normalizeFilterPolarity re-pushed the passband after the mode and
those two were always correct. USB->DIGU does not flip the sign, nothing
re-pushed, and DIGU ran on whatever SetRXAMode had left behind. DIGL is
reachable only via a sign flip, which is why it worked.
Fix: mode FIRST, then passband, re-pushed on EVERY mode set rather than only
when its value changed.
The transmit chain had the matching hole: Hl2TxDsp::setFilter existed with no
caller at all, so TX ran on its construction-time 300..2700 for every mode of
the session. Now pushed on mode change and seeded correctly at connect.
TX and RX turn out to use OPPOSITE passband conventions, which is why they get
separate tables:
RX (RXANBPSetFreqs) the SIGN selects the sideband; the mode does not.
TX (SetTXABandpassFreqs) the MODE selects the sideband; the bandpass is an
audio-domain magnitude.
Feeding TX the RX table's signed pairs put LSB and DIGL on the UPPER sideband.
That was caught by extending hl2_txdsp_test to DIGU/DIGL before it shipped —
the pre-existing assertions only ever ran against the struct's default filter,
so the passband's effect on the sideband had never been exercised. The test now
drives a 1 kHz tone through the real modulator with the exact passband the
backend pushes and reads the sideband off the emitted IQ: USB/DIGU and LSB/DIGL
land on opposite wire bins with 77-85 dB suppression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the operator's seat: on 40 m, DIGU at 7.074 put the FT8 traffic
at 7.071 on the panadapter — left of a correctly-drawn passband — and decoded
nothing, while LSB/DIGL at the same dial lined up and decoded perfectly. LSB
transmit even produced real spotted QSOs. A radio that is entirely correct in
the wrong mode is not a mode bug; the whole chain was inverted, coherently.
Hl2RxDsp handed the DEMODULATOR the conjugate of the wire IQ and the SPECTRUM
the raw wire. Both are backwards. Two facts, measured on live hardware against
WWV rather than reasoned from convention:
1. The HPSDR wire is the conjugate of the analytic convention: a signal ABOVE
the NCO arrives at a NEGATIVE frequency.
2. WDSP's RXA, as configured here, selects the OPPOSITE sign to its passband
bounds — USB with [+150,+3000] passes negative frequencies. Confirmed
independently by hl2_rxdsp_test and hl2_shift_test.
So the demodulator wants the RAW wire (1 and 2 cancel) and the spectrum, which
has no such quirk, wants the CONJUGATE. They were wired to each other's.
The decisive measurement was forcing the WDSP slice shift to ZERO — the one
geometry where no second error can compensate. With the shift at zero the old
chain had USB hearing signals BELOW the dial and LSB hearing them above, by
100-300x in magnitude, in all four SSB-family modes.
The slice-shift sign is UNCHANGED. It looked like a co-conspirator and is not:
it is derivable once the handedness is settled (the wire puts F at -(F - NCO),
so shift = slice - NCO maps the slice to baseband). It only ever looked wrong
because hl2_shift_test validated it in LSB — the one mode the conjugation bug
made correct. Flipping it was tried and measurably broke off-centre tuning.
Both DSP tests were feeding textbook analytic IQ that no HL2 ever sends, which
is why a mirrored panadapter and an inverted demodulator both passed:
hl2_rxdsp_test fed exp(+j.w.t) for a signal above centre; the wire sends
exp(-j.w.t). Now generates wire order.
hl2_shift_test same, and its "tone 800 Hz BELOW the NCO" was really 800 Hz
above. Now generates wire order; assertions unchanged.
Verified on a live Hermes-Lite 2 (00:1C:C0:A2:13:DD), receive only:
- shift forced to zero: usb/digu hear ABOVE the dial, lsb/digl BELOW,
100-150x margin (previously inverted in all four)
- normal off-centre tuning: all four correct, recovered tone exactly 1500 Hz
on a 1500 Hz offset, confirming the shift sign
- 20 m panadapter: FT8 renders INSIDE the DIGU passband at 14.074-14.077,
right of the cursor, confirmed by the operator
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The RX handedness bug survived a full session of correct-looking measurements and is the one most likely to recur verbatim on the next backend that owns its own DSP. Writing it down is the point of this file. New §15 covers the whole thing: the demodulator/spectrum conjugation split and why each consumer needs the opposite convention; why the slice shift is NOT part of the bug and must not be "fixed"; a symptom-to-cause table (chiefly "LSB/DIGL work and USB/DIGU do not" meaning coherent inversion rather than a broken mode); the zero-shift measurement that is the only geometry where compensating errors cannot cancel; and a six-step bring-up checklist. §5 gains the two WDSP sideband rules the backend actually depends on: RX selects the sideband from the PASSBAND EDGES (so mode must be set before the passband, and the passband re-pushed on every mode set), while RX's RXA passes the OPPOSITE sign to its bounds — and TX is the mirror image, taking the sideband from the MODE with a positive audio-domain bandpass. Those opposite conventions are why Hl2Backend carries two separate passband tables. §14.6 previously recorded that the receive path "already compensated" for wire handedness and that transmit merely needed the same correction. That reasoning was right about the wire and wrong about which stage carries the fix — the receive-side conjugation was itself the bug. Corrected in place with a pointer to §15 rather than deleted, since the wrong turn is part of the record. §14.8 gains two open items surfaced while proving the fix: a consistent ~200 Hz (≈20 ppm) receive offset at 10 MHz with no calibration knob, and RTTY appearing in the TCI modulations_list with no HL2 mode mapping, falling through to USB. Header now points a new reader at §15 and §5 first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2c0a5bb to
790e0c3
Compare
Post-rebase on-air confirmation — QSO with KK7WAV, 20 m FT8 DIGUClosing the one gap flagged when this was rebased: the conflict resolution in Completed QSO with KK7WAV on 20 m FT8 in DIGU, plus 24 PSK Reporter spots, on the rebased binary. A completed QSO is the stronger of the two results — it is bidirectional. Decoding him exercises the receive chain; him decoding us exercises the transmit chain end to end, through independent equipment, with a full exchange rather than a one-way heard-report. Spot count varies with propagation and time of day and is not a quality metric; a completed exchange is binary and it passed. This specifically validates the merged path, which is what the rebase touched: Both upstream's WSPR beacon refactor and this PR's host-modulation branch survive the merge with the behaviour each intended. One useful consequence of the ordering: on an HL2 a WSPR beacon transmission now also routes through the host modulator, which is correct for a radio that modulates locally — it would otherwise have tried to build VITA-49 packets for a stream that does not exist. Rebased onto |
…s build
Two review findings on this branch.
TUNE keyed without publishing the raw-TX edge. The branch established that a
non-Flex backend publishes radioTransmittingChanged from its own keying command
(nothing else can, with no interlock status plane) and wired that into
setTransmit() and the moxCommandIssued path — but not tuneCommandIssued, which
also keys: Hl2Backend::setTune() calls setKeying(). So a tune carrier went out
with m_radioTransmitting still false. Three consequences, in descending order of
how much they matter on the air:
- TciServer's "already transmitting" guard reads isRadioTransmitting(), so a
TCI client could key ON TOP of a live tune carrier; that client's unkey then
dropped the key while the backend still believed tune owned it.
- No trx:true reached TCI clients, so a TCI-driven amplifier never switched to
TX for a tune carrier — the signal operators most often tune INTO an amp.
- Waterfall freeze and the TX indicator, both driven off the same edge, stayed
idle while transmitting.
Covered by a new case in hl2_tci_signaling_test, verified to fail (3 assertions)
with the fix reverted. It drives tune through PttSource::Dax because that is the
one source localPttInterlockMessage() admits with no TX slice assigned, and
TCI/DAX-initiated tune is a real path in its own right. The Flex case gains a
startTune() alongside its setMox()/setTransmit() calls, pinning that Flex still
publishes nothing from a command.
hl2_tci_signaling_test did not compile without Qt6WebSockets. TciServer.h is
`#pragma once` followed immediately by `#ifdef HAVE_WEBSOCKETS`, so it expands
to nothing in such a build, and the Hl2TciSignalingTest friend helper named
TciServer outside any guard — "unknown type name 'TciServer'". Every other TCI
test sits inside `if(Qt6WebSockets_FOUND)`; this target does not, deliberately,
because the rest of the file (transmit edge, command plane, host-modulated TX
audio, per-mode passband) is WebSocket-free and worth running there. So the
guard goes around the helper instead of around the target.
Verified by compiling the translation unit with -DHAVE_WEBSOCKETS stripped from
its real compile line: fails before, clean after. Suite 184/184.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review fixesTwo confirmed findings from a review pass over this branch, both fixed in 7d07f7b. 1. TUNE keyed without publishing the raw-TX edgeThis branch establishes that a non-Flex backend publishes So a tune carrier went out with
New case in 2.
|
Two review findings on the HL2 TCI signaling work, both the same shape as the ones it already fixed: a Flex-only assumption that fails silently on a seam backend. VFO B / split wedged TCI keying permanently. tuneSliceAndConfirm() learned that sendCmdPublic() is swallowed on a backend with no Flex command plane, but createTxSliceForVfoB() and promoteTxSliceAndContinue() did not. Both open a route transition around a command whose completion callback then never runs, so m_routeTransitionInFlight stayed true forever — and handleTrxRequest() defers every trx:true while one is in flight. WSJT-X with Split = Rig/Fake It sends split_enable:0,true; before it transmits, a single-slice HL2 resolves that to RouteAction::Create, and from that moment the rig showed connected and could not key again for the rest of the session. The capacity check ahead of the create does not catch it: maxSlices() is the model-string-derived Flex estimate, so a one-slice HL2 looks like it has room. Refuse the route instead, which is also the honest answer — reportVfoBRouteFailure() withdraws the split and returns the authoritative channel-1 VFO, so WSJT-X falls back to single-VFO operation. promoteTxSliceAndContinue() now always answers rather than going silent; the already-TX slice every HL2 key uses still short-circuits above it. MOX and TUNE published a transmit that never happened. setTransmit() refuses keying when backendCapabilities().canTransmit is false; the mox/tuneCommandIssued paths had no such test, so with the HL2 transmit gate closed Hl2Backend::setKeying() logged "key refused" and returned while this side still flipped m_radioTransmitting. TciServer broadcast trx:...,true; for nothing on the air, and its "already transmitting" guard then rejected the next genuine TCI key. Gated in two places on purpose, matching the backend's own idiom: localPttInterlockMessage() refuses at the preflight so requestPttOn() (MOX, TCI hardware PTT) and startTune() never build the optimistic state, and the seam handlers refuse as a backstop and roll back what was already latched. Tests: 12 new assertions in hl2_tci_signaling_test, each verified to fail without its fix. Also restates hl2_shift_test's runTone() contract — the argument is wire-signed, the opposite of the NCO offset its doc claimed.
# Conflicts: # HERMES.md # src/core/backends/hl2/Hl2RxDsp.cpp # src/models/RadioModel.h
Integrating aethersdr#4470 (operator-controllable span and display rate) with this branch's receive-handedness fix needed two things beyond the textual merge, and both were silent failures rather than conflicts. The span shaper feeds Hl2Spectrum on BOTH branches — process() when a frame is due, accumulate() when it is skipped — so conjugating only the process() call would have left every skipped block mirrored inside the window that the next displayed frame is built from. Conjugate once, ahead of the branch. hl2_rxdsp_rate_test arrived on main generating exp(+j.2.pi.f.t), the textbook analytic tone no HL2 ever puts on the wire. Against the corrected chain — raw wire to the demodulator, USB [150,3000] selecting the opposite sign — that tone is out of passband, and the test went silent at all four rates. Same stimulus correction this branch already applied to hl2_rxdsp_test and hl2_shift_test; peak recovers to 3.6 at every rate, 0.0 dB spread. The test was only ever passing because the chain it ran against inverted every sideband. Also renumbers this branch's HERMES section to §16, since aethersdr#4470's landed first and took §15.
ten9876
left a comment
There was a problem hiding this comment.
Approving. Full pass over every hunk plus the surrounding call graphs in TciServer, RadioModel, AudioEngine, Hl2Backend, Hl2RxDsp/Hl2TxDsp, SliceModel and TransmitModel. Two defects found; both fixed on this branch (4917ba4, e774b22) with tests verified to fail without their fix.
Fixed here
TciServer — VFO B / split wedged TCI keying permanently. tuneSliceAndConfirm() learned that sendCmdPublic() is swallowed on a backend with no Flex command plane, but createTxSliceForVfoB() and promoteTxSliceAndContinue() did not. Both open a route transition around a command whose completion callback then never runs, so m_routeTransitionInFlight stayed true forever — and handleTrxRequest() defers every trx:true while one is in flight. WSJT-X with Split = Rig/Fake It sends split_enable:0,true; before it transmits, a single-slice HL2 resolves that to RouteAction::Create, and from that moment the rig showed connected and could not key again for the rest of the session. Worth noting the capacity check ahead of the create does not catch it: maxSlices() is the model-string-derived Flex estimate (2 by default), so a one-slice HL2 looks like it has room. Now refused before the transition opens, which is also the honest answer — reportVfoBRouteFailure() withdraws the split and returns the authoritative channel-1 VFO, so WSJT-X falls back to single-VFO operation instead of waiting. promoteTxSliceAndContinue() now always answers rather than going silent; the already-TX slice every HL2 key actually uses still short-circuits above it.
RadioModel — MOX and TUNE published a transmit that never happened. setTransmit() refuses keying when backendCapabilities().canTransmit is false; the mox/tuneCommandIssued paths had no such test. With the HL2 transmit gate closed, Hl2Backend::setKeying() logged "key refused" and returned while this side still flipped m_radioTransmitting — so TciServer broadcast trx:…,true; for nothing on the air, and its "already transmitting" guard then rejected the next genuine TCI key. Also reachable via the TCI hardware-PTT path (requestPttOn(TciHardware) → setMox). Gated in two places on purpose, matching this backend's own idiom: localPttInterlockMessage() refuses at the preflight so requestPttOn() and startTune() never build the optimistic state, and the seam handlers refuse as a backstop and roll back what was already latched.
Merge integration with #4470. Two silent failures rather than conflicts. The span shaper feeds Hl2Spectrum on both branches — process() when a frame is due, accumulate() when it is skipped — so conjugating only the process() call would have left every skipped block mirrored inside the window the next displayed frame is built from; the conjugation now happens once, ahead of the branch. And hl2_rxdsp_rate_test arrived on main generating exp(+j·2π·f·t), the textbook analytic tone no HL2 ever puts on the wire: against the corrected chain it is out of passband and the test went silent at all four rates. Same stimulus correction this branch already applied to hl2_rxdsp_test/hl2_shift_test — peak recovers to 3.6 at every rate, 0.0 dB spread. That test was only passing because the chain it ran against inverted every sideband. HERMES section renumbered to §16 since #4470 landed first and took §15.
Checked and cleared
The RX handedness swap (m_conjugated for spectrum, raw wire for demod) is internally consistent with the updated stimuli and with the TX-side wireUpper expectations in hl2_txdsp_test. The RX/TX passband tables are sign-correct against SliceModel::normalizeFilterPolarity — CW/CWU/CWL fall in no polarity family so the positive CW pair survives, AM/FM/DSB straddle. The feedDaxTxAudio host-modulation branch converts float32→int16 correctly, including clamp order and the −1.0 edge, and the 24 kHz contract holds end to end (TciServer resamples 48k→24k, DEFAULT_SAMPLE_RATE is 24000, Hl2Backend::submitTxAudio rejects anything else). The new backendAudioFrameReady → onDaxAudioReady(1, …) connection is inside #ifdef HAVE_WEBSOCKETS, wireCatPorts() is called once so it cannot duplicate, and the channel→trx fallback resolves 1→0. m_txAudioPrepared resets per TX session, so hostModulatingBackend() is re-evaluated after a family swap. ensureDaxForTci's !panStream() guard is a proxy for the new capability predicate but has no reachable failure on Flex.
Also restated hl2_shift_test's runTone() contract: the argument is wire-signed, the opposite of the NCO offset its doc claimed, which is precisely the trap §16 exists to document.
Verification
Full local build, 185/186 tests pass. The one failure — cross_needle_meter_test, "SWR label placement cache is keyed by the rendered font" — reproduces identically on pristine origin/main in this environment and is unrelated to this PR.
…gnostic Rebased onto PR aethersdr#4471, which already carried the transmit work by content and adds the receive-handedness fixes this needs. Generalises txcert into a phased runner covering both directions. ONE VERB, NOT TWO, and the reason is not tidiness. The checks worth the most are CROSS-CONSUMER, and two separate tools structurally cannot make them: panadapter vs demodulator (15.5 — each had the other's convention) RX handedness vs TX handedness (one wire fact, two users) demodulated audio vs transmitted (14.6) A combined runner states the wire handedness ONCE and shows every consumer agreeing or not. Split in two, you get two self-consistent reports that both pass — which is precisely how this radio transmitted on the wrong sideband for a fortnight. RECEIVE RUNS FIRST when both phases are selected. The wire's handedness is one fact both directions consume, so a transmit result read before the receive convention is settled is a result about an unknown quantity (15.6). New receive stages, each a transcription of a specific failure: mode-map every mode the app can emit survives a round trip. Plain "CW" was absent while "CWU" was present, so CW was demodulated as SSB with the indicator correct; "RTTY" is advertised over TCI with no mapping to this day. An unmapped mode does not fail, it becomes USB. (15.7) zero-shift forces the NCO to re-centre so the slice shift is exactly zero. Two compensating errors cancel at any other geometry, so measurements taken at normal off-centre tuning prove nothing. (15.4) rx-sidebands all FOUR SSB-family modes against a known off-centre carrier. A single-mode sideband test proves nothing: hl2_shift_test validated in LSB, the one mode the inversion made correct, and passed throughout. (15.3) consumer-agreement panadapter versus demodulator. Not yet automated — it needs a spectrum tap through the seam — so it is reported as an operator check rather than quietly omitted. It is the check that found the bug. (15.5) passband-after-mode-change SetRXAMode discards a passband applied before it. CW then DIGU is the ordering that exposed it: a 500 Hz window survived into a wide mode and the decoder saw nothing. (15.7) WWV is the default receive reference. It is free, always on, exactly known, and comes from a source that is not us — the one external reference available without a second radio, which is what 15.4 used to settle handedness. The receive phase does NOT require transmit permission. Gating it would put the first thing a new radio needs behind a permission nobody would have granted yet; only the keying phases are gated, on the same rail as every other TX verb. Still deliberately diagnostic — no pass/fail. Thresholds meaningful for one radio are guesses for the next, and the report says so. It ends with what it cannot determine, now including the two receive items: panadapter-versus-demodulator off-centre, and third-party spots in the mode under test. The receive bring-up ended with 63 spots on 14.074 DIGU, the first evidence that could not have come from a self-consistent loop. Suite: 184/184. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r#4500, aethersdr#4493) WSJT-X lost all rig control in v26.7.4: band changes failed, the dial read the wrong frequency, and transmissions went out of band. aethersdr#4407 replaced the model-driven tune with a raw `slice tune` written at the connection, using the radio's command reply as a barrier to read the settled frequency back out of SliceModel. Both halves of that are wrong on a Flex. The raw command bypasses SliceModel, so nothing updates m_frequency; and the radio answers `slice tune` with no RF_frequency status either (measured by @milenjb in aethersdr#4493: 89 tunes, 0 frequency statuses in one session). The read-back could therefore only ever observe the PRE-TUNE value, and every confirmation echoed the frequency the slice had already left. WSJT-X's do_frequency() waits on that echo. It concluded the radio had not moved and reported rig-control failure on every band change — while the radio was in fact on the new band, which is how transmissions ended up out of band and why this is a licence-compliance bug rather than a cosmetic one. aethersdr#4407 also added a short-circuit that confirmed from the model WITHOUT commanding the radio whenever the request matched what the model held. On its own that is harmless. Downstream of a model that no longer tracks the radio it is not: once the two diverged, a client asking for the frequency it had just been told got a false confirmation and the radio never moved at all. That is the second, worse divergence, and it is gone with the model authoritative again — a no-op now falls through to the setter, which ignores it internally and is still acknowledged. Tune through the model on every command plane. The setter IS the command path, not an addition to it: setFrequency() emits the byte-identical "slice tune <id> <mhz> autopan=0" this open-coded, and additionally updates the model, honours a locked slice, emits frequencyChanged, and routes through the TX-inhibit-guarded slice sink that the raw sendCmdPublic bypassed. There is no second command and no double-tune. This also collapses the Flex/seam split that aethersdr#4471 introduced: the non-Flex path already did exactly this, so only Flex was carrying the broken mechanism. One path now, and tuneSliceAndConfirm is synchronous, so a caller's route transition closes after the tune has really been taken rather than before. The confirmation is sent unconditionally even though a successful tune also reaches clients via frequencyChanged → broadcastSliceFrequencies(). That duplicates the channel-0 vfo: frame, which is idempotent; the alternative failure — a client left with no confirmation — hangs WSJT-X for its full rig-control timeout, and channel 1 is not covered by that automatic path unless the routing state happens to be bound. Tests: three cases in tci_server_review_test, asserted on the wire via tciMessage rather than only on the model, since the echo is what clients consume. The accumulating-tune case is @milenjb's Stream Deck encoder scenario. Verified to fail without the fix — "model did not take the tune (wanted 14224000, model holds 14225000)". Not fixed here, and worth its own issue: the radio never confirming a tune at all predates aethersdr#4407. The optimistic model update is what masks it, which is exactly why removing that update broke this much.
…r#4500, aethersdr#4493) WSJT-X lost all rig control in v26.7.4: band changes failed, the dial read the wrong frequency, and transmissions went out of band. aethersdr#4407 replaced the model-driven tune with a raw `slice tune` written at the connection, using the radio's command reply as a barrier to read the settled frequency back out of SliceModel. Both halves of that are wrong on a Flex. The raw command bypasses SliceModel, so nothing updates m_frequency; and the radio answers `slice tune` with no RF_frequency status either (measured by @milenjb in aethersdr#4493: 89 tunes, 0 frequency statuses in one session). The read-back could therefore only ever observe the PRE-TUNE value, and every confirmation echoed the frequency the slice had already left. WSJT-X's do_frequency() waits on that echo. It concluded the radio had not moved and reported rig-control failure on every band change — while the radio was in fact on the new band, which is how transmissions ended up out of band and why this is a licence-compliance bug rather than a cosmetic one. aethersdr#4407 also added a short-circuit that confirmed from the model WITHOUT commanding the radio whenever the request matched what the model held. On its own that is harmless. Downstream of a model that no longer tracks the radio it is not: once the two diverged, a client asking for the frequency it had just been told got a false confirmation and the radio never moved at all. That is the second, worse divergence, and it is gone with the model authoritative again — a no-op now falls through to the setter, which ignores it internally and is still acknowledged. Tune through the model on every command plane. The setter IS the command path, not an addition to it: setFrequency() emits the byte-identical "slice tune <id> <mhz> autopan=0" this open-coded, and additionally updates the model, honours a locked slice, emits frequencyChanged, and routes through the TX-inhibit-guarded slice sink that the raw sendCmdPublic bypassed. There is no second command and no double-tune. This also collapses the Flex/seam split that aethersdr#4471 introduced: the non-Flex path already did exactly this, so only Flex was carrying the broken mechanism. One path now, and tuneSliceAndConfirm is synchronous, so a caller's route transition closes after the tune has really been taken rather than before. The confirmation is sent unconditionally even though a successful tune also reaches clients via frequencyChanged → broadcastSliceFrequencies(). That duplicates the channel-0 vfo: frame, which is idempotent; the alternative failure — a client left with no confirmation — hangs WSJT-X for its full rig-control timeout, and channel 1 is not covered by that automatic path unless the routing state happens to be bound. Tests: three cases in tci_server_review_test, asserted on the wire via tciMessage rather than only on the model, since the echo is what clients consume. The accumulating-tune case is @milenjb's Stream Deck encoder scenario. Verified to fail without the fix — "model did not take the tune (wanted 14224000, model holds 14225000)". Not fixed here, and worth its own issue: the radio never confirming a tune at all predates aethersdr#4407. The optimistic model update is what masks it, which is exactly why removing that update broke this much.
…4493) (#4501) Fixes #4500. Fixes #4493. **Release-blocking.** WSJT-X lost all rig control in v26.7.4 — band changes fail, the dial reads the wrong frequency, and transmissions go out of band. Two users hit it independently within hours of the release, from opposite directions (WSJT-X and a Stream Deck encoder). ## Root cause #4407 replaced the model-driven tune with a raw `slice tune` written at the connection, using the radio's command reply as a barrier to read the settled frequency back out of `SliceModel`. Both halves are wrong on a Flex: - the raw command **bypasses `SliceModel`**, so nothing updates `m_frequency`; - the radio answers `slice tune` with **no `RF_frequency` status** either — measured by @milenjb in #4493: 89 tunes, 0 frequency statuses in one session. So the read-back could only ever observe the **pre-tune** value, and every confirmation echoed the frequency the slice had already left. WSJT-X's `do_frequency()` waits on that echo. It concluded the radio had not moved and reported rig-control failure on every band change — while the radio *was* on the new band. That mismatch is why this produces out-of-band transmissions rather than just a wrong display, and why it is labelled `safety`. ### The second divergence #4407 also added a short-circuit that confirmed from the model **without commanding the radio** when the request matched what the model held. Alone, harmless. Downstream of a model that no longer tracks the radio, not: once the two diverged, a client asking for the frequency it had just been told got a false confirmation and the radio never moved at all. ## The fix Tune through the model on every command plane. **The setter is the command path, not an addition to it.** `SliceModel::setFrequency()` emits the byte-identical `slice tune <id> <mhz> autopan=0` this open-coded — there is no second command and no double-tune. It additionally updates the model, honours a locked slice, emits `frequencyChanged`, and routes through the TX-inhibit-guarded slice sink that the raw `sendCmdPublic` bypassed. Net effect on `TciServer.cpp` is **47 added, 58 removed** — this is a simplification. It also collapses the Flex/seam split #4471 introduced: the non-Flex path was already doing exactly this, so only Flex carried the broken mechanism. `tuneSliceAndConfirm` is now synchronous, so a caller's route transition closes *after* the tune has been taken rather than before. The no-op short-circuit is removed rather than kept. Its stated purpose — acknowledging a repeat so WSJT-X cannot time out during startup convergence — survives, because a no-op falls through to the setter (which ignores it internally via `qFuzzyCompare`) and is still confirmed. There is a test pinning that specifically. ### On the duplicate broadcast The confirmation is sent unconditionally even though a successful tune also reaches clients via `frequencyChanged` → `broadcastSliceFrequencies()`. That duplicates the channel-0 `vfo:` frame. Deliberate: a duplicate is idempotent, whereas the opposite failure — a client left with **no** confirmation — hangs WSJT-X for its full rig-control timeout. Channel 1 is not covered by that automatic path at all unless the routing state happens to be bound, so suppressing this would make split confirmations depend on unrelated state. Documented at the call site. ## Testing Three cases in `tci_server_review_test`, asserted **on the wire** via `tciMessage` rather than only on the model, because the echo is what clients actually consume: - **accumulating tune** — @milenjb's nine-detent encoder scenario; each confirmation must carry the frequency just reached, and the stale value must not also go out. - **refused tune** — a locked slice must be confirmed with what the model *holds*, never with what the client asked for. - **no-op** — must still be acknowledged rather than met with silence (the behaviour the removed short-circuit was protecting). Verified to fail without the fix: ``` step 0: model did not take the tune (wanted 14224000, model holds 14225000) FAIL vfo: SET confirms the frequency reached (#4500/#4493) FAIL vfo: SET confirms the truth when the tune is refused ``` Full suite **190/191**. The single failure is `cross_needle_meter_test` ("SWR label placement cache is keyed by the rendered font"), which reproduces identically on pristine `origin/main` in a separate worktree — a local font-environment issue, unrelated. ## Not fixed here **The radio never confirming a tune at all predates #4407.** The optimistic model update is what masks it — which is precisely why removing that update broke this much. This PR restores the mask; the underlying gap wants its own issue, and #4220's TCI conformance RFC is the natural home for it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…-bus audit The WSPR beacon was refused outright on any non-Flex family. The reasoning was sound for a family that needs a `dax_tx` stream: ensureDaxTxStream() returns true optimistically on a pending `stream create`, so a backend whose command sink drops that text would "succeed" with a stream that never arrives and leave `transmit dax` latched for minutes. A host-modulating backend needs no stream. The transmit route already existed, built for TCI in aethersdr#4471 — pumpWsprBeacon() → feedDaxTxAudioInternal()'s m_hostModulation branch → txFinalMonitorPcmReady → submitTxAudio. So this adds an early arm in prepareWsprTransmit() that latches and returns true, the matching release, and teaches hasWsprTxStream() that "the route is ready" and "a dax_tx stream exists" are different claims. Three things that looked like blockers and were not: - Mic collision. Two producers into one modulator would put shack ambience on the frame, and the HL2 has no radio-side mic mute to hide behind. But startWsprPump() already calls setDaxTxMode(true), and onTxAudioReady() early-returns on m_daxTxMode before emitting txFinalMonitorPcmReady. The suppression is local and family-independent — written for Windows and for Linux-without-PipeWire, and exactly what a host-modulating backend needs. - Interlock timeout. m_interlockTimeout defaults to 0, HL2 never sets it, and isInterlockTimeoutSufficient(0) is true. - Keying. requestPttOn(PttSource::Wspr) already reaches setKeying() via moxCommandIssued on every non-Flex family. `transmit filter_low/high` stays Flex-only and is now documented as advisory rather than dropped: the HL2 derives its TX passband from the mode (DIGU → 150…3000 Hz), which contains the 1400–1600 Hz WSPR offset, and the tone is a single ~6 Hz-wide 4-FSK carrier. HERMES.md gains §18: the audit of which voice/data features actually work on this radio. Three RX audio buses carry identical payloads on different wires; every consumer is hard-wired to one at connect() time. CW, RTTY, the QSO recorder's RX tap, AetherClock, RADE, the DAX bridge and the DAX-IQ applet are all dead on HL2 for that reason alone. ASR, the AX.25/APRS/PMS stack and TCI work because they tap AudioEngine rather than a transport. Two of the dead ones (RADE, DAX bridge) deref panStream() bare and are a SIGSEGV on mode change, not a decline — flagged as the thing to fix ahead of this. §13 gains Tier 5 for the bus work; §6 cross-references gaps 16–19. Tests: the hl2_family_transition_test block that asserted the refusal now asserts the host-modulated arm, that it does NOT latch `transmit dax`, that release clears it, that it is re-armable, and that no claim survives a round-trip back to Flex. 192/192 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Initial bring up of TCI data stack on Hermes Lite 2.
What was broken
wirePanStreamTciSinks()binds TCI's audio feed toPanadapterStream, which an in-process backend doesn't have — so nothing was bound and TCI clients heard silence.TciServerwaits onRadioModel::radioTransmittingChangedto confirm a TCI key, and gives up 1250 ms later. That edge is decoded from Flexinterlockstatus and never arrived, so every WSJT-X transmission was unkeyed a second and a quarter in, mid-tone.AudioEngine::feedDaxTxAudioreturned immediately unless a Flex TX stream id was set. A host-modulating backend has none and never will — its modulator is local. Every TCI audio frame was dropped and the radio transmitted silence while reporting a normal transmit.sendCmdPublic(), which is swallowed on a seam backend and never runs its completion callback — so the band hop was dropped and WSJT-X's 2 s vfo-echo timeout expired on top of it.What changed
RadioModel::backendAudioFrameReadytoonDaxAudioReady()on channel 1. The payload is already float32 stereo at 24 kHz, and the channel→TRX fallback maps 1 → trx 0, which is the whole mapping on a single-slice radio.setTransmit()and themoxCommandIssuedpath, since the MOX button and PTT coordinator use only the latter.submitTxAudio(). Still a DSP bypass; these are pre-shaped digital tones.IRadioBackendand is authoritative on return.ensureDaxForTci, thedax=1TX route) on a backend with no DAX plane, rather than emitting Flex text at a radio speaking HPSDR.Two mode-table bugs found while verifying
Both broke WSJT-X directly:
modeFromStringknew"CWU"but not"CW"— the spellingTciProtocolproduces for TCI'scw, and the one a Flex reports. Plain CW fell through to the USB fallback and was demodulated as SSB, with the mode indicator reading CW.NFMwas missing for the same reason.Verification — live Hermes-Lite 2 (
00:1C:C0:A2:13:DD)Driven by a stdlib-only TCI client that shares no code with AetherSDR, so a convention error on both sides can't hide.
trx_count:1, one slice, one pan, every field WSJT-X reads presentcw/digu/digl/usb/lsb/am/cwrTX testing was into a dummy load on 7.200, then DIGU, at 1% and 30% drive.
New
hl2_tci_signaling_testcovers the seam (edge publication on both keying paths, Flex staying interlock-owned, the command-plane predicate, host-modulated TX audio conversion, and the per-mode passband table). Full suite: 178/178.Known gaps
vfo_limitsstill advertises the Flex1000,75000000rather than the HL2's real range — harmless for WSJT-X, worth making honest later.tx_sensorsmic_dbmis inert on HL2:micMetersChangedcarries theMICmeter and HL2 definesMICPEAK. Meter plumbing, not signaling.RTTYhas no HL2 mode mapping and still falls back to USB; left alone rather than guessed at.🤖 Generated with Claude Code