fix(tci): the squelch is the radio's refusal, not our bug — and the panel now shows it

The log settles it, and it was not mute. Mute works: '→ mute:0,true;' and
the radio answers 'mute:0,true'. What happens is that the squelch REFUSES
to go off — we send 'sql_enable:0,false;', the radio echoes false, and
eighty milliseconds later announces 'sql_enable:0,true' again. Six times
in the log, always the same shape. The SQL lamp coming back on right after
a MUTE click is that revert landing, not the mute doing it.

So the optimistic hold is fixed rather than the phantom. It was a fixed
1.2 s, which is wrong in both directions: too short for a setting the
radio never echoes at all (AGC), and too long for one it refuses, where it
showed the operator a lie for a second. The requested value now stands
while the radio says nothing about that setting, and the instant the radio
reports any change for it, its word replaces ours. A refusal is therefore
visible immediately, and an unechoed setting still sticks.

Also from the same session: filters per MODE — the narrow end in CW, the
voice widths in SSB, a middle set for the digital modes — because 250 Hz
in SSB and 2.8 kHz in CW are buttons nobody presses. And APF is shown only
in CW: it rings a single tone out of the noise, which is a CW tool and
nothing else.
This commit is contained in:
2026-08-26 21:04:00 +02:00
parent dad71e9e4f
commit 01d0b8f22b
+67 -32
View File
@@ -32,16 +32,27 @@ const ZERO: TCIState = {
rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0, rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0,
}; };
// The widths worth a button. TCI wants the two EDGES, not a width, so the // The widths worth a button, PER MODE — because 250 Hz is useless in SSB and
// edges are computed from the width and the mode — and the button says the // 2.8 kHz is useless in CW, and a row offering both is a row where half the
// width, which had better be the width you get. // buttons are never pressed.
// //
// It did not: "250" set 300-550, which is 250 Hz wide but sitting where a CW // CW gets the narrow end, where the difference between 250 and 500 is the
// note is not, and the row underneath said "300-550 Hz" while the button said // difference between one signal and three. Voice gets the range a passband is
// 250. Now a narrow filter is CENTRED ON THE CW NOTE and a wide one starts at // actually shaped over. Digital sits between: wide enough for a whole FT8
// the bottom of the voice band, which is what each is for. // sub-band, narrow enough for RTTY.
const CW_PITCH = 700; // ExpertSDR3's default sidetone, and where its CW filters sit const WIDTHS_CW = [100, 250, 400, 500, 700, 1000, 1800];
const WIDTHS = [250, 500, 1000, 1800, 2400, 2800, 3500]; const WIDTHS_SSB = [1800, 2100, 2400, 2700, 2800, 3000, 3500];
const WIDTHS_DIGI = [500, 1000, 1800, 2400, 2800, 3000, 3500];
// widthsFor picks the row from the mode the radio reports.
function widthsFor(mode: string): number[] {
if (/CW/i.test(mode)) return WIDTHS_CW;
if (/SSB|USB|LSB|AM|FM/i.test(mode)) return WIDTHS_SSB;
return WIDTHS_DIGI;
}
// isCW says whether the CW-only controls belong on screen at all.
function isCW(mode: string): boolean { return /CW/i.test(mode); }
function widthLabel(w: number): string { function widthLabel(w: number): string {
return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w); return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w);
@@ -141,14 +152,33 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
// announces afterwards — the new value, or a refusal that leaves the old one // 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 // — wins once the hold expires, which keeps a clamped or rejected setting
// honest without making every working one look broken. // honest without making every working one look broken.
const holdRef = useRef<Record<string, { v: any; until: number }>>({}); // Held UNTIL THE RADIO SPEAKS, not for a fixed moment.
//
// A timeout was wrong in both directions. Too short and a setting the radio
// never echoes — AGC is one — snapped back to its old value a second after
// the click. Too long and a setting the radio REFUSES looked accepted: a real
// log shows this one answering 'sql_enable:0,false' and then, eighty
// milliseconds later, 'sql_enable:0,true' — it puts the squelch straight back
// on. Holding through that would have shown the operator a lie.
//
// So the requested value stands while the radio says nothing about it, and
// the instant it reports ANY change for that setting, its word replaces ours.
const holdRef = useRef<Record<string, { v: any; reported: any }>>({});
const [, forceRender] = useState(0); const [, forceRender] = useState(0);
const hold = <T,>(key: string, reported: T): T => { const hold = <T,>(key: string, reported: T): T => {
const h = holdRef.current[key]; const h = holdRef.current[key];
return h && Date.now() < h.until ? (h.v as T) : reported; if (!h) return reported;
// The radio has said something different from what it was saying when the
// click happened — whether that is our value or a refusal, it is now the
// truth and the hold is over.
if (reported !== h.reported) {
delete holdRef.current[key];
return reported;
}
return h.v as T;
}; };
const setHold = (key: string, v: any) => { const setHold = (key: string, v: any, reported: any) => {
holdRef.current[key] = { v, until: Date.now() + 1200 }; holdRef.current[key] = { v, reported };
forceRender((n) => n + 1); forceRender((n) => n + 1);
}; };
@@ -200,7 +230,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
if (r.off || !r.on) return; if (r.off || !r.on) return;
e.preventDefault(); e.preventDefault();
const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10); const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10);
setHold('rit_hz', v); setHold('rit_hz', v, ritRef.current.hz);
SetTCIRITOffset(v).catch(() => {}); SetTCIRITOffset(v).catch(() => {});
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
@@ -255,9 +285,9 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
left each of them a couple of centimetres long — small enough that left each of them a couple of centimetres long — small enough that
setting 15% took aim. */} setting 15% took aim. */}
<LevelRow label={t('tcip.drive')} unit="%" value={drive} disabled={off} <LevelRow label={t('tcip.drive')} unit="%" value={drive} disabled={off}
onSet={(v) => { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> onSet={(v) => { setHold('drive', v, st.drive); call(() => SetTCIDrive(v)); }} />
<LevelRow label={t('tcip.tuneDrive')} unit="%" value={tuneDrive} disabled={off} accent="#f59e0b" <LevelRow label={t('tcip.tuneDrive')} unit="%" value={tuneDrive} disabled={off} accent="#f59e0b"
onSet={(v) => { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} /> onSet={(v) => { setHold('tune_drive', v, st.tune_drive); call(() => SetTCITuneDrive(v)); }} />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* TUNE transmits, and at the tune drive rather than the main one — {/* TUNE transmits, and at the tune drive rather than the main one —
which is why both numbers are above the button rather than one of which is why both numbers are above the button rather than one of
@@ -274,7 +304,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
)} )}
</div> </div>
<LevelRow label={t('tcip.mic')} unit="%" value={mic} disabled={off} accent="#a855f7" <LevelRow label={t('tcip.mic')} unit="%" value={mic} disabled={off} accent="#a855f7"
onSet={(v) => { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} /> onSet={(v) => { setHold('mic', v, st.mic_level); call(() => SetTCIMicLevel(v)); }} />
</Card> </Card>
{/* Receive */} {/* Receive */}
@@ -283,17 +313,22 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
squelch as a dBm threshold — so the numbers match the ones in squelch as a dBm threshold — so the numbers match the ones in
ExpertSDR3's window rather than being percentages of something. */} ExpertSDR3's window rather than being percentages of something. */}
<LevelRow label={t('tcip.volume')} unit=" dB" min={-60} max={0} value={vol} disabled={off} <LevelRow label={t('tcip.volume')} unit=" dB" min={-60} max={0} value={vol} disabled={off}
onSet={(v) => { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> onSet={(v) => { setHold('vol', v, st.volume); call(() => SetTCIVolume(v)); }} />
<LevelRow label={t('tcip.squelch')} unit=" dBm" min={-140} max={0} value={sql} <LevelRow label={t('tcip.squelch')} unit=" dBm" min={-140} max={0} value={sql}
disabled={off || !sqlOn} accent="#38bdf8" disabled={off || !sqlOn} accent="#38bdf8"
onSet={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} /> onSet={(v) => { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} />
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2"> <div className={cn('grid gap-2', isCW(mode) ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-3 sm:grid-cols-5')}>
<Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb); call(() => SetTCINB(!nb)); }} /> <Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb, st.nb); call(() => SetTCINB(!nb)); }} />
<Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} /> <Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr, st.nr); call(() => SetTCINR(!nr)); }} />
<Toggle label="ANF" on={anf} off={off} onClick={() => { setHold('anf', !anf); call(() => SetTCIANF(!anf)); }} /> <Toggle label="ANF" on={anf} off={off} onClick={() => { setHold('anf', !anf, st.anf); call(() => SetTCIANF(!anf)); }} />
<Toggle label="APF" on={apf} off={off} onClick={() => { setHold('apf', !apf); call(() => SetTCIAPF(!apf)); }} /> {/* APF is an audio PEAK filter — it rings a single tone out of the
<Toggle label="SQL" on={sqlOn} off={off} onClick={() => { setHold('sql_on', !sqlOn); call(() => SetTCISquelch(!sqlOn)); }} /> noise, which is a CW tool and nothing else. Shown only there:
<Toggle label={t('tcip.mute')} on={muted} off={off} onClick={() => { setHold('mute', !muted); call(() => SetTCIMute(!muted)); }} /> off CW it is not a control, it is a puzzle. */}
{isCW(mode) && (
<Toggle label="APF" on={apf} off={off} onClick={() => { setHold('apf', !apf, st.apf); call(() => SetTCIAPF(!apf)); }} />
)}
<Toggle label="SQL" on={sqlOn} off={off} onClick={() => { setHold('sql_on', !sqlOn, st.squelch_on); call(() => SetTCISquelch(!sqlOn)); }} />
<Toggle label={t('tcip.mute')} on={muted} off={off} onClick={() => { setHold('mute', !muted, st.mute); call(() => SetTCIMute(!muted)); }} />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span>
@@ -303,7 +338,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{['off', 'slow', 'med', 'fast'].map((m) => ( {['off', 'slow', 'med', 'fast'].map((m) => (
<Toggle key={m} label={m.toUpperCase()} on={agc === m} off={off} <Toggle key={m} label={m.toUpperCase()} on={agc === m} off={off}
onClick={() => { setHold('agc', m); call(() => SetTCIAGC(m)); }} /> onClick={() => { setHold('agc', m, st.agc || ''); call(() => SetTCIAGC(m)); }} />
))} ))}
</div> </div>
</div> </div>
@@ -323,7 +358,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} /> onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} />
</div> </div>
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2"> <div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
{WIDTHS.map((w) => { {widthsFor(mode).map((w) => {
const e = edgesFor(w, mode); const e = edgesFor(w, mode);
// Lit by the WIDTH the radio is actually using, not by an exact // Lit by the WIDTH the radio is actually using, not by an exact
// pair of edges: the operator may have moved one edge on the // pair of edges: the operator may have moved one edge on the
@@ -346,11 +381,11 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<Card icon={Mic} title="RIT / XIT"> <Card icon={Mic} title="RIT / XIT">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ShiftRow label="RIT" on={rit} hz={ritHz} accent="#38bdf8" disabled={off} <ShiftRow label="RIT" on={rit} hz={ritHz} accent="#38bdf8" disabled={off}
onToggle={() => { setHold('rit', !rit); call(() => SetTCIRIT(!rit)); }} onToggle={() => { setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }}
onSet={(v) => { setHold('rit_hz', v); call(() => SetTCIRITOffset(v)); }} /> onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} />
<ShiftRow label="XIT" on={xit} hz={xitHz} accent="#f59e0b" disabled={off} <ShiftRow label="XIT" on={xit} hz={xitHz} accent="#f59e0b" disabled={off}
onToggle={() => { setHold('xit', !xit); call(() => SetTCIXIT(!xit)); }} onToggle={() => { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }}
onSet={(v) => { setHold('xit_hz', v); call(() => SetTCIXITOffset(v)); }} /> onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
</div> </div>
</Card> </Card>
</div> </div>