import { useEffect, useRef, useState } from 'react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react'; import { GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexMox, FlexAmpOperate, GetPGXLStatus, PGXLSetFanMode, GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel, FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX, FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, } from '../../wailsjs/go/main/App'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { sMeterRST } from '@/lib/rst'; type FlexState = { available: boolean; model?: string; rf_power: number; tune_power: number; tune: boolean; transmitting: boolean; vox_enable: boolean; vox_level: number; vox_delay: number; proc_enable: boolean; proc_level: number; mon: boolean; mon_level: number; mic_level: number; atu_status?: string; atu_memories: boolean; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean; rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[]; split: boolean; rx_freq_hz?: number; tx_freq_hz?: number; rit: boolean; rit_freq: number; xit: boolean; xit_freq: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; wnb: boolean; wnb_level: number; // SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them. lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number; speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean; nrf?: boolean; nrf_level?: number; dsp_v4?: boolean; dax_ch?: number; tx_dax?: boolean; tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[]; mode?: string; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; apf: boolean; apf_level: number; filter_lo: number; filter_hi: number; amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string; meters?: Meter[]; slices?: FlexSlice[]; }; type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean }; type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number }; const ZERO: FlexState = { available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, mon: false, mon_level: 0, mic_level: 0, atu_memories: false, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false, rit: false, rit_freq: 0, xit: false, xit_freq: 0, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0, cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, amp_available: false, amp_operate: false, }; function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max = 100 }: { value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; max?: number; }) { const v = Math.max(0, Math.min(max, value)); const pct = max > 0 ? (v / max) * 100 : 0; 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; 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 d = e.deltaY < 0 ? stepRef.current : -stepRef.current; const nv = Math.max(0, Math.min(maxRef.current, 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 [&::-webkit-slider-thumb]:cursor-pointer')} style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }} /> ); } // Segmented — radio-style multi-choice (AGC, Processor preset). function Segmented({ value, options, onChange, disabled }: { value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; disabled?: boolean; }) { return (
{options.map((o) => ( ))}
); } // Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…). function Chip({ on, onClick, label, disabled, accent = 'emerald' }: { on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; }) { const onCls = { emerald: 'bg-success border-success text-success-foreground', violet: 'bg-info border-info text-info-foreground', cyan: 'bg-info border-info text-info-foreground', amber: 'bg-warning border-warning text-warning-foreground', }[accent]; return ( ); } function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: { label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string; }) { return (
{value}
); } // OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the // wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps). // Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are // driven identically. // // The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so // flipping RIT back on returns you exactly where you were, like the radio's own // knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for. const OFFSET_MAX = 99999; // SmartSDR's limit function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: { label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void; disabled?: boolean; title?: string; }) { const ref = useRef(null); const step = useRef((_d: number) => {}); step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d))); // React's onWheel is passive, so preventDefault() there is ignored and the panel // scrolls under the cursor instead of the value changing. Bind it natively. useEffect(() => { const el = ref.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const mag = e.ctrlKey || e.shiftKey ? 100 : 10; step.current(e.deltaY < 0 ? mag : -mag); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); const onKey = (e: React.KeyboardEvent) => { const up = e.key === 'ArrowUp' || e.key === 'ArrowRight'; const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft'; if (!up && !dn) return; e.preventDefault(); const mag = e.ctrlKey || e.shiftKey ? 100 : 10; step.current(up ? mag : -mag); }; return (
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz || 0)} Hz
); } // MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi. // `display` overrides the numeric readout; `segColor` colours segments by their // 0..1 position (zones); the top ~18% light red by default (overload/peak). const METER_SEGMENTS = 26; function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: { label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string; segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean; }) { const span = hi - lo; const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; const lit = Math.round((pct / 100) * METER_SEGMENTS); return (
{label} {display !== undefined ? display : ( <>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}{unit} )}
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
{Array.from({ length: METER_SEGMENTS }).map((_, i) => { const on = i < lit; const frac = i / METER_SEGMENTS; const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent); return (
); })}
{extra && !compact &&
{extra}
}
); } function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) { return (
{title}
{children}
); } // speMaxW is the amp's rated output, used as the power-bar full scale. Derived from // the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW). function speMaxW(model?: string): number { const m = (model || '').toUpperCase(); if (m.includes('20K') || m.includes('2K')) return 2000; if (m.includes('15K')) return 1500; return 1300; // 1.3K-FA default } // powerLevelLabel spells out the SPE power-level code (L/M/H) in full. function powerLevelLabel(pl?: string): string { switch ((pl || '').trim().toUpperCase()) { case 'L': return 'Low'; case 'M': return 'Mid'; case 'H': return 'High'; default: return pl || ''; } } // onCWSpeed (optional): notified when the operator changes CW speed here, so the // host can keep the WinKeyer (which actually sends the macros) in sync. export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); // Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT // block) collapse behind a button so the RECEIVE card doesn't grow tall — only // NB/NR/ANF stay visible by default. Remembered across sessions. const [dspOpen, setDspOpen] = useState(() => localStorage.getItem('opslog.flexDspOpen') === '1'); const toggleDsp = () => setDspOpen((o) => { const n = !o; localStorage.setItem('opslog.flexDspOpen', n ? '1' : '0'); return n; }); const hold = useRef>({}); // Keep the host's keyer speed (used for CW-length estimates and the keyer panel) // in step with the radio's ACTUAL CW speed — including when it's changed on the // radio / in SmartSDR, not just via the slider below. Notify only on a real change. const lastCwSpeed = useRef(null); useEffect(() => { const w = st.cw_speed; if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) { lastCwSpeed.current = w; onCWSpeed?.(w); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [st.cw_speed]); // Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters // read steadily instead of jumping every poll. const peak = useRef>({}); const peakHold = (key: string, val: number) => { const now = Date.now(); const p = peak.current[key]; if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } return p.v; }; useEffect(() => { let alive = true; const tick = async () => { try { const s = (await GetFlexState()) as any as FlexState; if (!alive) return; setSt((prev) => { const now = Date.now(); const merged: any = { ...s }; for (const k in hold.current) { if (hold.current[k] > now) merged[k] = (prev as any)[k]; } return merged as FlexState; }); } catch { /* not connected */ } }; tick(); const id = window.setInterval(tick, 400); return () => { alive = false; window.clearInterval(id); }; }, []); // PowerGenius XL direct connection (fan mode), independent of the Flex link. const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false }); useEffect(() => { let alive = true; const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} }; tick(); const id = window.setInterval(tick, 2000); return () => { alive = false; window.clearInterval(id); }; }, []); // Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops // run two SPEs in parallel). The card shows ONE at a time; the dropdown picks // which, and the choice is remembered per panel. const [ampList, setAmpList] = useState([]); const [ampSel, setAmpSel] = useState(() => localStorage.getItem('opslog.ampSel.flex') || ''); useEffect(() => { let alive = true; const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {}); tick(); const id = window.setInterval(tick, 1500); return () => { alive = false; window.clearInterval(id); }; }, []); const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0]; const isSPE = !!selAmp?.spe; const isACOM = !!selAmp?.acom; const spe = selAmp?.spe ?? { connected: false }; const acom = selAmp?.acom ?? { connected: false }; const ampPicker = ampList.length > 1 && selAmp ? ( ) : null; const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise) => { hold.current[key] = Date.now() + 900; setSt((p) => ({ ...p, [key]: val })); send().catch(() => {}); }; const off = !st.available; const rxOff = off || !st.rx_avail; // Radio-style global RIT tuning: Ctrl+←/→ nudges the RIT offset (Ctrl+Shift = // ±100 Hz) from ANYWHERE — including while the callsign/RST entry field has // focus, which is where the operator is while working a station. Only the RIT // offset row itself (data-offsetrow) is skipped, since it handles its own // arrows. Text-field word navigation via Ctrl+← / → is overridden only while // RIT is actually engaged (guarded below), which is fine mid-QSO. Requires RIT // on and the RX available. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!(e.ctrlKey || e.metaKey)) return; if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; const ae = document.activeElement as HTMLElement | null; if (ae && ae.hasAttribute('data-offsetrow')) return; if (!st.rit || rxOff) return; e.preventDefault(); const mag = e.shiftKey ? 100 : 10; const next = (st.rit_freq || 0) + (e.key === 'ArrowRight' ? mag : -mag); change('rit_freq', next, () => FlexSetRITFreq(next)); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [st.rit, st.rit_freq, rxOff]); // Clear the RIT offset back to 0 whenever a QSO is logged (any path: Log // button, keyer macro, WSJT-X), so the next station starts centred. Only acts // when there's actually a non-zero offset to clear. useEffect(() => { const off = EventsOn('qso:logged', () => { if (st.rit && (st.rit_freq || 0) !== 0) change('rit_freq', 0, () => FlexSetRITFreq(0)); }); return () => off(); }, [st.rit, st.rit_freq]); const isCW = (st.mode || '').toUpperCase().includes('CW'); const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }]; const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }]; const CW_BW = [100, 200, 300, 400, 500]; const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000]; const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0)); // Highlight the preset CLOSEST to the radio's actual filter width. The rig // rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as // 100–2790), so an exact/±50 match would leave nothing lit — "doesn't pick up // the current filter". Snapping to the nearest preset always reflects the rig. const ssbActiveBW = curBW > 0 ? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0]) : -1; // Compact header readouts for PA voltage + temperature (numbers, not bars) — the // user wanted these in the header to save vertical space in the Meters card. const hdrMeters = st.meters || []; const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP')); const voltM = hdrFind('13.8') || hdrFind('VOLT'); const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP'); return (
{/* Header strip */}
{st.model || 'FlexRadio'} {t('flxp.smartsdrRemote')}
{/* Compact PA voltage + temperature readouts (numbers) to save space. */} {!off && (voltM || patempM) && (
{voltM && ( {t('flxp.voltage')} {voltM.value.toFixed(1)}V )} {patempM && ( {t('flxp.paTemp')} = 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}°C )}
)} {!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
{/* Slices A/B/C/D — every in-use receiver. Click one to make it the ACTIVE slice: the main frequency, mode, DSP and spot-clicks all follow it. The active slice is highlighted; the TX slice is flagged. */} {!!st.slices && st.slices.length > 0 && (
{st.slices.map((sl) => ( ))}
)} {/* Live meters (UDP VITA-49 stream) — placed right under the slices. */} {(() => { const meters = st.meters || []; if (off || meters.length === 0) { return

{t('flxp.noMeters')}

; } const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || ''); const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10); // Radio meters (exclude the amplifier's, which we show separately). const radio = (name: string) => meters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP')); // Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the // one for the ACTIVE slice; otherwise we'd always show slice A's level. const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1; const sliceMeter = (name: string) => { const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP')); if (m.length === 0) return undefined; return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0]; }; const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL'); const fwd = radio('FWDPWR'); const swr = radio('SWR'); // Mic input level + speech-compression (voltage & PA temp live in the // header now). All already in the meter stream. const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP')); const comp = radio('COMPPEAK') || radio('COMP'); // S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit). const sUnit = (dbm: number) => { const sv = (dbm + 127) / 6; // S0 = -127 dBm if (sv >= 9) { const over = Math.max(0, Math.round(dbm + 73)); // dB over S9 return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over }; } return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 }; }; const cur = [ sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return ( onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined} segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} /> ); })(), fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return ( ); })(), swr && , // Mic input level in dBFS — SmartSDR's scale is -40…0 dB. mic && (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />, // Speech compression — original working meter, only the top of the // scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max). comp && , ].filter(Boolean); return (
{cur}
); })()}
{off && (
{t('flxp.waiting')}
)} {/* TX + RX columns */}
{/* TRANSMIT */}
{t('flxp.rfPower')} change('rf_power', v, () => FlexSetPower(v))} /> {st.rf_power}
{t('flxp.tunePwr')} change('tune_power', v, () => FlexSetTunePower(v))} /> {st.tune_power}
{st.split && !!st.tx_freq_hz && ( TX {(st.tx_freq_hz / 1e6).toFixed(3)} {!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`} )}
{!isCW ? (
change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} /> change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))} onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
{t('flxp.voxDly')} change('vox_delay', v, () => FlexSetVoxDelay(v))} /> {st.vox_delay}
change('mon', !st.mon, () => FlexSetMon(!st.mon))} onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
MIC change('mic_level', v, () => FlexSetMic(v))} /> {st.mic_level}
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on blur/Enter so we don't spam a command per keystroke; the hold guard keeps the poll from clobbering the field mid-edit. */}
{t('flxp.txFilter')} {(() => { const editTX = (patch: Partial) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); }; const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); }; const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40'; return (<> editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })} onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} /> editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })} onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} /> Hz ); })()}
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */} {!!st.mic_profiles && st.mic_profiles.length > 0 && (
{t('flxp.micProfile')}
)}
) : ( /* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
{t('flxp.speed')} { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} /> {st.cw_speed} wpm
{t('flxp.pitch')} change('cw_pitch', v, () => FlexSetCWPitch(v))} /> {st.cw_pitch} Hz
{t('flxp.delay')} change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} /> {st.cw_break_in_delay} ms
change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))} onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
)} {/* RIT/XIT live on the TRANSMIT side to balance the two columns — the RECEIVE card grew tall with the v4 DSP rows. */}
change('rit', !st.rit, () => FlexSetRIT(!st.rit))} onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} /> change('xit', !st.xit, () => FlexSetXIT(!st.xit))} onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
{/* RECEIVE */} {/* Antenna selection sits at the very top of the RX column. */} {((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
Ant
RX TX {/* DAX on/off — SmartSDR's transmit-bar DAX button (TX audio from DAX, e.g. WSJT-X). "transmit set dax=", not the slice channel. */}
)}
AGC change('agc_mode', v, () => FlexSetAGCMode(v))} />
AF change('audio_level', v, () => FlexSetAudioLevel(v))} /> {st.mute ? '—' : st.audio_level}
AGC-T change('agc_threshold', v, () => FlexSetAGCThreshold(v))} /> {st.agc_threshold}
{/* Core noise controls stay visible; WNB + the SmartSDR v4 DSP block hide behind the 'DSP' toggle so the card doesn't tower. When any hidden control is ON we badge the toggle so it's not forgotten. */} {(() => { const hiddenOn = st.wnb || (st.dsp_v4 && (!!st.lms_nr || !!st.speex_nr || !!st.nrf || (!isCW && !!st.lms_anf) || !!st.rnn || (!isCW && !!st.anft))); return (
{t('flxp.dspNoise')}
); })()} change('nb', !st.nb, () => FlexSetNB(!st.nb))} onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} /> change('nr', !st.nr, () => FlexSetNR(!st.nr))} onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} /> {/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */} {!isCW && ( change('anf', !st.anf, () => FlexSetANF(!st.anf))} onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> )} {/* Advanced / supplementary DSP — collapsed by default. WNB (wideband noise blanker) plus the SmartSDR v4 block (8000/Aurora): NRL/ANFL (legacy LMS), NRS (spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT notch). The v4 rows only exist when the radio reports those slice keys (older 6000s never do). */} {dspOpen && (
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */} change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))} onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} /> {st.dsp_v4 && ( <> change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))} onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} /> change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))} onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} /> change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))} onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} /> {/* Notch filters target carriers in voice — hidden in CW like ANF. */} {!isCW && ( change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))} onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} /> )} {/* RNN and ANFT are on/off only — no level in the API. */}
AI/FFT {!isCW && ( )}
)}
)}
{isCW && (
change('apf', !st.apf, () => FlexSetAPF(!st.apf))} onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
{t('flxp.filter')}
{CW_BW.map((bw) => ( ))}
Hz
)} {!isCW && (
{t('flxp.filter')}
{SSB_BW.map((bw) => ( ))}
kHz
)}
{/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp. The Flex doesn't report SPE amps, so this card is driven by OpsLog's own SPE link rather than the Flex amplifier object. */} {isSPE && (
{ampPicker} {/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
{/* Output power level: Low / Mid / High. */}
{(['L', 'M', 'H'] as const).map((lvl, i) => { const active = (spe.power_level || '').trim().toUpperCase() === lvl; return ( ); })}
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')} {spe.connected && ( {spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)} )}
{(spe.warnings || spe.alarms) && ( ⚠ {spe.warnings} {spe.alarms} )}
{spe.connected && ( (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> )} )} {/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */} {isACOM && (
{ampPicker} {/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp is off (the port stays open), so gate on port_open not connected. */}
{acom.connected ? acom.state : t('flxp.acomOffline')} {acom.connected && ( {acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan} )}
{acom.err_text && ( ⚠ {acom.err_text} )}
{acom.connected && ( (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> )} )} {/* External amplifier (PowerGenius XL) — only when the Flex reports one AND PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides this Flex-reported card so two amps don't both show. */} {st.amp_available && !isSPE && !isACOM && (
{ampPicker} {st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')} {/* Fan mode — shown when the PowerGenius is configured (Settings → PowerGenius). The dot shows the direct-connection state; the selector is disabled until connected (hover it for the error). */} {selAmp?.type === 'pgxl' && (pg.host || pg.connected) && ( )}
{st.amp_fault && st.amp_fault !== 'NONE' && ( {t('flxp.fault')}: {st.amp_fault} )}
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card and shown compact so they sit with the amp controls. */} {(() => { const meters = st.meters || []; const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10); const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') && !/^(RL|DRV)$/i.test((m.name || '').trim())); if (off || amp.length === 0) return null; return (
{amp.map((m) => { if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) { return ; } const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a'; // Drain current (ID): the PGXL reports a full-scale far too small // for this meter (~3 A), so 1.5 A pinned the bar near half. The // value itself is fine — only the bar's range was wrong. Give it a // fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and // only override an unusable reported range, not a sane one. let lo = m.lo, hi = m.hi; if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) { lo = 0; hi = m.hi >= 25 ? m.hi : 25; } return ; })}
); })()} )}
); }