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:
@@ -4,11 +4,13 @@ import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel,
|
||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
@@ -31,6 +33,10 @@ type FlexState = {
|
||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||
wnb: boolean; wnb_level: number;
|
||||
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
|
||||
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
|
||||
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
|
||||
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
|
||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||
mode?: string;
|
||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||
@@ -342,39 +348,31 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the
|
||||
// operator runs an SPE Expert instead of the PowerGenius.
|
||||
const [ampType, setAmpType] = useState<string>('pgxl');
|
||||
const [ampEnabled, setAmpEnabled] = useState(false);
|
||||
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
||||
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
||||
// which, and the choice is remembered per panel.
|
||||
const [ampList, setAmpList] = useState<any[]>([]);
|
||||
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 5000);
|
||||
const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
const isACOM = ampEnabled && ampType.startsWith('acom');
|
||||
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl';
|
||||
// SPE Expert live status (only polled when an SPE amp is configured).
|
||||
const [spe, setSpe] = useState<any>({ connected: false });
|
||||
useEffect(() => {
|
||||
if (!isSPE) return;
|
||||
let alive = true;
|
||||
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [isSPE]);
|
||||
// ACOM live status (only polled when an ACOM amp is configured).
|
||||
const [acom, setAcom] = useState<any>({ connected: false });
|
||||
useEffect(() => {
|
||||
if (!isACOM) return;
|
||||
let alive = true;
|
||||
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [isACOM]);
|
||||
const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
|
||||
const isSPE = !!selAmp?.spe;
|
||||
const isACOM = !!selAmp?.acom;
|
||||
const spe = selAmp?.spe ?? { connected: false };
|
||||
const acom = selAmp?.acom ?? { connected: false };
|
||||
const ampPicker = ampList.length > 1 && selAmp ? (
|
||||
<select value={selAmp.id}
|
||||
onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
|
||||
title={t('flxp.ampPick')}
|
||||
className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
|
||||
{ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
) : null;
|
||||
|
||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||
hold.current[key] = Date.now() + 900;
|
||||
@@ -777,6 +775,49 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||
)}
|
||||
</div>
|
||||
{/* SmartSDR v4 DSP (8000/Aurora series) — NRL/ANFL (legacy LMS), NRS
|
||||
(spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT
|
||||
notch). Only shown when the radio actually reports these slice keys
|
||||
(older 6000s never do). API keys per the FlexLib slice docs. */}
|
||||
{st.dsp_v4 && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
|
||||
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
|
||||
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
||||
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
|
||||
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
|
||||
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
|
||||
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
|
||||
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
|
||||
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
|
||||
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
|
||||
{!isCW && (
|
||||
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
|
||||
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
|
||||
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
|
||||
)}
|
||||
{/* RNN and ANFT are on/off only — no level in the API. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
|
||||
<button type="button" disabled={rxOff}
|
||||
title={t('flxp.rnnHint')}
|
||||
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
|
||||
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
RNN
|
||||
</button>
|
||||
{!isCW && (
|
||||
<button type="button" disabled={rxOff}
|
||||
title={t('flxp.anftHint')}
|
||||
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
|
||||
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
ANFT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCW && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||
@@ -830,10 +871,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||
SPE link rather than the Flex amplifier object. */}
|
||||
{isSPE && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · SPE${spe.model ? ' ' + spe.model : ''}`} accent="#ea580c">
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetOperate(!spe.operate).catch(() => {})}
|
||||
onClick={() => AmpOperate(selAmp.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'}
|
||||
@@ -841,10 +883,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPower(true).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
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={() => SPESetPower(false).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.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>
|
||||
{/* Output power level: Low / Mid / High. */}
|
||||
@@ -853,7 +895,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
|
||||
onClick={() => AmpPowerLevel(selAmp.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)}
|
||||
@@ -887,10 +929,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
||||
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
||||
{isACOM && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ACOM${acom.model ? ' ' + acom.model : ''}`} accent="#ea580c">
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})}
|
||||
onClick={() => AmpOperate(selAmp.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'}
|
||||
@@ -899,11 +942,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
is off (the port stays open), so gate on port_open not connected. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||
onClick={() => ACOMSetPower(true).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.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={() => ACOMSetPower(false).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.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')}>
|
||||
@@ -935,6 +978,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{st.amp_available && !isSPE && !isACOM && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
@@ -947,7 +991,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
{ampType === 'pgxl' && (pg.host || pg.connected) && (
|
||||
{selAmp?.type === 'pgxl' && (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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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