Files
OpsLog/frontend/src/components/AmpWidget.tsx
T
2026-08-02 06:40:10 +02:00

282 lines
15 KiB
TypeScript

import { useRef } from 'react';
import { Flame, X, Power } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { MeterBar } from '@/components/MeterBar';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
// (which is the full-size card shown in the FlexRadio panel and Station
// Control). It covers all three families, since an operator running an SPE and
// a PowerGenius wants one widget, not two:
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
// With several amplifiers configured the caller passes a selection ("all" or an
// amp id) chosen from the toolbar icon's dropdown.
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
// Full-scale watts for the output bar, from the model string.
function maxW(model?: string, fallback = 1300): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
if (m.includes('13K')) return 1300;
return fallback;
}
function swrColour(swr?: number): string {
if (!swr || swr <= 0) return 'text-muted-foreground';
if (swr <= 1.5) return 'text-success';
if (swr <= 2.0) return 'text-warning';
return 'text-danger';
}
// PowerMeter — the output-power LED bar that closes each column, straight under
// the controls (no filler above it: pushed to the bottom it left a band of dead
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
// so it reads exactly like the meters on the rig panels.
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
return (
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
display={`${Math.round(watts)} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
);
}
// usePeakHold keeps the highest reading seen for a short window, so a reading
// sampled between two syllables doesn't collapse the meter. `live` is the key
// part: pass false the moment the carrier drops and the held value is thrown
// away at once — holding it there made releasing the PTT look like a five
// second lag, which read as the whole app being slow.
function usePeakHold(windowMs = 1000) {
const peak = useRef<Record<string, { v: number; t: number }>>({});
return (key: string, val: number, live = true) => {
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
}
// Stat — one small labelled figure (power, SWR, temperature).
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
return (
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
</div>
);
}
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
// three families (only where the state comes from differs).
function OperateButton({ operate, disabled, onClick, t }: {
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
}) {
return (
<button type="button" disabled={disabled} onClick={onClick}
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
operate
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
<Power className="size-3.5" />
{operate ? t('ampw.operate') : t('ampw.standby')}
</button>
);
}
// AmpBlock — one amplifier inside the widget.
function AmpBlock({ amp, flex, showName, t }: {
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
}) {
const spe = amp.spe, acom = amp.acom;
const hold = usePeakHold();
if (spe || acom) {
const s = spe || acom;
// The amp reports zero watts on receive, so its TX flag clears the meter as
// soon as the operator lets go — and the radio's own flag, when we have one,
// gets there first (the amp is polled on its own slower cycle).
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
// Power ON drives the remote-on control lines, so it stays available while
// the amplifier is off and reporting nothing — but only over a serial link.
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
)}
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
<div className="flex gap-1.5">
<button type="button" disabled={!canPowerOn}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
{t('ampw.on')}
</button>
<button type="button" disabled={!s.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
{t('ampw.off')}
</button>
</div>
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
{spe && (
<div className="grid grid-cols-3 gap-1">
{(['L', 'M', 'H'] as const).map((lvl) => {
const active = (s.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!s.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{t(`ampw.lvl${lvl}`)}
</button>
);
})}
</div>
)}
{s.connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(s.warnings || s.alarms || s.err_text) && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
</div>
);
}
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
// reports the amp; the fan mode rides on our own 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;
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const fwd = meter(/^fwd|pwr/i);
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
// Whether there is power is the RADIO's transmit flag: it flips the instant
// the PTT does, where the amplifier's own status poll answers a second and a
// half late and holds TRANSMIT past the carrier.
//
// How MUCH is the live reading — the radio's meter stream, or the amp's own
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
// latched maximum which is never reset AND survives in the last-known status
// after the amp disconnects, so it reported 1350 W from an old transmission
// while the radio was putting out 10 W into a disconnected amplifier.
const txing = typeof flex?.transmitting === 'boolean'
? flex.transmitting
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
const fwdW = hold('fwd', txing ? liveW : 0, txing);
const id = meter(/^ID$|current/i);
// Live drain current, for the same reason: peak_id latches like peak_w.
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
const temp = meter(/temp/i);
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
</div>
)}
<OperateButton operate={operate} disabled={!connected} t={t}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
{connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(pg.host || pg.connected) && (
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
title={t('ampw.fan')}
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold 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>
)}
{fault && fault !== 'NONE' && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{t('flxp.fault')}: {fault}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
</div>
);
}
export function AmpWidget({ amps, flex, sel, onClose }: {
amps: Amp[];
flex: any;
sel: string; // "all" or an amp id
onClose: () => void;
}) {
const { t } = useI18n();
// An id that no longer matches (the amp was removed in Settings) falls back to
// showing everything rather than an empty widget.
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
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">
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
<X className="size-3.5" />
</button>
</div>
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
made a widget taller than the dock row and both ended up half hidden. */}
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
{shown.length === 0 ? (
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
) : (
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
{shown.map((a, i) => (
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
</div>
))}
</div>
)}
</div>
</div>
);
}