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, NudgeKenwoodRIT, GetCATState, } 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'; type KenwoodState = { available: boolean; model?: string; elecraft: boolean; mode?: 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. The K3 answers 0-21 across S0…S9+60; S9 is taken at // raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the // scaling: the raw value is on screen and in the log, so a real radio settles // it rather than this comment. const S9_RAW = 9; const DB_PER_RAW = 6; function sParts(rawV: number): { s: number; over: number; label: string } { if (rawV >= S9_RAW) { const over = Math.max(0, Math.round((rawV - S9_RAW) * DB_PER_RAW)); return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; } const s = Math.max(0, Math.min(9, rawV)); 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 ( ); } export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); 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) : '—'}
{off &&
{t('k3.waiting')}
} {!!err &&
{err}
} {/* Meters */}
{ if (view.transmitting || !onReportRST) return; const sp = sParts(view.s_meter_raw); 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. Clear zeroes both offsets at once — the radio's own RC, and what an operator means by "clear it". */}
SetKenwoodRIT(!view.rit).catch(setErrMsg)} /> SetKenwoodXIT(!view.xit).catch(setErrMsg)} /> {/* The offset itself. A lit RIT button says the feature is on and nothing about where it has put the receiver. */} {[-100, -10, 10, 100].map((d) => ( 0 ? '+' : '') + d} on={false} off={off} onClick={() => NudgeKenwoodRIT(d).catch(setErrMsg)} /> ))} {view.rit_offset > 0 ? '+' : ''}{view.rit_offset || 0} Hz ClearKenwoodRIT().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 )}
); }