feat: multiple amplifiers + SmartSDR v4 DSP filters
Multi-amp: Settings->Amplifier becomes a list (amps.json, legacy single-amp keys auto-migrate to entry #1); one client per enabled amp with per-id bindings (GetAmplifiers/SaveAmplifiers/GetAmpStatuses/AmpOperate/AmpPower/AmpPowerLevel/AmpFanMode); legacy a.pgxl/a.spe/a.acom point at the first enabled amp of each family. Amp cards in FlexPanel and Station Control gain a dropdown to pick the amp; the status bar shows one clickable chip per amp. Use case: two SPEs run in parallel. Flex v4 DSP (8000/Aurora): NRL/ANFL (lms_nr/lms_anf), NRS (speex_nr), NRF (nrf) with level sliders, RNN (rnnoise) and ANFT on/off — keys per the FlexLib slice docs; the section only shows when the radio reports these keys (dsp_v4 flag), so 6000-series panels are unchanged.
This commit is contained in:
@@ -13,9 +13,7 @@ import {
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode, PGXLSetOperate, GetFlexState, FlexAmpOperate,
|
||||
GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
@@ -251,38 +249,38 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
||||
|
||||
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
|
||||
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
|
||||
// them (the FlexPanel card only exists when a Flex is the rig). Same backends:
|
||||
// SPE Expert / ACOM (full control) and PowerGenius XL (fan mode + state).
|
||||
function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: any) => string }) {
|
||||
const isACOM = ampType.startsWith('acom');
|
||||
const isPGXL = ampType === 'pgxl';
|
||||
const [st, setSt] = useState<any>({ connected: false });
|
||||
// them. Several amps can be configured (some ops run two SPEs in parallel) —
|
||||
// the header dropdown picks which one this card shows; the choice is remembered.
|
||||
function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [list, setList] = useState<any[]>([]);
|
||||
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.station') || '');
|
||||
const [flex, setFlex] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// PGXL: the Flex reports the amp's OPERATE state authoritatively (the direct
|
||||
// GSCP status doesn't carry it) — merge it in when a Flex sees the amp.
|
||||
const tick = isPGXL
|
||||
? () => Promise.all([
|
||||
GetPGXLStatus().catch(() => ({ connected: false })),
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([pg, fx]: any[]) => {
|
||||
if (!alive) return;
|
||||
const viaFlex = !!fx?.amp_available;
|
||||
setSt({
|
||||
...(pg || {}),
|
||||
connected: !!pg?.connected || viaFlex,
|
||||
operate: viaFlex ? !!fx.amp_operate : !!pg?.operate,
|
||||
via_flex: viaFlex,
|
||||
});
|
||||
})
|
||||
: () => (isACOM ? GetACOMStatus : GetSPEStatus)()
|
||||
.then((s: any) => alive && setSt(s || { connected: false })).catch(() => {});
|
||||
// The Flex state rides along because a PGXL's OPERATE state comes from the
|
||||
// radio (the direct GSCP status doesn't carry it).
|
||||
const tick = () => Promise.all([
|
||||
GetAmpStatuses().catch(() => []),
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([l, fx]: any[]) => { if (alive) { setList((l ?? []) as any[]); setFlex(fx); } });
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [ampType, isACOM, isPGXL]);
|
||||
|
||||
const title = isPGXL ? 'PowerGenius XL' : isACOM ? `ACOM ${st.model || ''}` : `SPE ${st.model || 'Expert'}`;
|
||||
}, []);
|
||||
const amp = list.find((a) => a.id === sel) ?? list[0];
|
||||
if (!amp) return null;
|
||||
const isACOM = !!amp.acom;
|
||||
const isSPE = !!amp.spe;
|
||||
const isPGXL = !isACOM && !isSPE;
|
||||
const viaFlex = isPGXL && !!flex?.amp_available;
|
||||
const raw = amp.spe ?? amp.acom ?? amp.pgxl ?? { connected: false };
|
||||
const st: any = isPGXL
|
||||
? { ...raw, connected: !!raw.connected || viaFlex, operate: viaFlex ? !!flex.amp_operate : !!raw.operate }
|
||||
: raw;
|
||||
const doOperate = () => {
|
||||
const want = !st.operate;
|
||||
(isPGXL && viaFlex ? FlexAmpOperate(want) : AmpOperate(amp.id, want)).catch(() => {});
|
||||
};
|
||||
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
|
||||
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
|
||||
const frac = Math.min(1, outW / maxW);
|
||||
@@ -292,8 +290,15 @@ function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: a
|
||||
<Flame className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">{title}</div>
|
||||
{list.length <= 1 && <div className="text-[10px] text-muted-foreground font-mono truncate">{amp.name}</div>}
|
||||
</div>
|
||||
{list.length > 1 && (
|
||||
<select value={amp.id} title={t('flxp.ampPick')}
|
||||
onChange={(e) => { setSel(e.target.value); localStorage.setItem('opslog.ampSel.station', e.target.value); }}
|
||||
className="h-7 rounded-md border border-border bg-card px-1.5 text-xs font-semibold outline-none min-w-0">
|
||||
{list.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
|
||||
</div>
|
||||
@@ -301,14 +306,14 @@ function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: a
|
||||
{isPGXL ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button type="button" disabled={!st.connected}
|
||||
onClick={() => { const want = !st.operate; setSt((s: any) => ({ ...s, operate: want })); (st.via_flex ? FlexAmpOperate(want) : PGXLSetOperate(want)).catch(() => {}); }}
|
||||
onClick={doOperate}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
|
||||
<button key={m} type="button" disabled={!st.connected}
|
||||
onClick={() => PGXLSetFanMode(m).catch(() => {})}
|
||||
onClick={() => AmpFanMode(amp.id, m).catch(() => {})}
|
||||
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
|
||||
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
|
||||
@@ -320,7 +325,7 @@ function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: a
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button type="button" disabled={!st.connected}
|
||||
onClick={() => (isACOM ? ACOMSetOperate(!st.operate) : SPESetOperate(!st.operate)).catch(() => {})}
|
||||
onClick={doOperate}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||
@@ -328,17 +333,17 @@ function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: a
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
|
||||
<button type="button"
|
||||
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
|
||||
onClick={() => (isACOM ? ACOMSetPower(true) : SPESetPower(true)).catch(() => {})}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
|
||||
<button type="button" disabled={!st.connected}
|
||||
onClick={() => (isACOM ? ACOMSetPower(false) : SPESetPower(false)).catch(() => {})}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
|
||||
</div>
|
||||
{!isACOM && (
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||
{(['L', 'M', 'H'] as const).map((lvl, i) => (
|
||||
<button key={lvl} type="button" disabled={!st.connected}
|
||||
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
|
||||
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{lvl}
|
||||
@@ -389,13 +394,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
});
|
||||
const dragId = useRef<string | null>(null);
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
// Amplifier (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. Re-read every 5s so enabling the
|
||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. Re-read every 5s so enabling an
|
||||
// amp in Settings makes the widget appear without reopening the tab.
|
||||
const [amp, setAmp] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
|
||||
const [ampCount, setAmpCount] = useState(0);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmp({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
|
||||
const load = () => GetAmpStatuses().then((l: any) => { if (alive) setAmpCount((l ?? []).length); }).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 5000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
@@ -520,8 +525,8 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
if (ant.enabled) {
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
}
|
||||
if (amp.enabled) {
|
||||
widgets.push({ id: 'amplifier', node: <AmplifierWidget ampType={amp.type} t={t} /> });
|
||||
if (ampCount > 0) {
|
||||
widgets.push({ id: 'amplifier', node: <AmplifierWidget t={t} /> });
|
||||
}
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
@@ -529,7 +534,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||
const widgetIds = ordered.map((w) => w.id);
|
||||
|
||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && !amp.enabled;
|
||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0;
|
||||
|
||||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||
|
||||
Reference in New Issue
Block a user