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,
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user