590 lines
36 KiB
TypeScript
590 lines
36 KiB
TypeScript
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,
|
||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||
} from '../../wailsjs/go/main/App';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
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;
|
||
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[];
|
||
};
|
||
|
||
type Meter = { id: number; src?: string; name?: string; unit?: string; 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,
|
||
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<HTMLInputElement>(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 (
|
||
<input
|
||
ref={ref}
|
||
type="range" min={0} max={max} value={v} disabled={disabled}
|
||
onChange={(e) => 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-white [&::-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 (
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||
{options.map((o) => (
|
||
<button key={o.v} type="button" disabled={disabled} onClick={() => onChange(o.v)}
|
||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||
{o.l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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-emerald-600 border-emerald-600 text-white',
|
||
violet: 'bg-violet-600 border-violet-600 text-white',
|
||
cyan: 'bg-cyan-600 border-cyan-600 text-white',
|
||
amber: 'bg-amber-500 border-amber-500 text-white',
|
||
}[accent];
|
||
return (
|
||
<button type="button" onClick={onClick} disabled={disabled}
|
||
className={cn(
|
||
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
|
||
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex items-center gap-2">
|
||
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent={accent} />
|
||
<Slider value={value} disabled={disabled || !on} accent={sliderAccent} onChange={onLevel} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 }: {
|
||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||
segColor?: (frac: number) => string;
|
||
}) {
|
||
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 (
|
||
<div className="rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0">
|
||
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
|
||
{display !== undefined ? display : (
|
||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
|
||
{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 (
|
||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||
style={on
|
||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||
);
|
||
})}
|
||
</div>
|
||
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||
</div>
|
||
<div className="p-3 space-y-3">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 }: { onCWSpeed?: (wpm: number) => void } = {}) {
|
||
const [st, setSt] = useState<FlexState>(ZERO);
|
||
const hold = useRef<Record<string, number>>({});
|
||
// 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<Record<string, { v: number; t: number }>>({});
|
||
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<any>) => {
|
||
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;
|
||
|
||
return (
|
||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||
{/* Header strip */}
|
||
<div className="flex items-center gap-3 rounded-xl px-4 py-3 text-white shadow-sm"
|
||
style={{ background: 'linear-gradient(135deg,#1e293b,#0f172a)' }}>
|
||
<Radio className="size-6 text-sky-400" />
|
||
<div className="flex flex-col leading-tight">
|
||
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
|
||
<span className="text-[11px] text-slate-400">SmartSDR remote control</span>
|
||
</div>
|
||
<div className="flex-1" />
|
||
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||
!st.available ? 'bg-slate-700 text-slate-300'
|
||
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
|
||
<span className="size-2 rounded-full bg-current" />
|
||
{!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
|
||
</span>
|
||
</div>
|
||
|
||
{off && (
|
||
<div className="text-center text-sm text-muted-foreground py-6">
|
||
Waiting for the FlexRadio… (set CAT to FlexRadio and connect)
|
||
</div>
|
||
)}
|
||
|
||
{/* TX + RX columns */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
{/* TRANSMIT */}
|
||
<Card icon={Zap} title="Transmit" accent="#dc2626">
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</span>
|
||
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">Tune Pwr</span>
|
||
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
|
||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 pt-0.5">
|
||
<button type="button" disabled={off}
|
||
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
|
||
TUNE
|
||
</button>
|
||
<button type="button" disabled={off}
|
||
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
|
||
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" disabled={off}
|
||
title="Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR."
|
||
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
|
||
SPLIT
|
||
</button>
|
||
{st.split && !!st.tx_freq_hz && (
|
||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||
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)`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{!isCW ? (
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
|
||
onClick={() => change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} />
|
||
<Segmented value={String(st.proc_level)} options={PROC} disabled={off || !st.proc_enable}
|
||
onChange={(v) => change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
|
||
<span className="flex-1" />
|
||
</div>
|
||
<LevelRow label="VOX" on={st.vox_enable} disabled={off} value={st.vox_level} sliderAccent="#16a34a"
|
||
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
||
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">VOX Dly</span>
|
||
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
|
||
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
|
||
</div>
|
||
<LevelRow label="MON" on={st.mon} disabled={off} value={st.mon_level} accent="cyan" sliderAccent="#0891b2"
|
||
onToggle={() => change('mon', !st.mon, () => FlexSetMon(!st.mon))}
|
||
onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">MIC</span>
|
||
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
||
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
|
||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
|
||
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
|
||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
|
||
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
|
||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||
</div>
|
||
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
|
||
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
|
||
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* RECEIVE */}
|
||
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||
<span className="text-[10px] text-muted-foreground">RX</span>
|
||
<select disabled={rxOff} value={st.rx_ant ?? ''}
|
||
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
|
||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||
</select>
|
||
<span className="text-[10px] text-muted-foreground">TX</span>
|
||
<select disabled={rxOff} value={st.tx_ant ?? ''}
|
||
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
|
||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||
<button type="button" disabled={rxOff}
|
||
title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
|
||
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
|
||
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||
</button>
|
||
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
|
||
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||
</div>
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||
onToggle={() => 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 && (
|
||
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||
)}
|
||
</div>
|
||
{isCW && (
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||
{CW_BW.map((bw) => (
|
||
<button key={bw} type="button" disabled={rxOff}
|
||
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
|
||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||
{bw}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!isCW && (
|
||
<div className="border-t border-border/60 pt-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||
{SSB_BW.map((bw) => (
|
||
<button key={bw} type="button" disabled={rxOff}
|
||
onClick={() => {
|
||
const lsb = (st.mode || '').toUpperCase().includes('LSB');
|
||
let lo: number, hi: number;
|
||
if (lsb) { const near = (st.filter_hi && st.filter_hi < 0) ? st.filter_hi : -100; hi = near; lo = near - bw; }
|
||
else { const near = (st.filter_lo && st.filter_lo > 0) ? st.filter_lo : 100; lo = near; hi = near + bw; }
|
||
setSt((p) => ({ ...p, filter_lo: lo, filter_hi: hi }));
|
||
FlexSetFilter(lo, hi).catch(() => {});
|
||
}}
|
||
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||
bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||
{(bw / 1000).toFixed(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="text-[10px] text-muted-foreground/70 font-mono">kHz</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
|
||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||
{st.amp_available && (
|
||
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||
<div className="flex items-center gap-3">
|
||
<button type="button" disabled={off}
|
||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
|
||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||
</button>
|
||
<span className="text-xs text-muted-foreground">
|
||
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||
</span>
|
||
{/* 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) && (
|
||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? 'PowerGenius connected' : (pg.last_error || 'PowerGenius offline')}>
|
||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
|
||
<span className="text-muted-foreground">Fan</span>
|
||
<select
|
||
disabled={!pg.connected}
|
||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
|
||
>
|
||
<option value="STANDARD">Standard</option>
|
||
<option value="CONTEST">Contest</option>
|
||
<option value="BROADCAST">Broadcast</option>
|
||
</select>
|
||
</label>
|
||
)}
|
||
<div className="flex-1" />
|
||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Live meters (UDP VITA-49 stream) */}
|
||
<Card icon={Gauge} title="Meters">
|
||
{(() => {
|
||
const meters = st.meters || [];
|
||
if (off || meters.length === 0) {
|
||
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet — waiting for the radio's UDP stream…</p>;
|
||
}
|
||
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'));
|
||
const sig = radio('LEVEL') || radio('SIGNAL');
|
||
const fwd = radio('FWDPWR');
|
||
const swr = radio('SWR');
|
||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
||
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
|
||
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||
const sUnit = (dbm: number) => {
|
||
const s = (dbm + 127) / 6; // S0 = -127 dBm
|
||
if (s >= 9) {
|
||
const over = Math.round(dbm + 73); // dB over S9
|
||
return { display: over > 0 ? `S9+${over}` : 'S9', bar: s };
|
||
}
|
||
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
||
};
|
||
const cur = [
|
||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||
); })(),
|
||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||
); })(),
|
||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||
].filter(Boolean);
|
||
return (
|
||
<>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||
{amp.length > 0 && (
|
||
<div className="border-t border-border/60 pt-2 mt-1">
|
||
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">AMPLIFIER</div>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||
{amp.map((m) => {
|
||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
||
}
|
||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|