import { useEffect, useRef, useState } from 'react'; import { Radio, Activity, AudioLines, SlidersHorizontal, Mic } from 'lucide-react'; import { GetTCIPanel, GetCATState, SetTCIDrive, SetTCITuneDrive, SetTCIMicLevel, SetTCIVolume, SetTCIMute, SetTCIAGC, SetTCISquelch, SetTCISquelchLevel, SetTCINB, SetTCINR, SetTCIANF, SetTCIAPF, SetTCIFilter, SetTCIRIT, SetTCIXIT, SetTCIRITOffset, SetTCIXITOffset, SetTCILock, SetTCITune, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; 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'; import { LevelRow } from '@/components/LevelRow'; type TCIState = { connected: boolean; device?: string; protocol?: string; drive: number; tune_drive: number; mic_level: number; tx_enabled: boolean; tx: boolean; tuning: boolean; volume: number; mute: boolean; agc?: string; squelch_on: boolean; squelch: number; nb: boolean; nr: boolean; anf: boolean; apf: boolean; filter_lo: number; filter_hi: number; rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean; smeter: number; modulations?: string[]; tx_power_w: number; tx_swr: number; }; const ZERO: TCIState = { connected: false, drive: 0, tune_drive: 0, mic_level: 0, tx_enabled: false, tx: false, tuning: false, volume: 0, mute: false, squelch_on: false, squelch: 0, nb: false, nr: false, anf: false, apf: false, filter_lo: 0, filter_hi: 0, rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0, tx_power_w: 0, tx_swr: 0, }; // 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. // // 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); } // edgesFor turns a width into the pair TCI wants: 0 to the width, and nothing // clever. // // It centred narrow filters on the CW note first — 250 became 575-825 — on the // reasoning that a CW filter should contain the note. That reasoning may even be // right for a radio, but it is not what the button says, and a button that does // not do what it says is worse than one that does something simple. 250 means // 0-250. The two edges are editable underneath for anything else. function edgesFor(w: number, _mode: string): { lo: number; hi: number } { return { lo: 0, hi: w }; } // dBm → S units. TCI reports a real signal level rather than a meter position, // which is the useful way round: S9 is -73 dBm by the IARU definition and every // S unit below it is 6 dB, so this is arithmetic rather than a calibration // table — nothing here is provisional the way the K3's meter reading is. function sParts(dbm: number): { s: number; over: number; label: string } { if (dbm === 0) return { s: 0, over: 0, label: '—' }; if (dbm >= -73) { const over = Math.round((dbm + 73) / 10) * 10; return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; } const s = Math.max(0, Math.min(9, Math.round(9 + (dbm + 73) / 6))); return { s, over: 0, label: `S${s}` }; } // The meter bar wants 0-100; -127 dBm is the bottom of the scale and -13 dBm // (S9+60) the top. function sBar(dbm: number): number { if (dbm === 0) return 0; return Math.max(0, Math.min(100, ((dbm + 127) / 114) * 100)); } function sSegColor(frac: number): string { return frac > 0.75 ? '#dc2626' : frac > 0.55 ? '#f59e0b' : '#16a34a'; } function Card({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) { return (
{title}
{children}
); } // One button shape for every on/off control, as on the other consoles. function Toggle({ label, on, off, onClick, title }: { label: string; on: boolean; off: boolean; onClick: () => void; title?: string; }) { return ( ); } function Row({ label, value, children }: { label: string; value: string; children: React.ReactNode }) { return (
{label} {value}
{children}
); } export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); const [freqHz, setFreqHz] = useState(0); const [mode, setMode] = useState(''); const [err, setErr] = useState(''); // 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. // 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>({}); const [, forceRender] = useState(0); const hold = (key: string, reported: T): T => { const h = holdRef.current[key]; 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, reported: any) => { holdRef.current[key] = { v, reported }; forceRender((n) => n + 1); }; useEffect(() => { let alive = true; const tick = async () => { try { const p: any = await GetTCIPanel(); if (!alive) return; setSt(p as TCIState); const cs: any = await GetCATState(); if (!alive) return; setFreqHz(Number(cs?.rx_freq_hz) || Number(cs?.freq_hz) || 0); setMode(String(cs?.mode || '')); } catch (e: any) { if (alive) setErr(String(e?.message ?? e)); } }; tick(); const id = window.setInterval(tick, 400); return () => { alive = false; window.clearInterval(id); }; }, []); const off = !st.connected; const call = (fn: () => Promise) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); }; const drive = hold('drive', st.drive); const tuneDrive = hold('tune_drive', st.tune_drive); 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, ritRef.current.hz); SetTCIRITOffset(v).catch(() => {}); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); return (
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles. Stretched across a wide window a console puts each slider a hand's width from its own label and stops reading as one instrument. */}
{/* VFO + identity */}
{st.device || 'SunSDR'} {st.protocol ? `· ${st.protocol}` : ''}
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
{st.tx && TX} {st.split && SPLIT} call(() => SetTCILock(!st.lock))} />
{off &&
{t('tcip.waiting')}
} {!!err &&
{err}
} {/* Meters. The S-meter while receiving, power and SWR while transmitting — the radio answers TX_POWER and TX_SWR only when it is keyed, so showing them the rest of the time would be showing the last thing that happened as if it were now. There is no temperature: the protocol has no such command, and a made-up figure on a transmitter is the kind somebody trusts. */} { if (st.tx || !onReportRST) return; onReportRST(sMeterRST(s.s, s.over, mode)); }} title={t('tcip.sMeterHint')} /> {(st.tx || st.tuning) && (
{/* 0 is "not measured yet", and it must not draw as a perfect match: an SWR of 1.0 on an antenna nobody has measured is the one reading an operator should not be handed. */} 0 ? Math.min(100, (st.tx_swr - 1) * 50) : 0} lo={0} hi={100} accent="#f59e0b" display={st.tx_swr > 0 ? st.tx_swr.toFixed(1) : '—'} segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
)}
{/* Transmit */} {/* One level per ROW, full width. Two half-width sliders side by side left each of them a couple of centimetres long — small enough that setting 15% took aim. */} { setHold('drive', v, st.drive); call(() => SetTCIDrive(v)); }} /> { setHold('tune_drive', v, st.tune_drive); call(() => SetTCITuneDrive(v)); }} />
{/* 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 them being in a menu somewhere. */} {!st.tx_enabled && !off && ( {t('tcip.txDisabled')} )}
{ setHold('mic', v, st.mic_level); call(() => SetTCIMicLevel(v)); }} />
{/* Receive */} {/* Both in the radio's OWN units — volume in dB, negative, and the squelch as a dBm threshold — so the numbers match the ones in ExpertSDR3's window rather than being percentages of something. */} { setHold('vol', v, st.volume); call(() => SetTCIVolume(v)); }} /> { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} />
{ setHold('nb', !nb, st.nb); call(() => SetTCINB(!nb)); }} /> { setHold('nr', !nr, st.nr); call(() => SetTCINR(!nr)); }} /> { 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) && ( { setHold('apf', !apf, st.apf); call(() => SetTCIAPF(!apf)); }} /> )} { setHold('sql_on', !sqlOn, st.squelch_on); call(() => SetTCISquelch(!sqlOn)); }} /> { setHold('mute', !muted, st.mute); call(() => SetTCIMute(!muted)); }} />
{t('tcip.agc')} {/* 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. */}
{['off', 'slow', 'med', 'fast'].map((m) => ( { setHold('agc', m, st.agc || ''); call(() => SetTCIAGC(m)); }} /> ))}
{t('tcip.filter')} {st.filter_lo}–{st.filter_hi} Hz
call(() => SetTCIFilter(v, st.filter_hi))} />
call(() => SetTCIFilter(st.filter_lo, v))} />
{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 // radio, and a button that only lights on our own numbers would // go dark for a filter that is plainly 500 Hz wide. const on = Math.abs((st.filter_hi - st.filter_lo) - w) <= 50; return ( call(() => SetTCIFilter(e.lo, e.hi))} /> ); })}
{/* 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. */}
{ setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }} onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} /> { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }} onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
); }