fix(tci): the drive commands need the TRX index, and the console holds its own clicks

Two faults, reported from a real SunSDR.

DRIVE AND TUNE DRIVE DID NOTHING, and neither did MUTE. Those commands
carry the transceiver index — 'drive:0,15;', not 'drive:15;' — and sent
without it the radio ignores them silently: no error, no answer, the power
unchanged. The rule was in the radio's own reports all along, which is
where it should have been read from: it announces 'drive:0,85' and
'mute:0,false' at connect, while 'mic_level:100' and 'volume:-12' come
with no index at all. Sending the shape the radio speaks in is the whole
rule, and it is now written down next to the two exceptions.

AGC LOOKED STUCK ON SLOW. The panel showed only what the radio reported
back, on the principle that the radio is the truth — but ExpertSDR3 does
not echo every setting it accepts, so a working button sat unlit. Changes
are shown at once and held for a moment now; whatever the radio announces
afterwards still wins, so a clamped or refused setting stays honest
without every working one looking broken.

Also from the same report, and fair: the consoles did not resemble each
other. The Icom panel's RIT control is now a shared component both use —
chip, signed offset, ± keys, wheel, and TYPING a value straight in, which
is the thing a row of ±10/±100 buttons cannot do. Ctrl+←/→ shifts the RIT
here as it does there. And LONG is gone from the AGC row: the protocol
takes it, but it is a hang time nobody reaches for between overs.
This commit is contained in:
2026-08-26 19:15:48 +02:00
parent 4c2638a7e5
commit fc79be7c05
4 changed files with 180 additions and 87 deletions
+69 -47
View File
@@ -12,6 +12,7 @@ import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { MeterBar } from '@/components/MeterBar';
import { WheelRange } from '@/components/WheelRange';
import { ShiftRow } from '@/components/ShiftRow';
type TCIState = {
connected: boolean; device?: string; protocol?: string;
@@ -113,16 +114,27 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
const [mode, setMode] = useState('');
const [err, setErr] = useState('');
// A slider the operator is dragging must not be dragged back by the poll.
// The radio confirms every change by announcing it, but that answer takes a
// round trip — long enough for a drag to stutter against its own echo.
const holdRef = useRef<Record<string, { v: number; until: number }>>({});
const hold = (key: string, reported: number) => {
// OPTIMISTIC, like the Icom console — and for a reason found on a real radio.
//
// This panel used to show only what the radio reported back, on the principle
// that the radio is the truth. But ExpertSDR3 does not echo every setting it
// is given: press MED and the radio changes, says nothing, and the button
// stays lit on SLOW. Waiting for an answer that never comes reads as a dead
// control.
//
// So a change is shown at once and held for a moment. Whatever the radio
// announces afterwards — the new value, or a refusal that leaves the old one
// — wins once the hold expires, which keeps a clamped or rejected setting
// honest without making every working one look broken.
const holdRef = useRef<Record<string, { v: any; until: number }>>({});
const [, forceRender] = useState(0);
const hold = <T,>(key: string, reported: T): T => {
const h = holdRef.current[key];
return h && Date.now() < h.until ? h.v : reported;
return h && Date.now() < h.until ? (h.v as T) : reported;
};
const setHold = (key: string, v: number) => {
holdRef.current[key] = { v, until: Date.now() + 900 };
const setHold = (key: string, v: any) => {
holdRef.current[key] = { v, until: Date.now() + 1200 };
forceRender((n) => n + 1);
};
useEffect(() => {
@@ -146,6 +158,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
}, []);
const off = !st.connected;
const call = (fn: () => Promise<any>) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); };
const drive = hold('drive', st.drive);
@@ -153,8 +166,32 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
const mic = hold('mic', st.mic_level);
const vol = hold('vol', st.volume);
const sql = hold('sql', st.squelch);
const agc = hold('agc', st.agc || '');
const nb = hold('nb', st.nb), nr = hold('nr', st.nr);
const anf = hold('anf', st.anf), apf = hold('apf', st.apf);
const sqlOn = hold('sql_on', st.squelch_on), muted = hold('mute', st.mute);
const rit = hold('rit', st.rit), xit = hold('xit', st.xit);
const ritHz = hold('rit_hz', st.rit_offset), xitHz = hold('xit_hz', st.xit_offset);
const s = sParts(st.smeter);
// Ctrl+Left/Right shifts the RIT by ±10 Hz, the same keys the Icom console
// uses. Two consoles for two radios should not need two habits.
const ritRef = useRef({ on: false, hz: 0, off: true });
ritRef.current = { on: rit, hz: ritHz, off };
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
const r = ritRef.current;
if (r.off || !r.on) return;
e.preventDefault();
const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10);
setHold('rit_hz', v);
SetTCIRITOffset(v).catch(() => {});
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<div className="h-full min-h-0 overflow-auto bg-background">
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles.
@@ -240,25 +277,28 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<WheelRange min={-60} max={0} disabled={off} value={vol}
onChange={(v) => { setHold('vol', v); call(() => SetTCIVolume(v)); }} />
</Row>
<Row label={t('tcip.squelch')} value={st.squelch_on ? `${sql} dBm` : t('tcip.off')}>
<WheelRange min={-140} max={0} disabled={off || !st.squelch_on} value={sql}
<Row label={t('tcip.squelch')} value={sqlOn ? `${sql} dBm` : t('tcip.off')}>
<WheelRange min={-140} max={0} disabled={off || !sqlOn} value={sql}
onChange={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
</Row>
</div>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
<Toggle label="NB" on={st.nb} off={off} onClick={() => call(() => SetTCINB(!st.nb))} />
<Toggle label="NR" on={st.nr} off={off} onClick={() => call(() => SetTCINR(!st.nr))} />
<Toggle label="ANF" on={st.anf} off={off} onClick={() => call(() => SetTCIANF(!st.anf))} />
<Toggle label="APF" on={st.apf} off={off} onClick={() => call(() => SetTCIAPF(!st.apf))} />
<Toggle label="SQL" on={st.squelch_on} off={off} onClick={() => call(() => SetTCISquelch(!st.squelch_on))} />
<Toggle label={t('tcip.mute')} on={st.mute} off={off} onClick={() => call(() => SetTCIMute(!st.mute))} />
<Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb); call(() => SetTCINB(!nb)); }} />
<Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} />
<Toggle label="ANF" on={anf} off={off} onClick={() => { setHold('anf', !anf); call(() => SetTCIANF(!anf)); }} />
<Toggle label="APF" on={apf} off={off} onClick={() => { setHold('apf', !apf); call(() => SetTCIAPF(!apf)); }} />
<Toggle label="SQL" on={sqlOn} off={off} onClick={() => { setHold('sql_on', !sqlOn); call(() => SetTCISquelch(!sqlOn)); }} />
<Toggle label={t('tcip.mute')} on={muted} off={off} onClick={() => { setHold('mute', !muted); call(() => SetTCIMute(!muted)); }} />
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span>
<div className="grid grid-cols-5 gap-2">
{['off', 'long', 'slow', 'med', 'fast'].map((m) => (
<Toggle key={m} label={m.toUpperCase()} on={(st.agc || '') === m} off={off}
onClick={() => call(() => SetTCIAGC(m))} />
{/* LONG is gone. The protocol accepts it, but it is a hang time
nobody reaches for between overs, and a fifth button that has to
be explained is worse than four that do not. */}
<div className="grid grid-cols-4 gap-2">
{['off', 'slow', 'med', 'fast'].map((m) => (
<Toggle key={m} label={m.toUpperCase()} on={agc === m} off={off}
onClick={() => { setHold('agc', m); call(() => SetTCIAGC(m)); }} />
))}
</div>
</div>
@@ -277,35 +317,17 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
</div>
</Card>
{/* RIT / XIT */}
{/* RIT / XIT — the SAME control the Icom console uses, now shared
rather than reinvented: a chip, a signed offset you can type into,
scroll on, or step with ±, and a zero. Ctrl+←/→ shifts the RIT. */}
<Card icon={Mic} title="RIT / XIT">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{([
{ key: 'rit', on: st.rit, offset: st.rit_offset, toggle: SetTCIRIT, set: SetTCIRITOffset },
{ key: 'xit', on: st.xit, offset: st.xit_offset, toggle: SetTCIXIT, set: SetTCIXITOffset },
] as const).map((r) => (
<div key={r.key} className="flex items-center gap-2">
<Toggle label={r.key.toUpperCase()} on={r.on} off={off} onClick={() => call(() => r.toggle(!r.on))} />
<span className="text-xs font-mono tabular-nums w-16 text-center">
{r.offset > 0 ? `+${r.offset}` : r.offset} Hz
</span>
{/* ±10 and ±100, and a zero. The radio's own knob does the rest;
a console that tries to replace it needs a knob, not more
buttons. */}
{[-100, -10, 10, 100].map((d) => (
<button key={d} type="button" disabled={off || !r.on}
onClick={() => call(() => r.set(r.offset + d))}
className="rounded-md border border-border bg-card px-1.5 py-1 text-[10px] font-mono hover:bg-muted disabled:opacity-30">
{d > 0 ? `+${d}` : d}
</button>
))}
<button type="button" disabled={off || !r.on}
onClick={() => call(() => r.set(0))}
className="rounded-md border border-border bg-card px-1.5 py-1 text-[10px] font-bold hover:bg-muted disabled:opacity-30">
0
</button>
</div>
))}
<ShiftRow label="RIT" on={rit} hz={ritHz} accent="#38bdf8" disabled={off}
onToggle={() => { setHold('rit', !rit); call(() => SetTCIRIT(!rit)); }}
onSet={(v) => { setHold('rit_hz', v); call(() => SetTCIRITOffset(v)); }} />
<ShiftRow label="XIT" on={xit} hz={xitHz} accent="#f59e0b" disabled={off}
onToggle={() => { setHold('xit', !xit); call(() => SetTCIXIT(!xit)); }}
onSet={(v) => { setHold('xit_hz', v); call(() => SetTCIXITOffset(v)); }} />
</div>
</Card>
</div>