716 lines
46 KiB
TypeScript
716 lines
46 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, 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<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-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 (
|
||
<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-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 (
|
||
<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, 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 (
|
||
<div onClick={onClick} title={title}
|
||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||
{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={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||
{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 && !compact && <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, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||
const { t } = useI18n();
|
||
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;
|
||
|
||
// 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 (
|
||
<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">{t('flxp.smartsdrRemote')}</span>
|
||
</div>
|
||
<div className="flex-1" />
|
||
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
|
||
{!off && (voltM || patempM) && (
|
||
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
|
||
{voltM && (
|
||
<span className="flex flex-col items-end leading-none">
|
||
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
|
||
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
|
||
</span>
|
||
)}
|
||
{patempM && (
|
||
<span className="flex flex-col items-end leading-none">
|
||
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
|
||
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<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-muted text-muted-foreground'
|
||
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
||
<span className="size-2 rounded-full bg-current" />
|
||
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 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 && (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{st.slices.map((sl) => (
|
||
<button key={sl.index} type="button" disabled={off}
|
||
onClick={() => FlexSetActiveSlice(sl.index).catch(() => {})}
|
||
title={t('flxp.sliceHint')}
|
||
className={cn('flex items-center gap-2 rounded-lg border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||
sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}>
|
||
<span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold',
|
||
sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span>
|
||
<span className="flex items-baseline gap-1.5">
|
||
<span className="font-mono font-bold tabular-nums text-sm leading-none">{(sl.freq_hz / 1e6).toFixed(3)}</span>
|
||
<span className="text-[11px] text-muted-foreground uppercase leading-none">{sl.mode}</span>
|
||
{sl.band ? <span className="text-[10px] text-muted-foreground leading-none">{sl.band}</span> : null}
|
||
</span>
|
||
{/* TX badge — click a non-TX slice's badge to move TX onto it
|
||
(e.g. transmit on the active slice). stopPropagation so it
|
||
doesn't also fire the outer "make active" click. */}
|
||
<span role="button" tabIndex={-1}
|
||
onClick={(e) => { e.stopPropagation(); if (!sl.tx && !off) FlexSetTXSlice(sl.index).catch(() => {}); }}
|
||
title={sl.tx ? t('flxp.txSlice') : t('flxp.setTxSlice')}
|
||
className={cn('rounded text-[10px] font-bold px-1',
|
||
sl.tx ? 'bg-danger text-danger-foreground' : 'border border-danger/50 text-danger cursor-pointer hover:bg-danger/10')}>
|
||
TX
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
|
||
<Card icon={Gauge} title={t('flxp.meters')}>
|
||
{(() => {
|
||
const meters = st.meters || [];
|
||
if (off || meters.length === 0) {
|
||
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</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'));
|
||
// 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 (
|
||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||
onClick={onReportRST ? () => 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 (
|
||
<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" />,
|
||
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
|
||
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
||
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||
// Speech compression (dB of gain reduction).
|
||
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
|
||
].filter(Boolean);
|
||
return (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||
);
|
||
})()}
|
||
</Card>
|
||
|
||
{off && (
|
||
<div className="text-center text-sm text-muted-foreground py-6">
|
||
{t('flxp.waiting')}
|
||
</div>
|
||
)}
|
||
|
||
{/* TX + RX columns */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
{/* TRANSMIT */}
|
||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</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">{t('flxp.tunePwr')}</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-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||
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-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50' : 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||
<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={t('flxp.splitHint')}
|
||
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-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||
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">{t('flxp.voxDly')}</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>
|
||
{/* 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. */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
|
||
{(() => {
|
||
const editTX = (patch: Partial<FlexState>) => { 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 (<>
|
||
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
|
||
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
|
||
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||
<span className="text-muted-foreground text-xs">–</span>
|
||
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
|
||
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
|
||
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||
</>);
|
||
})()}
|
||
</div>
|
||
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
|
||
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
|
||
<select disabled={off} value={st.mic_profile || ''}
|
||
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
|
||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
|
||
{!st.mic_profiles.includes(st.mic_profile || '') && <option value="">—</option>}
|
||
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
|
||
</select>
|
||
</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">{t('flxp.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">{t('flxp.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">{t('flxp.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={t('flxp.receiveActive')} 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 ? t('flxp.muted') : t('flxp.mute')}
|
||
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||
st.mute ? 'bg-destructive text-destructive-foreground' : '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))} />
|
||
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(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">{t('flxp.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">{t('flxp.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={`${t('flxp.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-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||
</button>
|
||
<span className="text-xs text-muted-foreground">
|
||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||
</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 ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||
<span className="text-muted-foreground">{t('flxp.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-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
|
||
>
|
||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||
</select>
|
||
</label>
|
||
)}
|
||
<div className="flex-1" />
|
||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
||
)}
|
||
</div>
|
||
{/* 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 (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||
{amp.map((m) => {
|
||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||
}
|
||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={acc} />;
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
</Card>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|