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:
@@ -32,16 +32,27 @@ const ZERO: TCIState = {
|
||||
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
|
||||
// edges are computed from the width and the mode — and the button says the
|
||||
// width, which had better be the width you get.
|
||||
// The widths worth a button, PER MODE — because 250 Hz is useless in SSB and
|
||||
// 2.8 kHz is useless in CW, and a row offering both is a row where half the
|
||||
// buttons are never pressed.
|
||||
//
|
||||
// It did not: "250" set 300-550, which is 250 Hz wide but sitting where a CW
|
||||
// note is not, and the row underneath said "300-550 Hz" while the button said
|
||||
// 250. Now a narrow filter is CENTRED ON THE CW NOTE and a wide one starts at
|
||||
// the bottom of the voice band, which is what each is for.
|
||||
const CW_PITCH = 700; // ExpertSDR3's default sidetone, and where its CW filters sit
|
||||
const WIDTHS = [250, 500, 1000, 1800, 2400, 2800, 3500];
|
||||
// CW gets the narrow end, where the difference between 250 and 500 is the
|
||||
// difference between one signal and three. Voice gets the range a passband is
|
||||
// actually shaped over. Digital sits between: wide enough for a whole FT8
|
||||
// sub-band, narrow enough for RTTY.
|
||||
const WIDTHS_CW = [100, 250, 400, 500, 700, 1000, 1800];
|
||||
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 {
|
||||
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
|
||||
// — 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 }>>({});
|
||||
// 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 hold = <T,>(key: string, reported: T): T => {
|
||||
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) => {
|
||||
holdRef.current[key] = { v, until: Date.now() + 1200 };
|
||||
const setHold = (key: string, v: any, reported: any) => {
|
||||
holdRef.current[key] = { v, reported };
|
||||
forceRender((n) => n + 1);
|
||||
};
|
||||
|
||||
@@ -200,7 +230,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
if (r.off || !r.on) return;
|
||||
e.preventDefault();
|
||||
const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10);
|
||||
setHold('rit_hz', v);
|
||||
setHold('rit_hz', v, ritRef.current.hz);
|
||||
SetTCIRITOffset(v).catch(() => {});
|
||||
};
|
||||
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
|
||||
setting 15% took aim. */}
|
||||
<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"
|
||||
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">
|
||||
{/* 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
|
||||
@@ -274,7 +304,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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
|
||||
ExpertSDR3's window rather than being percentages of something. */}
|
||||
<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}
|
||||
disabled={off || !sqlOn} accent="#38bdf8"
|
||||
onSet={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||
<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)); }} />
|
||||
onSet={(v) => { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} />
|
||||
<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, st.nb); call(() => SetTCINB(!nb)); }} />
|
||||
<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, st.anf); call(() => SetTCIANF(!anf)); }} />
|
||||
{/* APF is an audio PEAK filter — it rings a single tone out of the
|
||||
noise, which is a CW tool and nothing else. Shown only there:
|
||||
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 className="space-y-1">
|
||||
<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">
|
||||
{['off', 'slow', 'med', 'fast'].map((m) => (
|
||||
<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>
|
||||
@@ -323,7 +358,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
|
||||
{WIDTHS.map((w) => {
|
||||
{widthsFor(mode).map((w) => {
|
||||
const e = edgesFor(w, mode);
|
||||
// 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
|
||||
@@ -346,11 +381,11 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
<Card icon={Mic} title="RIT / XIT">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<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)); }} />
|
||||
onToggle={() => { setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }}
|
||||
onSet={(v) => { setHold('rit_hz', v, st.rit_offset); 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)); }} />
|
||||
onToggle={() => { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }}
|
||||
onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user