239 lines
14 KiB
TypeScript
239 lines
14 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { Flame, ChevronDown } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { MeterBar } from '@/components/MeterBar';
|
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
|
|
|
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
|
// so Station Control (and anywhere else that needs it) shows the SAME card instead
|
|
// of a stripped-down variant. It handles all three amp families:
|
|
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
|
|
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
|
|
// amp), fan mode from the direct GSCP link (amp.pgxl)
|
|
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
|
// can each get their own card.
|
|
|
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
|
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
|
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
|
<button type="button" onClick={toggle}
|
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
|
</button>
|
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
|
|
function speMaxW(model?: string): number {
|
|
const m = (model || '').toUpperCase();
|
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
|
if (m.includes('15K')) return 1500;
|
|
return 1300;
|
|
}
|
|
function powerLevelLabel(pl?: string): string {
|
|
switch ((pl || '').trim().toUpperCase()) {
|
|
case 'L': return 'Low';
|
|
case 'M': return 'Mid';
|
|
case 'H': return 'High';
|
|
default: return pl || '';
|
|
}
|
|
}
|
|
|
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
|
|
|
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
|
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
|
// One second: the same window as the docked widget, so the two never show
|
|
// different figures for the same amplifier, and neither trails the end of a
|
|
// transmission long enough to look like lag.
|
|
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 > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
|
return p.v;
|
|
};
|
|
|
|
const isSPE = !!amp.spe;
|
|
const isACOM = !!amp.acom;
|
|
|
|
if (isSPE) {
|
|
const spe = amp.spe;
|
|
return (
|
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<button type="button" disabled={!spe.connected}
|
|
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
|
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
|
spe.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')}>
|
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
|
</button>
|
|
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
|
is off (no status = not "connected"), and serial only. */}
|
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
|
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
|
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
|
<button type="button" disabled={!spe.connected}
|
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
|
</div>
|
|
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
|
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
|
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
|
return (
|
|
<button key={lvl} type="button" disabled={!spe.connected}
|
|
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
|
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
|
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
|
{powerLevelLabel(lvl)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
|
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
|
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
|
</span>
|
|
{spe.connected && (
|
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
|
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
|
</span>
|
|
)}
|
|
<div className="flex-1" />
|
|
{(spe.warnings || spe.alarms) && (
|
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
|
)}
|
|
</div>
|
|
{spe.connected && (
|
|
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
|
lo={0} hi={speMaxW(spe.model)}
|
|
display={`${Number(spe.output_w) || 0} W`}
|
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (isACOM) {
|
|
const acom = amp.acom;
|
|
return (
|
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<button type="button" disabled={!acom.connected}
|
|
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
|
acom.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')}>
|
|
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
|
</button>
|
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
|
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
|
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
|
<button type="button" disabled={!acom.connected}
|
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
|
</div>
|
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
|
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
|
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
|
</span>
|
|
{acom.connected && (
|
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
|
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
|
</span>
|
|
)}
|
|
<div className="flex-1" />
|
|
{acom.err_text && (
|
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
|
)}
|
|
</div>
|
|
{acom.connected && (
|
|
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
|
lo={0} hi={Number(acom.max_w) || 800}
|
|
display={`${Number(acom.fwd_w) || 0} W`}
|
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
|
const pg = amp.pgxl || {};
|
|
const viaFlex = !!flex?.amp_available;
|
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
|
const connected = !!pg.connected || viaFlex;
|
|
const fault = flex?.amp_fault;
|
|
return (
|
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<button type="button" disabled={!connected}
|
|
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
|
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')}>
|
|
{operate ? 'OPERATE' : 'STANDBY'}
|
|
</button>
|
|
{(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) => AmpFanMode(amp.id, e.target.value).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" />
|
|
{fault && fault !== 'NONE' && (
|
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
|
)}
|
|
</div>
|
|
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
|
{viaFlex && (() => {
|
|
const meters = (flex?.meters as any[]) || [];
|
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
|
if (amps.length === 0) return null;
|
|
// Power comes from the radio's meter stream and nothing else. The
|
|
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
|
// over: it is a latched maximum that is never reset, and it survives in
|
|
// the last-known status after the amp disconnects — so it claimed
|
|
// 1350 W from an old transmission while the radio was putting out 10.
|
|
return (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
|
{amps.map((m) => {
|
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
|
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" />;
|
|
}
|
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
|
let lo = m.lo, hi = m.hi;
|
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
|
lo = 0;
|
|
hi = m.hi >= 25 ? m.hi : 25;
|
|
}
|
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
|
})}
|
|
</div>
|
|
);
|
|
})()}
|
|
</Card>
|
|
);
|
|
}
|