feat: Tuner Genius XL — A/B channels, plus FlexRadio panel + Station Control cards
Push the tuner control further so it mirrors the native 4O3A app and the way the PowerGenius XL is surfaced. Backend (internal/tunergenius): - Status now carries both RF channels A and B (source/mode, band, frequency, bound Flex nickname, per-channel bypass, antenna, PTT), the active channel, the C1/L/C2 relay-network positions, and the 3-way-vs-SO2R hardware variant (learned once from the `info` reply). Flat freq/antenna still mirror the active channel for the compact widget. - New TunerGeniusActivate(ch) binding → `activate ch=N` (or `ant=N` on 3-way). Frontend: - New shared TunerCard, styled exactly like AmpCard (PWR/SWR meter bars, A/B channel selector with source+freq+antenna, Tune/Bypass/Operate). Used in BOTH the FlexRadio panel (its own card, like the PGXL) and Station Control. - Docked TunerGeniusPanel widget now shows the two channels A/B with their source/frequency/antenna and lets you click to make one active — the missing A/B state the user flagged. - i18n EN/FR for the new labels (channels, antenna, in-line/bypassed, title). Still UNTESTED on hardware — verify the per-channel field names/units and the activate behaviour on the real box.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { Zap, Radio } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
||||
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
|
||||
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
|
||||
// mirrors the native app's two channels (A / B) with their source, frequency and
|
||||
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
||||
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
||||
// reconciles the display, just like AmpCard.
|
||||
|
||||
const METER_SEGMENTS = 26;
|
||||
|
||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor }: {
|
||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: 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 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0 px-2.5 py-2">
|
||||
<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="font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90 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>
|
||||
<div className="flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px] 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
|
||||
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
|
||||
// antenna, matching the two rows of the native 4O3A app.
|
||||
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
|
||||
onSelect: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const cls = ptt
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)]'
|
||||
: active
|
||||
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-card text-foreground/80 border-border hover:bg-muted';
|
||||
const src = ch.mode_str || '—';
|
||||
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
|
||||
return (
|
||||
<button type="button" onClick={onSelect}
|
||||
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-extrabold text-sm">{letter}</span>
|
||||
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
|
||||
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
|
||||
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
|
||||
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
|
||||
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
|
||||
const connected = !!status.connected;
|
||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||
const active = status.active ?? 1;
|
||||
const a: TGChannel = status.a ?? {};
|
||||
const b: TGChannel = status.b ?? {};
|
||||
|
||||
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
|
||||
return (
|
||||
<Card icon={Zap} title={title} accent="#f59e0b">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.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')}>
|
||||
{status.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusAutotune().catch(() => {})}
|
||||
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
|
||||
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||
</button>
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
|
||||
{t('tgp.bypass')}
|
||||
</button>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
|
||||
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{status.message && (
|
||||
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold">⚠ {status.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connected && (
|
||||
<>
|
||||
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
|
||||
<div className="flex items-stretch gap-2">
|
||||
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
|
||||
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
|
||||
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
|
||||
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||
</div>
|
||||
|
||||
{/* PWR + SWR meters. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
|
||||
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user