import { useEffect, useRef, useState } from 'react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react'; import { GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexMox, FlexAmpOperate, GetPGXLStatus, PGXLSetFanMode, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, } from '../../wailsjs/go/main/App'; 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; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; wnb: boolean; wnb_level: number; 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, 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}
); } // 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}
); } // 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); const hold = useRef>({}); // 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); }; }, []); 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; 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: fixed -40..+10 dB scale, RED from 0 dB up (overdrive). mic && (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />, // Speech compression (dB of gain reduction). comp && 0) ? comp.hi : 25} accent="#0891b2" />, ].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))} />
)}
{/* RECEIVE */} {((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
Ant
RX TX
)}
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}
change('nb', !st.nb, () => FlexSetNB(!st.nb))} onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} /> {/* 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))} /> 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))} /> )}
{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
)}
{/* External amplifier (PowerGenius XL) — only when detected. */} {st.amp_available && (
{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). */} {(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'; return ; })}
); })()} )}
); }