Addresses three points of feedback on the Tuner Genius work: - Meter sizes: the amplifier meters were rendered `compact` (smaller than the FlexRadio meters). Dropped compact so the amp, tuner and Flex meters are all the same size. - Collapsible cards: the FlexRadio panel Card, AmpCard and TunerCard now fold from a chevron in the header, state persisted per card (opslog.cardOpen.*). The amplifier cards share the "amplifier" collapse key across their SPE/ACOM/ PGXL variants so folding sticks regardless of the shown model. - TGXL responsiveness: the tuner's device poll dropped 1500ms→400ms and the three UI pollers 1500ms→500ms, so the SWR/power meters track TX without the 2–3s lag behind the amplifier the user saw. - Icon: the Tuner Genius top-bar toggle used Zap, same as the CW keyer — changed the tuner's icon (top bar + widget + card) to Gauge so the two are distinct.
177 lines
8.8 KiB
TypeScript
177 lines
8.8 KiB
TypeScript
import { Gauge, X, Power, Radio } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
export type TGChannel = {
|
|
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
|
|
freq_mhz?: number; bypass?: boolean; antenna?: number;
|
|
};
|
|
export type TGStatus = {
|
|
connected: boolean; host?: string; last_error?: string;
|
|
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
|
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
|
antenna?: number; three_way?: boolean; message?: string;
|
|
a?: TGChannel; b?: TGChannel;
|
|
relay_c1?: number; relay_l?: number; relay_c2?: number;
|
|
};
|
|
|
|
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
|
|
// red above (the usual "safe / caution / high" ATU thresholds).
|
|
function swrColour(vswr?: number): string {
|
|
if (!vswr || vswr <= 0) return 'text-muted-foreground';
|
|
if (vswr <= 1.5) return 'text-success';
|
|
if (vswr <= 2.0) return 'text-warning';
|
|
return 'text-danger';
|
|
}
|
|
|
|
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
|
|
// active channel (green) or TX (red), shows the source + frequency + antenna,
|
|
// and clicking it makes that channel active.
|
|
function ChanRow({ letter, ch, active, onSelect, t }: {
|
|
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
|
|
t: (k: string, v?: any) => string;
|
|
}) {
|
|
const ptt = !!ch.ptt;
|
|
const cls = ptt
|
|
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
|
|
: active
|
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
|
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
|
const src = ch.mode_str || '—';
|
|
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
|
|
return (
|
|
<button type="button" onClick={onSelect}
|
|
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
|
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
|
|
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
|
|
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
|
|
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
|
|
{ch.antenna != null && ch.antenna > 0 && (
|
|
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
|
|
)}
|
|
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
|
|
// the live SWR / forward power, the two RF channels (A / B, click to activate),
|
|
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
|
|
// panel and Station Control.
|
|
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
|
|
status: TGStatus;
|
|
onTune: () => void;
|
|
onBypass: (on: boolean) => void;
|
|
onOperate: (on: boolean) => void;
|
|
onActivate: (ch: number) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
|
const active = status.active ?? 1;
|
|
|
|
return (
|
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
|
<Gauge className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Tuner Genius</span>
|
|
<span className="flex-1" />
|
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('tgp.online') : t('tgp.offline')}</span>
|
|
</span>
|
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{!status.connected ? (
|
|
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
|
|
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
|
|
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
|
|
{/* SWR + forward power readouts */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
|
|
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
|
|
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
|
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
|
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Channel A / B — click to activate */}
|
|
<div className="space-y-1">
|
|
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
|
|
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
|
|
</div>
|
|
|
|
{status.message && (
|
|
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
|
|
{status.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tune */}
|
|
<button
|
|
type="button"
|
|
onClick={onTune}
|
|
disabled={status.tuning}
|
|
title={t('tgp.tuneHint')}
|
|
className={cn(
|
|
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
|
|
status.tuning
|
|
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
|
|
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
|
|
)}
|
|
>
|
|
<span className="inline-flex items-center justify-center gap-2">
|
|
<Radio className="size-4" />
|
|
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Bypass + Operate toggles */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => onBypass(!status.bypass)}
|
|
title={t('tgp.bypassHint')}
|
|
className={cn(
|
|
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
|
status.bypass
|
|
? 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]'
|
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
|
)}
|
|
>
|
|
{t('tgp.bypass')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onOperate(!status.operate)}
|
|
title={t('tgp.operateHint')}
|
|
className={cn(
|
|
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
|
status.operate
|
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
|
)}
|
|
>
|
|
<Power className="size-3.5" />
|
|
{status.operate ? t('tgp.operate') : t('tgp.standby')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|