import { useEffect, useRef, useState } from 'react'; import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react'; import { GetIcomState, IcomRefresh, IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel, IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter, IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT, IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode, IcomSetRIT, IcomSetRITOn, IcomSetXITOn, IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos, IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel, IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { sMeterRST } from '@/lib/rst'; type IcomState = { available: boolean; model?: string; 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; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean; agc?: string; preamp: number; att: number; filter: number; rit_hz: number; rit_on: boolean; xit_on: boolean; antenna: number; pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number; squelch: number; comp: boolean; comp_level: number; monitor: boolean; mon_level: number; vox: boolean; vox_gain: number; anti_vox: number; }; const ZERO: IcomState = { 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, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, apf: false, preamp: 0, att: 0, filter: 1, rit_hz: 0, rit_on: false, xit_on: false, antenna: 1, pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50, squelch: 0, comp: false, comp_level: 0, monitor: false, mon_level: 0, vox: false, vox_gain: 0, anti_vox: 0, }; // Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using // the plain SetFrequency command — no band-stacking codes needed. Hz values. const BANDS: { l: string; hz: number }[] = [ { l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 }, { l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 }, { l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 }, { l: '6', hz: 50_150_000 }, ]; // Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for // SSB by frequency and the rig's data variant for digital modes. const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM']; // Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the // same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18 // dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the // IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs // e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models. function attOptions(model?: string): { v: string; l: string }[] { const m = (model ?? '').toUpperCase(); const OFF = { v: '0', l: 'OFF' }; if (/(7610|7700|7800|7850|7851)/.test(m)) { return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]; } if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }]; return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default } // fmtVFO renders a Hz frequency the way an Icom front panel does: // MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00". 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')}`; } // modeMatches marks a mode button active, folding the rig's USB/LSB into SSB. function modeMatches(btn: string, cur?: string): boolean { if (!cur) return false; if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB'; return btn === cur; } function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: { value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; }) { const v = Math.max(0, Math.min(100, value)); const ref = useRef(null); // Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault // is ignored), so attach a non-passive native listener; read live values via // refs to avoid stale closures. const valRef = useRef(value); valRef.current = value; const cbRef = useRef(onChange); cbRef.current = onChange; const disRef = useRef(disabled); disRef.current = disabled; const stepRef = useRef(step); stepRef.current = step; useEffect(() => { const el = ref.current; if (!el) return; const onWheel = (e: WheelEvent) => { if (disRef.current) return; e.preventDefault(); const d = e.deltaY < 0 ? stepRef.current : -stepRef.current; const nv = Math.max(0, Math.min(100, valRef.current + d)); 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-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default', '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full', '[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')} style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, 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 }: { on: boolean; onClick: () => void; label: string }) { return ( ); } function LevelRow({ label, on, onToggle, value, onLevel }: { label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void; }) { return (
{value}
); } 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}
); } // Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR). // Optional onClick makes the row a button (used to send the S reading to RST tx). function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: string; onClick?: () => void; title?: string }) { const v = Math.max(0, Math.min(100, value)); const body = ( <> {label}
{scale ?? v} ); if (onClick) { return ; } return
{body}
; } // ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed // offset (±10 Hz per notch or per ± button) + a clear (0) button. function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: { label: string; on: boolean; hz: number; accent: string; onToggle: () => void; onDelta: (d: number) => void; onClear: () => void; }) { const ref = useRef(null); const cb = useRef(onDelta); cb.current = onDelta; useEffect(() => { const el = ref.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); return (
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
); } // sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the // CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and // the RST-tx value on click. function sParts(v: number): { s: number; over: number; label: string } { if (v >= 47) { const over = Math.max(0, Math.round((v - 47) * 60 / 47)); return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; } const s = Math.max(0, Math.min(9, Math.round(v / 5.2))); return { s, over: 0, label: `S${s}` }; } // wfColor maps a 0-1 amplitude to a classic waterfall colour ramp // (near-black → blue → cyan → green → amber → red). const WF_STOPS: [number, [number, number, number]][] = [ [0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]], [0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]], ]; function wfColor(v: number): [number, number, number] { v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue for (let i = 1; i < WF_STOPS.length; i++) { if (v <= WF_STOPS[i][0]) { const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i]; const f = (v - a) / (b - a || 1); return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)]; } } return WF_STOPS[WF_STOPS.length - 1][1]; } // ScopePanadapter — enables the rig's spectrum-scope stream and draws the // reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace // on top and a scrolling colour waterfall below. Amplitudes are raw rig scale // (~0-160), normalised to the tallest recent peak so the trace fills the height. function ScopePanadapter() { const { t } = useI18n(); const [on, setOn] = useState(false); const [fixed, setFixed] = useState(true); const canvasRef = useRef(null); const wfRef = useRef(null); // waterfall const peakRef = useRef(160); // running amplitude ceiling for auto-scale const holdRef = useRef([]); // per-bin peak-hold line const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶) const toggle = () => { const next = !on; setOn(next); IcomSetScope(next).catch(() => {}); }; // Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz // window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This // just writes the rig's fixed edges — simple and independent of the waveform // decode. const SCOPE_HALF = 50_000; const applyEdges = (center: number) => { if (center <= 0) return; centerRef.current = center; setFixed(true); IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {}); }; const centerOnVfo = async () => { let c = vfoRef.current; if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} } applyEdges(c); }; const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF); const setMode = (nextFixed: boolean) => { setFixed(nextFixed); IcomSetScopeMode(nextFixed).catch(() => {}); }; // Stop the stream when the panel unmounts. useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []); useEffect(() => { if (!on) return; let raf = 0, lastSeq = -1, alive = true; const tick = async () => { if (!alive) return; try { const sw = await IcomScopeData(); if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) { lastSeq = sw.seq; setFixed(sw.fixed); spanRef.current = { low: sw.low_hz, high: sw.high_hz }; let vfo = 0; try { const cs = await GetCATState(); vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0); } catch {} if (vfo > 0) vfoRef.current = vfo; draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed); } } catch {} if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number; }; raf = requestAnimationFrame(tick); return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [on]); // Double-click tunes the rig to the clicked frequency. const onDblClick = (e: React.MouseEvent) => { const cv = canvasRef.current; const { low, high } = spanRef.current; if (!cv || !(low > 0 && high > low)) return; const rect = cv.getBoundingClientRect(); const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz SetCATFrequency(hz).catch(() => {}); }; // Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can // preventDefault (else the page scrolls); optimistic vfoRef so quick spins // accumulate before the poll reconciles. useEffect(() => { const el = canvasRef.current; if (!el || !on) return; const onWheel = (e: WheelEvent) => { if (!vfoRef.current) return; e.preventDefault(); const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100); vfoRef.current = next; SetCATFrequency(next).catch(() => {}); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [on]); const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => { const cv = canvasRef.current; if (!cv) return; const dpr = window.devicePixelRatio || 1; const w = cv.clientWidth, h = cv.clientHeight; if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; } const ctx = cv.getContext('2d'); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Auto-scale: track the peak, decaying slowly so the floor doesn't jump. const peak = Math.max(...amp); peakRef.current = Math.max(peak, peakRef.current * 0.95, 40); const scale = peakRef.current; const n = amp.length; // Background — deep navy vertical gradient. const bg = ctx.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e'); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); // Grid. ctx.strokeStyle = 'rgba(120,150,200,0.08)'; ctx.lineWidth = 1; for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } const xOf = (i: number) => (i / (n - 1)) * w; const yOf = (v: number) => h - Math.min(1, v / scale) * h; // Peak-hold line (slow decay) — a faint ghost of recent maxima. const hold = holdRef.current; if (hold.length !== n) hold.length = n, hold.fill(0); for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92); // Filled spectrum area. ctx.beginPath(); ctx.moveTo(0, h); for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i])); ctx.lineTo(w, h); ctx.closePath(); const grad = ctx.createLinearGradient(0, 0, 0, h); grad.addColorStop(0, 'rgba(56,189,248,0.40)'); grad.addColorStop(1, 'rgba(56,189,248,0.02)'); ctx.fillStyle = grad; ctx.fill(); // Peak-hold trace (thin, faint). ctx.beginPath(); for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); // Live spectrum trace with a soft glow. ctx.save(); ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6; ctx.beginPath(); for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke(); ctx.restore(); // VFO marker: you should ALWAYS see where you are. Exact position when the VFO // is inside the span; the centre in CTR mode; clamped to the nearest edge with // a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so // you can tell which way to tune to get it back on-screen). const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz; const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz; let markerX = -1; let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w; else if (!fixedMode) markerX = w / 2; else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; } if (markerX >= 0) { const x = markerX; ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h); ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); ctx.fillStyle = 'rgba(244,63,94,0.95)'; if (offEdge === 0) { ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill(); } else { const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill(); } } // Frequency scale. In fixed mode the rig reports usable edge frequencies, so // we label low/centre/high from them. In centre mode the header frame's edge // pair isn't a usable low..high range, but the scope is centred on the VFO — // so we always label the centre with the live VFO frequency (which we fetch // each sweep), and only add edge labels when the reported edges genuinely // bracket the VFO. That guarantees you always see your frequency in CTR. const mhz = (hz: number) => (hz / 1e6).toFixed(3); ctx.font = '10px ui-monospace, monospace'; ctx.textBaseline = 'bottom'; ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3; ctx.fillStyle = 'rgba(226,232,240,0.85)'; const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); }; const validEdges = lowHz > 0 && highHz > lowHz; if (fixedMode) { if (validEdges) { label(mhz(lowHz), 4, 'left'); label(mhz((lowHz + highHz) / 2), w / 2, 'center'); label(mhz(highHz), w - 4, 'right'); } } else { if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) { label(mhz(lowHz), 4, 'left'); label(mhz(highHz), w - 4, 'right'); } if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center'); } ctx.shadowBlur = 0; drawWaterfall(amp, scale); }; // drawWaterfall scrolls the history down one row and paints the newest sweep // as a colour-mapped line at the top. const drawWaterfall = (amp: number[], scale: number) => { const cv = wfRef.current; if (!cv) return; const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight); if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; } const ctx = cv.getContext('2d'); if (!ctx) return; // Scroll everything down by one pixel row. ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1); // Paint the new top row. const row = ctx.createImageData(w, 1); const n = amp.length; for (let x = 0; x < w; x++) { const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1))); const [r, g, b] = wfColor(amp[i] / scale); const o = x * 4; row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255; } ctx.putImageData(row, 0, 0); }; // Collapsible card: when the scope is off, only the header band shows (the // canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX // and ON/OFF controls live in the header itself. return (
{t('icmp.spectrum')}
{on && (
)} {on && ( setMode(v === 'FIX')} /> )}
{on && (
)}
); } // IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend. // Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are // read every cache cycle; DSP set-controls are optimistic and reconcile on the // next poll. Front-panel knob changes for DSP show after ↻ Refresh. export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); const [cat, setCat] = useState(null); // RigState (freq/mode/split) for the VFO display const [tuning, setTuning] = useState(false); const txRef = useRef(false); const stRef = useRef(ZERO); stRef.current = st; const load = () => { GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {}); GetCATState().then((c) => setCat(c ?? null)).catch(() => {}); }; const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); }; // Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only // re-reads the cache; the backend also loads DSP on the first responsive read). const refresh = async () => { try { await IcomRefresh(); } catch {} await load(); }; useEffect(() => { refresh(); const id = window.setInterval(load, 500); // fast poll so meters/TX feel live return () => window.clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Optimistic local update + fire the command; the cache poll reconciles. const set = (patch: Partial, fn: () => Promise) => { setSt((s) => ({ ...s, ...patch })); fn().catch(() => {}); }; const toggleMox = () => { const next = !txRef.current; txRef.current = next; set({ transmitting: next }, () => IcomSetPTT(next)); }; const tune = async () => { setTuning(true); try { await IcomTune(); } catch {} window.setTimeout(() => setTuning(false), 4000); }; // RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls. const setRit = (hz: number) => { const v = Math.max(-9999, Math.min(9999, hz)); set({ rit_hz: v }, () => IcomSetRIT(v)); }; // Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard // clarifier for zero-beating a caller without touching the mouse. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return; const s = stRef.current; if (!s.available || !s.rit_on) return; e.preventDefault(); setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10)); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (!st.available) { return (
{t('icmp.notConnected')}
); } const tx = st.transmitting; // VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the // other is TX (freq_hz); otherwise there's a single VFO (freq_hz). const split = !!cat?.split; const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0); const subHz: number = split ? (cat?.freq_hz || 0) : 0; const curMode: string = cat?.mode || st.mode || ''; // Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on // CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone. const um = curMode.toUpperCase(); const isCW = um === 'CW' || um === 'CWR'; const isPhone = um === 'SSB' || um === 'USB' || um === 'LSB' || um === 'AM' || um === 'FM'; return (
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
{tx ? 'TX' : 'RX'} {st.model || 'Icom'} {st.mode ? {st.mode} : null} {st.split ? Split : null}
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is unpowered while the rig is off, so power-ON can't reach it (OFF works but ON doesn't); hiding both avoids a dead button. On the network the rig's LAN server stays alive in standby, so both work. */} {isNetwork && ( <> )}
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
{/* MAIN VFO */}
{tx ? 'Main · TX' : 'Main'} {curMode ? {curMode} : null}
{fmtVFO(mainHz)}
{cat?.band || (mainHz ? '' : '—')} {st.rit_on ? RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)} : null} {st.xit_on ? ΔTX : null}
{/* SUB VFO (populated in split; dimmed otherwise) */}
Sub {split ? SPLIT : null}
{fmtVFO(subHz)}
{split ? 'TX' : ''}
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
{MODES.map((m) => { const on = modeMatches(m, curMode); return ( ); })}
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
{(() => { const sp = sParts(st.s_meter); return ( onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} /> ); })()} 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
{/* Spectrum panadapter (full width). */}
{/* Band buttons + antenna selection. */}
{BANDS.map((b) => ( ))}
set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */} set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))} onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} /> set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))} onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />

{t('icmp.ritHint')}

{/* Transmit controls. */} set({ rf_power: v }, () => IcomSetRFPower(v))} /> {st.rf_power} {isPhone && ( set({ mic_gain: v }, () => IcomSetMicGain(v))} /> {st.mic_gain} )}
set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
{/* Monitor (all modes) + speech processor / VOX (voice modes only — they don't exist on CW or data). */}
set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))} onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} /> {isPhone && ( <> set({ comp: !st.comp }, () => IcomSetComp(!st.comp))} onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} /> set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))} onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} /> set({ anti_vox: v }, () => IcomSetAntiVOX(v))} /> {st.anti_vox} )}
set({ af_gain: v }, () => IcomSetAFGain(v))} /> {st.af_gain} set({ rf_gain: v }, () => IcomSetRFGain(v))} /> {st.rf_gain} set({ squelch: v }, () => IcomSetSquelch(v))} /> {st.squelch} set({ agc: v }, () => IcomSetAGC(v))} /> set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} /> set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} /> set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} /> {/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */} set({ pbt_inner: v }, () => IcomSetPBTInner(v))} /> {st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50} set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} /> {st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}
set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} /> set({ notch_pos: v }, () => IcomSetNotchPos(v))} /> {st.notch_pos}

{t('icmp.manualNotch')}

set({ nb: !st.nb }, () => IcomSetNB(!st.nb))} onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} /> set({ nr: !st.nr }, () => IcomSetNR(!st.nr))} onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} /> {t('icmp.autoNotch')}
{/* APF (audio peak filter) — CW only: peaks the CW tone. */} {isCW && (
set({ apf: !st.apf }, () => IcomSetAPF(!st.apf))} /> {t('icmp.apf')}
)}
); }