The card is two grid columns wide and sits beside a tall neighbour in Station Control, so a single row of four bars left the height unused and squeezed each bar into a quarter of the width. Two rows of two fill the room already there and double the resolution of every bar.
285 lines
17 KiB
TypeScript
285 lines
17 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;
|
|
|
|
// Meters built from the amplifier's own GSCP status frame, for when the radio
|
|
// is not feeding a meter stream.
|
|
//
|
|
// Whether there is power is the amp's state field; how much is the plain
|
|
// forward figure. NOT "peakfwd" — that is a latched maximum which is never
|
|
// reset and survives in the last-known status after the amp disconnects, so it
|
|
// once claimed 1350 W from an old transmission while 10 W was going out. Same
|
|
// reason peak_id is left alone. Both readings are gated on transmit so they
|
|
// fall back to zero between overs instead of freezing on the last one.
|
|
const pgxlMeters = () => {
|
|
if (!pg.connected) return null;
|
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : /TRANSMIT/i.test(pg.state || '');
|
|
const fwdW = peakHold('pgfwd', txing ? Number(pg.fwd_w) || 0 : 0);
|
|
const idA = peakHold('pgid', txing ? Number(pg.id) || 0 : 0);
|
|
const swr = peakHold('pgswr', txing ? Number(pg.vswr) || 0 : 0);
|
|
const tempC = Number(pg.temperature) || 0;
|
|
// Two columns, not four. The card sits beside a tall neighbour in Station
|
|
// Control, so a single row of four leaves the height empty and squeezes each
|
|
// bar into a quarter width — two rows of two use the room that is already
|
|
// there and give every bar twice the resolution.
|
|
return (
|
|
<div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-border/50">
|
|
<MeterBar label={t('flxp.outputPower')} value={fwdW} unit="W" lo={0} hi={2000}
|
|
display={`${Math.round(fwdW)} W`}
|
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
|
<MeterBar label={t('ampw.id')} value={idA} lo={0} hi={25} display={`${idA.toFixed(1)} A`} accent="#16a34a" />
|
|
{/* Below 1:1 the reading is meaningless, so an idle amp shows a flat bar
|
|
rather than a zero that looks like a perfect match. */}
|
|
<MeterBar label={t('ampw.swr')} value={swr >= 1 ? swr : 1} lo={1} hi={3}
|
|
display={swr >= 1 ? swr.toFixed(1) : '—'}
|
|
segColor={(f) => (f > 0.75 ? '#dc2626' : f > 0.4 ? '#f59e0b' : '#16a34a')} />
|
|
<MeterBar label={t('ampw.temp')} value={tempC} unit="°C" lo={0} hi={100}
|
|
display={tempC > 0 ? `${Math.round(tempC)} °C` : '—'}
|
|
segColor={(f) => (f > 0.8 ? '#dc2626' : f > 0.6 ? '#f59e0b' : '#ea580c')} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 …).
|
|
The FlexRadio UDP stream is the preferred source — it is fast and reads
|
|
the same as SmartSDR. When there is no Flex, or it is not streaming,
|
|
the amplifier's OWN link carries the same figures; falling back to them
|
|
is what the docked widget already does. Without that fallback this card
|
|
showed an operator on a Kenwood nothing but OPERATE and the fan mode,
|
|
while the amplifier was reporting power, current and temperature all
|
|
along. */}
|
|
{(() => {
|
|
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 (!viaFlex || amps.length === 0) return pgxlMeters();
|
|
// 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>
|
|
);
|
|
}
|