import { useEffect, useRef, useState } from 'react'; import { Radio, Power, Activity, AudioLines, SlidersHorizontal } from 'lucide-react'; import { GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU, SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt, SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna, SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, SetKenwoodRITOffset, SetKenwoodPanelMode, GetCATState, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { sMeterRST } from '@/lib/rst'; import { ShiftRow } from '@/components/ShiftRow'; import { MeterBar } from '@/components/MeterBar'; import { WheelRange } from '@/components/WheelRange'; type KenwoodState = { available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string; transmitting: boolean; split: boolean; split_tx_hz?: number; s_meter: number; s_meter_raw: number; power_meter: number; swr: number; swr_raw: number; rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number; preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string; filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number; meters_provisional: boolean; }; const ZERO: KenwoodState = { available: false, elecraft: false, transmitting: false, split: false, s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0, rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0, preamp: false, att: false, nb: false, nr: false, filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0, meters_provisional: true, }; // Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8 // kHz; the rest of the range is reachable from the radio's own knob, and a // panel offering thirty widths is slower to use than the knob it replaces. // 4.0k is the K3 maximum and the one FT8 wants: a 2.8 kHz filter clips the // top of the FT8 sub-band, and the decodes that go missing are the ones // nobody notices are missing. const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000]; // Raw S-meter → S units, CALIBRATED against a real K3 beside its own display // (2026-08): raw 5 reads S7 on the radio, raw 9 reads S9+20. So S9 sits near // raw 6.5 — not 9, which showed everything two S-units low — and each raw step // above it is worth ~8 dB, shown in the 10 dB steps the K3's own meter uses. // A Kenwood answers 0-30 on the same command and keeps the simple 1-per-raw // scale until someone calibrates one against a real radio too. const K3_S9_RAW = 6.5; const K3_DB_PER_RAW = 8; function sParts(rawV: number, elecraft: boolean): { s: number; over: number; label: string } { const s9raw = elecraft ? K3_S9_RAW : 9; if (rawV >= s9raw) { let over = Math.max(0, Math.round((rawV - s9raw) * (elecraft ? K3_DB_PER_RAW : 6))); if (elecraft) over = Math.round(over / 10) * 10; return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; } const s = Math.max(0, Math.min(9, Math.round(rawV * (elecraft ? 9 / K3_S9_RAW : 1)))); return { s, over: 0, label: `S${s}` }; } // Segment colour, printed the way a radio's own meter is: green up to S9, amber // through the S9+ range, red once the signal would be reported as 59+20 or more. function sSegColor(frac: number) { if (frac > 0.78) return '#dc2626'; if (frac > 0.55) return '#f59e0b'; return '#16a34a'; } // The card shell the Yaesu and Icom consoles use, so the three read alike. function Card({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) { return (
{title}
{children}
); } // One button shape for every on/off control, so the panel reads as one // instrument instead of a collection of differently-styled switches. function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off: boolean; onClick: () => void }) { return ( ); } // isDataMode: what the rig reports for MD6 varies — "DATA", or the configured // digital default (FT8…) — so the DATA family is "not one of the native modes". function isDataMode(m?: string): boolean { switch ((m ?? '').toUpperCase()) { case '': case 'CW': case 'USB': case 'LSB': case 'SSB': case 'FM': case 'AM': case 'RTTY': return false; } return true; } export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); // Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is on — the same // keyboard clarifier the Icom and TCI consoles have, for zero-beating a // caller without touching the mouse. const stRef = useRef(st); stRef.current = st; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return; const v = stRef.current; if (!v.available || !v.rit) return; e.preventDefault(); SetKenwoodRITOffset((v.rit_offset || 0) + (e.key === 'ArrowRight' ? 10 : -10)).catch(() => {}); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); const [freqHz, setFreqHz] = useState(0); const [err, setErr] = useState(''); // Optimistic overlay: a slider must follow the finger, not the poll. Dropped // once the radio has had time to answer with the value it actually took. const [local, setLocal] = useState<{ rf_power?: number; af_gain?: number; rf_gain?: number; mic_gain?: number; squelch?: number; key_speed?: number }>({}); const localAtRef = useRef(0); useEffect(() => { let alive = true; const tick = async () => { try { const s = (await GetKenwoodState()) as KenwoodState; const c = (await GetCATState()) as any; if (!alive) return; setSt(s); setFreqHz(c?.split && c?.freq_rx_hz > 0 ? c.freq_rx_hz : (c?.freq_hz ?? 0)); if (Date.now() - localAtRef.current > 1200) setLocal({}); setErr(''); } catch (e: any) { if (alive) setErr(String(e?.message ?? e)); } }; tick(); const id = window.setInterval(tick, 500); return () => { alive = false; window.clearInterval(id); }; }, []); const view = { ...st, ...local }; const off = !st.available; const setErrMsg = (e: any) => setErr(String(e?.message ?? e)); const put = (patch: typeof local, run: () => Promise) => { setLocal((p) => ({ ...p, ...patch })); localAtRef.current = Date.now(); run().catch((e) => setErr(String(e?.message ?? e))); }; return (
{/* Same wrapper as the Yaesu, Icom and Flex consoles: capped width and CENTRED. A console stretched across a 2000 px window puts a slider a hand's width from its own label, and the panel stops reading as one instrument. */}
{/* VFO + status */}
{st.elecraft ? 'Elecraft' : 'Kenwood'} {st.model}
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
{/* Mode row. The two DATA buttons are why it exists: MD6 alone keeps whatever submode the last session left, and a K3 "in DATA" with FSK D still armed keys FT8 with no audio. DATA sends MD6+DT0 (DATA A, the soundcard path), RTTY sends MD6+DT2 (FSK D). The active DATA button follows the rig's own DT answer. */}
{([ ['CW', 'CW', view.mode === 'CW'], ['USB', 'USB', view.mode === 'USB'], ['LSB', 'LSB', view.mode === 'LSB'], ['DATA', t('k3.modeData'), isDataMode(view.mode) && view.data_sub !== 'FSK D' && view.data_sub !== 'PSK D'], ['RTTY', t('k3.modeRtty'), (isDataMode(view.mode) && (view.data_sub === 'FSK D' || view.data_sub === 'PSK D')) || view.mode === 'RTTY'], ] as [string, string, boolean][]).map(([cmd, label, on]) => ( ))}
{off &&
{t('k3.waiting')}
} {!!err &&
{err}
} {/* Meters */}
{ if (view.transmitting || !onReportRST) return; const sp = sParts(view.s_meter_raw, view.elecraft); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); }} title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} /> {/* 0 means "not measured", and it must not render as a perfect 1.0: a match that looks ideal on an antenna nobody has measured is the one reading that can cost a radio. */} 0 ? view.swr : 1} lo={1} hi={4} accent="#f59e0b" display={view.transmitting && view.swr > 0 ? view.swr.toFixed(1) : '—'} />
{view.meters_provisional && (

{t('k3.provisional')}

)}
{/* MOX + TUNE */}
{/* The HOLD of the same switch: tuner in line or bypassed. Two buttons because they are two things on the radio — tuning is a cycle you start, bypassing is a state you leave it in. */}
{/* Levels — two columns, so a slider stays beside the label it belongs to instead of running the width of the window. */}
{/* CW keyer speed — the radio's own keyer, the one the K3 sends with. */}
{/* Receive chain. Toggles, because that is what they are on the radio: one press each, and the state comes back from the rig rather than from what the button was asked to do. */}
SetKenwoodPreamp(!view.preamp).catch(setErrMsg)} /> SetKenwoodAtt(!view.att).catch(setErrMsg)} /> SetKenwoodNB(!view.nb).catch(setErrMsg)} /> SetKenwoodNR(!view.nr).catch(setErrMsg)} /> {['OFF', 'SLOW', 'FAST'].map((a) => ( SetKenwoodAGC(a).catch(setErrMsg)} /> ))}
{/* RIT / XIT — the shared ShiftRow, exactly as the Icom and TCI consoles drive theirs: ± / wheel / type, Ctrl+←/→ while RIT is on. The K3 has ONE offset for both, so both rows show it, like the Icom's. */}
SetKenwoodRIT(!view.rit).catch(setErrMsg)} onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} /> SetKenwoodXIT(!view.xit).catch(setErrMsg)} onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
{/* Antenna only when the radio answered AN — a K3 without the internal ATU has one socket and no switch to offer. */} {view.antenna > 0 && (<> {t('k3.antenna')} {[1, 2].map((n) => ( SetKenwoodAntenna(n).catch(setErrMsg)} /> ))} )}
{/* Filter width */}
{t('k3.filter')} {FILTERS.map((hz) => ( = 1000 ? (hz / 1000).toFixed(1) + 'k' : String(hz)} on={view.filter_hz === hz} off={off} onClick={() => SetKenwoodFilter(hz).catch(setErrMsg)} /> ))} {view.filter_hz > 0 && ( {view.filter_hz} Hz )}
); }