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:
2026-07-22 17:25:10 +02:00
parent 4c75680689
commit b7dd8d4852
12 changed files with 1051 additions and 411 deletions
+172 -162
View File
@@ -13,7 +13,7 @@ import {
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate, GetACOMStatus, ACOMSetOperate,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -620,6 +620,9 @@ function ADIFMonitorPanel() {
);
}
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
// RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
// off, a frequency window (kHz), or a set of bands.
@@ -628,33 +631,38 @@ type StationDevUI = { id: string; type: string; name: string; labels: string[] }
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a
// nested component) so it isn't remounted on every parent render. Polls once a
// second while shown.
function SPEStatusCard() {
const [st, setSt] = useState<any>({ connected: false });
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
// isn't remounted on every parent render. Polls once a second while shown.
function AmpStatusCard({ id }: { id: string }) {
const [amp, setAmp] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {});
const tick = () => GetAmpStatuses().then((all: any[]) => {
if (alive) setAmp((all ?? []).find((a) => a.id === id) ?? null);
}).catch(() => {});
tick();
const id = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const t = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(t); };
}, [id]);
const st: any = amp?.spe ?? amp?.acom ?? amp?.pgxl ?? { connected: false };
const isSPE = !!amp?.spe;
const isACOM = !!amp?.acom;
const operate = !!st.operate;
return (
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span>
<span className="font-semibold">{amp?.name || 'Amplifier'}{st.connected ? '' : ' — not connected'}</span>
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
<span className="flex-1" />
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
onClick={() => SPESetOperate(!operate).catch(() => {})}
onClick={() => AmpOperate(id, !operate).catch(() => {})}
title="Toggle OPERATE / STANDBY">
{operate ? 'OPERATE' : 'STANDBY'}
</Button>
</div>
{st.connected && (
{st.connected && isSPE && (
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.tx ? 'TX' : 'RX'}</div>
<div>Band {st.band}</div>
@@ -667,35 +675,7 @@ function SPEStatusCard() {
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning"> {st.warnings} {st.alarms}</div>}
</div>
)}
</div>
);
}
// Live ACOM amplifier status + OPERATE/STANDBY. Module-scoped for the same
// remount reason as SPEStatusCard. Polls once a second while shown.
function ACOMStatusCard() {
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const tick = () => GetACOMStatus().then((s) => alive && setSt(s || {})).catch(() => {});
tick();
const id = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const operate = !!st.operate;
return (
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
<span className="font-semibold">{st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`}</span>
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
<span className="flex-1" />
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
onClick={() => ACOMSetOperate(!operate).catch(() => {})}
title="Toggle OPERATE / STANDBY">
{operate ? 'OPERATE' : 'STANDBY'}
</Button>
</div>
{st.connected && (
{st.connected && isACOM && (
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.state}</div>
<div>Band {st.band || '—'}</div>
@@ -708,6 +688,13 @@ function ACOMStatusCard() {
{st.err_text && <div className="col-span-4 text-warning"> {st.err_text} ({st.err_code})</div>}
</div>
)}
{st.connected && !isSPE && !isACOM && (
<div className="grid grid-cols-3 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.state || ''}</div>
<div>Fan {st.fan_mode || '—'}</div>
<div>{st.temperature ? `${Math.round(st.temperature)}°C` : ''}</div>
</div>
)}
</div>
);
}
@@ -1076,9 +1063,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>(
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 });
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
// each with its own connection. Saved as a whole via SaveAmplifiers.
const [amps, setAmps] = useState<AmpUI[]>([]);
// WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string };
@@ -1396,7 +1383,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setRotator(r);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setPgxl(await GetPGXLSettings() as any); } catch {}
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
setBackupCfg(b as any);
setQslDefaults(qd as any);
setExtSvc(es as any);
@@ -1436,7 +1423,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setPgxl(await GetPGXLSettings() as any); } catch {}
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() as any); } catch {}
@@ -1605,7 +1592,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SavePGXLSettings(pgxl as any);
await SaveAmplifiers(amps as any);
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any);
@@ -2710,12 +2697,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function PGXLPanelSettings() {
const isPGXL = pgxl.type === 'pgxl';
const isACOM = (pgxl.type || '').startsWith('acom');
const isSerial = pgxl.transport === 'serial';
// The stored `type` stays the single flat value ("spe13", "acom700", "pgxl" —
// binding compatibility); the UI presents it as brand + model.
const brand = isPGXL ? 'pgxl' : isACOM ? 'acom' : 'spe';
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
// presents it as brand + model.
const brandModels: Record<string, { value: string; label: string }[]> = {
spe: [
{ value: 'spe13', label: 'Expert 1.3K-FA' },
@@ -2730,123 +2713,150 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{ value: 'acom2020', label: '2020S' },
],
};
const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' : ty.startsWith('acom') ? 'acom' : 'spe';
const patchAmp = (i: number, patch: Partial<AmpUI>) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a)));
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
const applyType = (v: string) => setPgxl((s) => ({
...s, type: v,
transport: v === 'pgxl' ? 'tcp' : s.transport,
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : s.baud,
}));
const applyType = (i: number, v: string) => patchAmp(i, {
type: v,
transport: v === 'pgxl' ? 'tcp' : amps[i].transport,
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : amps[i].baud,
});
const addAmp = () => setAmps((l) => [...l, {
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
}]);
return (
<>
<SectionHeader title="Amplifier" />
<SectionHeader title="Amplifier" hint={t('amp.hint')} />
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
Enable amplifier control
</label>
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was
truncated with 3 equal columns. */}
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
<div className="space-y-1">
<Label>Brand</Label>
<Select value={brand} onValueChange={(b) => {
if (b === brand) return;
applyType(b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
}}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pgxl">4O3A</SelectItem>
<SelectItem value="spe">SPE</SelectItem>
<SelectItem value="acom">ACOM</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Model</Label>
{isPGXL ? (
// The PowerGenius is the brand's single model — fixed, no choice.
<Select value="pgxl" disabled>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
</SelectContent>
</Select>
) : (
<Select value={pgxl.type} onValueChange={applyType}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
</SelectContent>
</Select>
)}
</div>
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or
an RS232-to-Ethernet bridge, so they offer both. */}
{!isPGXL && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="serial">USB (serial COM)</SelectItem>
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isSerial ? (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>COM port</Label>
{amps.length === 0 && (
<p className="text-sm text-muted-foreground">{t('amp.none')}</p>
)}
{amps.map((amp, i) => {
const brand = brandOf(amp.type);
const isPGXL = brand === 'pgxl';
const isACOM = brand === 'acom';
const isSerial = !isPGXL && amp.transport === 'serial';
return (
<div key={amp.id || `new-${i}`} className="rounded-lg border border-border p-3 space-y-3">
<div className="flex items-center gap-2">
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
<Checkbox checked={amp.enabled} onCheckedChange={(c) => patchAmp(i, { enabled: !!c })} />
<Input className="h-8 flex-1" value={amp.name}
placeholder={t('amp.namePh')}
onChange={(e) => patchAmp(i, { name: e.target.value })} />
<Button variant="ghost" size="sm" className="h-8 text-danger" title={t('amp.remove')}
onClick={() => setAmps((l) => l.filter((_, j) => j !== i))}>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={pgxl.baud}
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" />
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.70" className="font-mono" />
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={pgxl.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
</div>
</div>
)}
{!isPGXL && pgxl.enabled && (isACOM ? <ACOMStatusCard /> : <SPEStatusCard />)}
{!isPGXL && !isACOM && (
<p className="text-[10px] text-muted-foreground">
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
</p>
)}
{isACOM && (
<p className="text-[10px] text-muted-foreground">
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
</p>
)}
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)"
was truncated with 3 equal columns. */}
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
<div className="space-y-1">
<Label>Brand</Label>
<Select value={brand} onValueChange={(b) => {
if (b === brand) return;
applyType(i, b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
}}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pgxl">4O3A</SelectItem>
<SelectItem value="spe">SPE</SelectItem>
<SelectItem value="acom">ACOM</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Model</Label>
{isPGXL ? (
// The PowerGenius is the brand's single model — fixed, no choice.
<Select value="pgxl" disabled>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
</SelectContent>
</Select>
) : (
<Select value={amp.type} onValueChange={(v) => applyType(i, v)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
</SelectContent>
</Select>
)}
</div>
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial
or an RS232-to-Ethernet bridge, so they offer both. */}
{!isPGXL && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={amp.transport} onValueChange={(v) => patchAmp(i, { transport: v })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="serial">USB (serial COM)</SelectItem>
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isSerial ? (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={amp.com_port || '_'} onValueChange={(v) => patchAmp(i, { com_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={amp.baud}
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input value={amp.host ?? ''} onChange={(e) => patchAmp(i, { host: e.target.value })}
placeholder="192.168.1.70" className="font-mono" />
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
</div>
</div>
)}
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
{!isPGXL && !isACOM && (
<p className="text-[10px] text-muted-foreground">
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
</p>
)}
{isACOM && (
<p className="text-[10px] text-muted-foreground">
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
</p>
)}
</div>
);
})}
<Button variant="outline" size="sm" onClick={addAmp}>
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
</Button>
</div>
</>
);