import { useEffect, useRef, useState } from 'react'; import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react'; import { GetYaesuState, RefreshYaesuPanel, SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch, SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel, SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU, SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, 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'; type YaesuState = { available: boolean; model?: string; mode?: string; raw_mode?: string; transmitting: boolean; split: boolean; s_meter: number; power_meter: number; swr_meter: number; rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number; agc?: string; preamp: number; att: number; nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean; split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number; }; const ZERO: YaesuState = { available: false, transmitting: false, split: false, s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0, preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false, }; // Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we // choose: pressing 20 m lands where the operator last was on 20 m, which is what // the radio's own band keys do. That is why these are band names, not Hz. const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m']; // Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and // the operator is the one who knows which they want, so each shows its sideband // and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button, // one finger, no hidden gesture. SSB takes its sideband from the frequency, as // the band plan dictates, and AM/FM have none. // // PSK rides on the rig's DATA mode, like the other digital modes — the button // exists because the operator thinks in modes, not in what the radio calls them. type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string }; const MODES: ModeBtn[] = [ { id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' }, { id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s }, { id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s }, { id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s }, { id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s }, { id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' }, { id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' }, ]; // Which button the rig's current raw mode belongs to, and on which sideband. function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null { switch ((raw || '').toUpperCase()) { case 'USB': return { id: 'SSB', side: 'U' }; case 'LSB': return { id: 'SSB', side: 'L' }; case 'CW-U': return { id: 'CW', side: 'U' }; case 'CW-L': return { id: 'CW', side: 'L' }; case 'RTTY-U': return { id: 'RTTY', side: 'U' }; case 'RTTY-L': return { id: 'RTTY', side: 'L' }; case 'DATA-U': return { id: 'DIGI', side: 'U' }; case 'DATA-L': return { id: 'DIGI', side: 'L' }; case 'AM': return { id: 'AM', side: 'U' }; case 'FM': return { id: 'FM', side: 'U' }; } return null; } // The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off: // IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and // AMP2 add gain. Presenting it as a toggle would hide the middle position. const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }]; const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }]; // The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle. const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]; function fmtVFO(hz?: number): string { if (!hz || hz <= 0) return '––.–––.––'; const mhz = Math.floor(hz / 1_000_000); const khz = Math.floor((hz % 1_000_000) / 1000); const h2 = Math.floor((hz % 1000) / 10); return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`; } function bandOfHz(hz?: number): string { if (!hz || hz <= 0) return ''; const mhz = hz / 1_000_000; const bands: [string, number, number][] = [ ['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3], ['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168], ['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0], ]; for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name; return ''; } // Split the 0-100 S-meter reading into S units + dB over S9. // // The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9 // falls; the front panel puts it at roughly half travel, which is the 50 used // here. That figure is a HYPOTHESIS — if reports come out consistently one S // unit off on a radio, this is the number to correct, not the RST helper. const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above) const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB function sParts(v: number): { s: number; over: number; label: string } { if (v >= S9_PCT) { const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT)); return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; } const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9)))); return { s, over: 0, label: `S${s}` }; } // Segment colour, the way a radio's own meter is printed: green up to S9, amber // through the S9+ range, red once the signal is strong enough to be reported as // 59+20 or more. Derived from the SAME S9 point as the label, so the colour // change always lands exactly where the numbers say it should — if the S9 point // is ever corrected, the colours follow on their own. const RED_OVER_DB = 20; function sSegColor(frac: number): string { const pct = frac * 100; if (pct < S9_PCT) return '#16a34a'; if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b'; return '#dc2626'; } function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: { value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number; }) { const v = Math.max(min, Math.min(max, value)); const pct = max > min ? ((v - min) / (max - min)) * 100 : 0; const ref = useRef(null); // React's onWheel is passive, so preventDefault is ignored there — attach a // native non-passive listener, and read live values through refs so the // handler never closes over a stale value. const valRef = useRef(value); valRef.current = value; const cbRef = useRef(onChange); cbRef.current = onChange; const disRef = useRef(disabled); disRef.current = disabled; const minRef = useRef(min); minRef.current = min; const maxRef = useRef(max); maxRef.current = max; useEffect(() => { const el = ref.current; if (!el) return; const onWheel = (e: WheelEvent) => { if (disRef.current) return; e.preventDefault(); const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1))); if (nv !== valRef.current) cbRef.current(nv); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); return ( onChange(parseInt(e.target.value, 10))} className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default', '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full', '[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow', '[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')} // The filled side was invisible against the dark theme: --muted is barely // lighter than the card it sits on, so the whole track read as one bar. // An explicit translucent track keeps both halves distinct in either theme. style={{ background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`, borderColor: accent, }} /> ); } function Segmented({ value, options, onChange }: { value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; }) { return (
{options.map((o) => ( ))}
); } function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) { return ( ); } function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) { return (
{title}
{children}
); } function Row({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } export function YaesuPanel({ onReportRST, onKeySpeed }: { onReportRST?: (rst: string) => void; // Told whenever the operator moves the CW speed here, so the app can keep the // keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does // the timing and the rig's internal keyer speed changes nothing audible — the // slider looked broken because it was driving the wrong keyer. onKeySpeed?: (wpm: number) => void; }) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); // The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the // TRANSMIT frequency — under split that is the other VFO, so taking it as the // main display showed the operator the frequency they transmit on and an // offset of 0 kHz against itself. const [freqHz, setFreqHz] = useState(0); const [txHz, setTxHz] = useState(0); const [err, setErr] = useState(''); // Optimistic local values for the sliders. Without them a drag fights the // poll: the rig's older reading arrives mid-gesture and yanks the thumb back. const [local, setLocal] = useState>({}); const localAtRef = useRef(0); useEffect(() => { let alive = true; const tick = async () => { try { const s = (await GetYaesuState()) as YaesuState; const c = await GetCATState(); if (!alive) return; setSt(s); const cs = c as any; const tx = cs?.freq_hz ?? 0; const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx; setFreqHz(rx); setTxHz(tx); // Drop the optimistic overlay once the rig has had time to answer with // the new value — 1.2 s covers the slow-beat settings read. if (Date.now() - localAtRef.current > 1200) setLocal({}); setErr(''); } catch (e: any) { if (alive) setErr(String(e?.message ?? e)); } }; tick(); const id = window.setInterval(tick, 400); return () => { alive = false; window.clearInterval(id); }; }, []); const view = { ...st, ...local }; // Every setter follows the same shape: show the value at once, remember when, // and let the poll take over. A rejected command surfaces as an error rather // than as a control that silently springs back. function push(key: K, value: YaesuState[K], fn: () => Promise) { setLocal((l) => ({ ...l, [key]: value })); localAtRef.current = Date.now(); fn().catch((e) => setErr(String(e?.message ?? e))); } const band = bandOfHz(freqHz); // CW changes what belongs on the panel: no microphone, no VOX, but a keyer // speed, break-in and ZIN. Driven by the RIG's mode, not the logged one. const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW'); if (!st.available) { return (

{t('yaesu.notConnected')}

{err &&

{err}

}
); } return (
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping it without mx-auto left the console pinned to the left edge with a window of empty space beside it. */}
{/* VFO + status */}
{st.model || 'Yaesu'}
{fmtVFO(freqHz)}
{view.split && txHz > 0 && (
{t('yaesu.txOn')} {fmtVFO(txHz)} {freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
)}
{st.transmitting && ( TX )} push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" /> {/* The usual pile-up offsets. Which one is idiomatic depends on the mode — up 5 on phone, up 1 on CW — so both are offered rather than guessed, and each turns split on in the same action. */}
{err &&

{err}

} {/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three consoles read alike instead of each having its own instrument style. */}
{ const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined} title={onReportRST ? t('yaesu.sToRst') : undefined} /> {/* Watts as MEASURED, not the power setting scaled by a percentage: the setting says what was asked for, the meter says what left. */} {/* The RATIO, as the rig shows it — a percentage of meter travel is not something an operator can act on. The bar keeps the travel. */} = 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
{/* Bands + modes */}
{BANDS.map((b) => ( ))}
{MODES.map((m) => { const act = activeMode(view.raw_mode); const on = act?.id === m.id; // The sideband shown is the rig's when this mode is active, else the // one the band plan implies — so a button says what pressing it will // actually do rather than a stale letter. const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U'); const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U'; return ( ); })}
{/* Receive */} push('af_gain', v, () => SetYaesuAFGain(v))} /> {view.af_gain} push('rf_gain', v, () => SetYaesuRFGain(v))} /> {view.rf_gain} push('squelch', v, () => SetYaesuSquelch(v))} /> {view.squelch} push('agc', v, () => SetYaesuAGC(v))} /> push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} /> push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} /> {/* Noise + filter */}
push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" /> push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" /> push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage: the radio's own display counts 1-15, and matching it is what makes the panel readable next to the front panel. */} push('nr_level', v, () => SetYaesuNRLevel(v))} /> {view.nr_level || 1}
{/* Transmit */} {/* Watts, not a percentage: the rig reports and takes watts, and a percentage would be a second unit to reconcile every time. */} push('rf_power', v, () => SetYaesuPower(v))} /> {view.rf_power}W {/* Microphone gain and VOX are meaningless in CW — the rig ignores both — so they are hidden rather than shown as dead controls. */} {!isCW && ( <> push('mic_gain', v, () => SetYaesuMicGain(v))} /> {view.mic_gain} push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" /> )} {/* CW — only in CW, where these replace the phone controls above. */} {isCW && ( { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} /> {view.key_speed || 20} wpm
push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))} label="BK-IN" title={t('yaesu.breakInHint')} /> {/* ZIN is a one-shot: the rig retunes so the station being received lands on the operator's own CW pitch. Not a toggle, so it is a plain button rather than a chip that would look latched. */}
)}
); }