282 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|